mizdra's blog

ぽよぐらみんぐ

GitHub Sponsors 始めました

GitHub Sponsors 始めました。以下のページからスポンサーできます。

github.com

主に僕が作った OSS を使っている方、ブログを読んだりしている方からスポンサーして頂くことを想定しています。僕の OSS や記事がきっかけで、僕を支援をしたいと思った方がいらっしゃれば、是非スポンサー登録をしてみてください。

GitHub Sponsors を始めた理由

ちょっとした OSS を繰り返し作っていった結果、その OSS をメンテナンスする時間が増えてきました。情熱的に OSS 活動に時間を注がれている方と比較するとそうでもないかもしれませんが、個人的にはそこそこ OSS のメンテナンスに時間を費やしている実感があります。

特に eslint-interactivehappy-css-modules は徐々にユーザ数が増えています。多くのユーザを支えるためにも、それなりのコストを掛けてメンテナンスしています。

eslint-interactive と happy-css-modules の GitHub の star 数の推移 (ソース)

eslint-interactive と happy-css-modules の npmjs.com 上の download 数の推移 (ソース)

eslint-interactive と happy-css-modules の commit 数の推移 (ソース1, ソース2)

こうしたメンテナンスを継続的に行うためにも、ちょっとした金銭的な支援を受け取るのもありかなと。そう考えて GitHub Sponsors を始めました。

お金が欲しいというよりは、支援をしたい人から支援を受け取りたくてやってみたかった、というのが正直なところです。ユーザーさんから何かしらの支援のいただけると、それが励みになるかなと思っています。

GitHub Sponsors を始めるにあたって参考にした記事

以下の記事を参考にしました。大変助かりました。

efcl.info

tsc の代替実装は作れるのか

tsc の代替実装を作る話、とりわけ Rust や Go で tsc を高速化した移植版を作る話について。非常に野心的で面白いと思いつつ、正直僕は実用レベルまで達したものが本当に登場するのか疑問に思っている。今ある型システムもそうだし、新機能として追加されるものにも追従する必要がある。当然、実用レベルとして使ってもらうには、不具合も少なくないといけない。

それに tsc も最近はパフォーマンス改善に力を入れているように見えている。実際にリリースノートを見ると、ちょくちょくパフォーマンス改善系の変更が入っている。

tsc に追いつこうと思うなら型システムの実装をする傍ら、こうしたパフォーマンス改善も取り込む必要があるかもしれない。

とはいえ、仕様は tsc を真似すれば良いので、本家 tsc を作るよりは楽だとは思う。tsc にあるものを、そのまま真似すれば良い。例えば、tsc では Node.js ESM を TypeScript でサポートするための仕様 (--module node16) を何年も掛けて練っていたが、移植する側はそれを省略できる。

といっても Microsoft の優秀なエンジニアを何人も動員してやっと作ってるものを真似するには、それと張り合える開発リソースが必要になる。しかし、そんな開発リソース用意できるのかという。圧倒的な個人技™ を持った人、または集団がいれば話は変わってきそうだけど...

stc の開発終了

なぜこんなことに突然思いを馳せているかというと、tsc を Rust に移植するプロジェクトである「stc」が開発終了したから。

開発を終了させた理由も簡潔に述べられていた。

TypeScript was not something that I could follow up on in an alternative language.

https://github.com/swc-project/swc/issues/571#issuecomment-1915966297 より

なんとも侘しい。

Microsoft が別の言語で rewrite するという道

MS が tsc を TypeScript で書くのをやめて、Rust などに rewrite してくれれば話は早いのだけど、どうなんでしょうね。自分たちで言語の問題点を見つけてブラッシュアップするにはドッグフーディングが重要なのだけど。ドッグフーディングと速度どっちを取るかという問題が発生する。

少なくとも、Microsoft は tsc と VS Code の両方を、ドッグフーディングのために TypeScript で書いている (TypeScript のドキュメンタリーの 9:14〜15:46 の部分で言及されている)。

youtu.be

tsc の高速版が欲しい背景と、その解決策

そもそも誰が tsc の高速版が欲しいと言っていたのだろうか。どこかにまとまっている訳ではないけど、界隈の様子を見るに、こういう感じだと思う:

  • 型チェックの時間を短縮したい人
    • 少しでも時間を短縮したいから、単に速いのが好きだから、大規模プロジェクトで型チェックが遅すぎて困っているから、など理由は様々
  • no-floating-promises などの型情報を使った rule を実装したい Linter 開発者
    • deno_lint, biome, oxc
    • TypeScript と他言語で型情報を高速にやり取りをするのが難しいので、別言語 (Rust) 実装が欲しいというモチベーション

前者であれば、tsc そのものが改善していくことである程度要求を満たせるとは思う(もちろん、型チェッカーが爆速であるに越したことはないが)。

後者については、stc のようなものを頼らずに解決を測る動きが出ている。例えば oxc では、「モジュールから export される関数や変数に明示的な型注釈を付ける」という制約を課した上で、型情報を用いた lint を実装方針のようだった。この制約を課せば、モジュールグラフ全体を辿らなくとも、lint に必要な型情報を得られるようになる。つまり、型情報を使った lint を高速に行えるようになる。

biome も Roadmap で似たような計画を発表している。

この辺の話は以下のスライドに詳しくまとまっているので、読んでみてほしい。

lint に掛かる時間に悩まされている大規模なプロジェクトであれば、そうした制約を受け入れる価値があると思う。一方、小規模なプロジェクトはどうなんだろう。本当は型推論を駆使して簡潔に書けるはずなのに、制約のせいで明示的に型注釈を書く必要がある...という煩わしさが少なくともある。小規模プロジェクトでは、速度向上による旨味が少ないので、煩わしさのほうが目立ってしまうかもしれない。

まあでもどうなるのか正直わからないなー。型注釈を書くことをそう煩わしく感じないかもしれない。エディタの言語機能や Copilot が勝手に型注釈を補完してくれたら、それなりに煩わしさも軽減されそう。とりあえず試してみたいかな。

代替実装を作るということ

tsc の代替実装を作るのは非常に難しいし、なくてもなんとかなる、と言ってもそれを作る価値は十分あると思う。速度が改善されることで、今までできなかったことができるようになる。制約なんか取っ払って、型推論効かせまくっても型情報使った高速 linter 作れるぜ! というのは憧れる。本当にできるのなら、是非この目で見てみたい。

まあでも、大変だと思います。

Hohndel氏は最後の質問で、Torvalds氏がLinuxと「Git」に続く別の大規模プロジェクトに取り組むつもりがあるかを聞いた。Torvalds氏は、そうならないことを願っている。

同氏は次のように答えた。「絶対にそうなってほしくない。というのも、これまでに始めたすべてのプロジェクトは、他の人々が不適任であることや金銭目的であることに対するいら立ちがきっかけだったからだ。Linuxを始めた理由は、本物を買う余裕がなかったことだった。そして、こう思った。『どれくらい大変だろうか』と。その答えは『とても大変そう』だ。33年経った今もここにいて、Linuxに取り組んでいるのだから」

トーバルズ氏が語った「XZ Utils」バックドア問題、AIの誇大宣伝 - (page 2) - ZDNET Japan より

あわせて読みたい

zenn.dev

zenn.dev

似ているけどちょっと違うものをスタイリングする CSS テクニック

React + CSS Modules なプロジェクトを例にサンプルコードを書いてるけど、それ以外の構成のプロジェクトでも使えるテクニックのはず。記事タイトルは id:hitode909 さんの 似ているけどちょっと違うものたちをモデリングする技術 - hitode909 のリスペクトです。

data 属性, 属性セレクター, CSS カスタムプロパティを使う

記事のリストがあって、記事のカテゴリごとに文字色やボーダーの色を変えたい、みたいな状況だったとする。多分素朴に書くと以下のようになるはず。

import styles from './Entry.module.css';
import clsx from 'clsx'
function Entry(props) {
  return (
    <div className={
      clsx(props.category === '暮らし' ? styles.categoryLife
         : props.category === '政治' ? styles.categoryPolitics
         : props.category === '技術' ? styles.categoryTechnology
         : null)
    }>
      {/* ... */}
    </div>
  );
}
.categoryLife {
  color: red;
  border: 1px red solid;
}
.categoryPolitics {
  color: blue;
  border: 1px blue solid;
}
.categoryTechnology {
  color: yellow;
  border: 1px yellow solid;
}

ただ、これだと .categoryXxx { ... } ごとに color, border のプロパティの値を定義しないといけない。特に border1px ... solid の部分は、どのカテゴリでも同じにも関わらず、カテゴリごとに書かないといけない。

これではイマイチということで、id:mizdra はよく以下のように書いてる。

import styles from './Entry.module.css';
import clsx from 'clsx'
function Entry(props) {
  // ...
  return (
    <div
      className={styles.container}
      data-category={props.category}>
      {/* ... */}
    </div>
  );
}
.container {
  color: var(--category-color);
  border: 1px var(--category-color) solid;
}
.container[data-category="暮らし"] {
  --category-color: red;
}
.container[data-category="政治"] {
  --category-color: blue;
}
.container[data-category="技術"] {
  --category-color: yellow;
}

data 属性属性セレクターCSS カスタムプロパティ を使っているのがポイント。どれも 2016 年頃から全てのメジャーブラウザに実装されてる機能なので *1、ほとんどのサービスで利用できるはず。

自由度も結構高い。やろうと思えば、CSS カスタムプロパティは複数設定できるし、特定のカテゴリでだけ適用したいプロパティも定義できる。

.container {
  color: var(--category-text-color);
  border: 1px var(--category-border-color) solid;
}
.container[data-category="暮らし"] {
  /* テキスト用の色とボーダー用の色を別々に定義 */
  --category-text-color: red;
  --category-border-color: red;
  /* 「暮らし」カテゴリにだけ適用したいプロパティを定義 */
  border-radius: 30px;
}
.container[data-category="政治"] {
  --category-text-color: blue;
  --category-border-color: blue;
}
.container[data-category="技術"] {
  --category-text-color: yellow;
  --category-border-color: yellow;
}

コラム: CSS Nesting と組み合わせる

一応 CSS Nesting を使うと以下のようにも書ける。こちらは去年メジャーブラウザに実装されたばかりの機能なので、古いブラウザでも動かしたい場合は postcss でトランスパイルする必要がある。

.container {
  color: var(--category-color);
  border: 1px var(--category-color) solid;

  &[data-category="暮らし"] {
    --category-color: red;
  }
  &[data-category="政治"] {
    --category-color: blue;
  }
  &[data-category="技術"] {
    --category-color: yellow;
  }
}

style 属性を使って CSS カスタムプロパティを定義する

カテゴリの数が有限、つまり決まった数しかないなら上記の方法で問題ない。しかしユーザがカテゴリを自由に増やせてカテゴリカラーも好きに選べる場合は、上記の方法だけでは上手くいかない。

そういう時はどうするかというと、バックエンドからカテゴリカラーをフロントエンドに渡せるようにした上で、カテゴリカラー用の CSS カスタムプロパティを style 属性で定義すれば良い。

import styles from './Entry.module.css';
import clsx from 'clsx'
function Entry(props) {
  // props.categoryColor にはバックエンドから引いた色の情報が文字列で入っている (例`"#FF0000"`)
  return (
    <div
      className={styles.container}
      style={{ '--category-color': props.categoryColor }}>
      {/* ... */}
    </div>
  );
}
.container {
  color: var(--category-color);
  border: 1px var(--category-color) solid;
}

コラム: attr(<attribute-name> color) と組み合わせる

人によっては、style 属性の中にカスタムプロパティを定義するのを避けたく思うかもしれない。--category-color というカスタムプロパティがどこからやってきたのか、どこで値が定義されているのかが .css を見ただけでわかるようにしたい、とかそういう考えを持っていると、そう思うのではないか。

一応将来的には attr(<attribute-name> color) を使えば、以下のようにカスタムプロパティの定義も .css 側へと追いやれる。

import styles from './Entry.module.css';
import clsx from 'clsx'
function Entry(props) {
  // props.categoryColor にはバックエンドから引いた色の情報が文字列で入っている (例`"#FF0000"`)
  return (
    <div
      className={styles.container}
      data-category-color={props.categoryColor}>
      {/* ... */}
    </div>
  );
}
.container {
  color: attr(data-category-color color);
  border: 1px attr(data-category-color color) solid;
}

けどまだ attr(<attribute-name> color) をサポートしているメジャーブラウザは存在しないので、使おうにも使えない。あとそもそもカスタムプロパティの定義が .tsx から .css に移動しただけで、本質的にはやっていることは変わらないので、この書き方が特別良いかというと意見が分かれるかも...

fetch の中断と Back/Forward Cache からの復元で発生する奇妙な現象について

TL;DR

  • あるリソースの fetch 中にページ遷移すると、一部ブラウザでは fetch が中断される
  • 中断されると、TypeError が throw される
  • ページ遷移時は、ブラウザによって遷移前のページの実行が"停止"され、"捨てられる"ので、通常 throw された後のことは考えなくて良い
    • しかし、そのページが Back/Forward Cache から復元されうるなら、話は別
  • ブラウザバックすると、エラーが throw された後からページが再開される!!!
    • そして発生する、奇妙な現象の数々...

はじまりは、あるサービスの不具合報告

ある日、「Webサービスから外部サービスにページ遷移した後、ブラウザバックで戻ると、エラー画面が表示される」という不具合が報告された。どうも Webサービスの ErrorBoundary で何かしらのエラーが catch され、それによってエラー画面に切り替わっているようだった。しかもページ遷移するタイミングによって、再現する時としない時があるという。

よく調べてみると、以下の手順で再現することが分かった。

  1. Webサービスのページ (ページA) を開く
  2. 上記ページ上でアクセス直後に、外部サービスへと遷移する
  3. 外部サービスのページ (ページB) が開かれる
  4. ブラウザバックでページAへと戻る
  5. Webサービスのエラー画面が表示される

何が起きているのか

重要なこととして、ページAは Back/Forward Cache (bfcache) に対応したページだった。bfcache に明るくない人にも補足しておくと、bfcache はページ遷移時に遷移元のページの状態をメモリにキャッシュしておき、ブラウザバックで戻ってきた時にその状態からページを再開する機能である。これにより、ユーザは高速にページ遷移でき、input 要素に入力していた内容も残った状態から再開できる。より詳しくは web.dev の記事を参照してほしい。

web.dev

また、我々が開発している Web サービスは SSR 無しで動く SPA だった。ブラウザ側で fetch API でバックエンドにリクエストし、その結果をもとにコンテンツを表示している。よってページAにアクセスした直後は、fetch API で様々なデータの取得が行われている最中だった。

話を戻すと、つまりはこういうことが起きていた。

  1. Webサービスのページ (ページA) を開く
  2. バックエンドに対して fetch でリクエストを投げる
  3. 上記ページ上でアクセス直後に、外部サービスへと遷移する
    • ページ遷移により、fetch が中断された
    • 中断されると、 TypeError が発生する
    • それを ErrorBoundary が catch し、エラー画面に切り替わった
    • そして、そのエラー画面に切り替わった状態で bfcache に保存された
  4. 外部サービスのページ (ページB) が開かれる
  5. ブラウザバックでページAへと戻る
    • ページAは bfcache に対応しているので、bfcache から復元された
  6. Webサービスのエラー画面が表示される

ページ遷移によって fetch が中断されてエラーが throw され、ブラウザバックした際にその状態からページが再開される ことで、こういった奇妙な現象が起きていたようだった。再現が確率的なのも、ページ遷移が fetch 中かそうでないかによって再現するかどうかが決まるためだった。

修正

妥当な修正方法から、やんちゃなものまで、色々考えられる。

  • 案1: bfcache をやめる
  • 案2: bfcache から復元された時にエラー画面が出ていたら、ページをリロードする
  • 案3: fetch がエラーになっても何度か retry する
    • retry 中はエラーを ErrorBoundary に伝えない。最後まで retry して駄目だったら伝える。
    • これであればページ遷移時に 1 度エラーになってもエラー画面は出ない
      • ブラウザバックで戻ってきた時に、retry される
  • 案4: fetch でエラーになってもエラー画面に切り替えない
    • 画面全体を「エラーが発生しました」に切り替えて操作不能にしてしまうのではなく、レンダリングできるところまでやる戦略
      • これなら仮にエラーになっても、ユーザへの影響を最小限にできる
    • 丁寧ではあるけど、中途半端にレンダリングが行われて、表示上の不整合が出たりと、面倒なことが多い
    • 我々の Web サービスではそこまで丁寧にやらないポリシーだったので、これは避けたかった

今回は 案3 の方法で修正した。多分真っ当な修正方法だと思う。そもそも日常生活の中で一瞬オフラインになって fetch がネットワークエラーを投げることはままあるし、そういう意味でも retry する機構はあったほうが良いだろう。

もし他に良い修正方法があれば教えてほしい!

ページ遷移時に fetch を中断するかは、ブラウザ次第

どうも、この挙動はブラウザによって統一されてないらしい。Chrome はページ遷移しても fetch を中断せず、裏で継続してくれるようだった。そのため、Chrome では上記不具合は再現しなかった。

Chrome を用いて fetch 中にページ遷移した際の挙動をキャプチャした映像。ページ遷移した後ブラウザバックしても fetch は継続されていて、一定時間後にレスポンスが画面に表示されている。 Firefox を用いて fetch 中にページ遷移した際の挙動をキャプチャした映像。ページ遷移した後ブラウザバックすると、エラーが画面に表示されている。 Safari を用いて fetch 中にページ遷移した際の挙動をキャプチャした映像。ページ遷移した後ブラウザバックすると、エラーが画面に表示されている。

fetch 中にページ遷移した際の挙動をキャプチャした映像。上から Chrome、Firefox、Safari。

Chrome とそれ以外のブラウザ、どちらが仕様に準拠した挙動なのか気になって調べてみたけど、そこまで細かい挙動は仕様で定められておらず、どちらも仕様に準拠した挙動らしい。

なんとなく統一したほうがユーザの混乱を生まない気はするけど、どうなんでしょうね。まあ Chrome の挙動も、それ以外のブラウザの挙動も、どちらも妥当ではありそう。

上記の Issue を読んだ限りでは、挙動の統一に関する議論まではされてなさそうな雰囲気だった。

throw されるエラーの特徴

https://github.com/mizdra/bfcache-and-page-transitions-during-fetching-test で観察してみたところ...

  • エラーオブジェクトの種類
  • error.message
    • Firefox: NetworkError when attempting to fetch resource.
    • Safari: Load failed
  • その他特徴
    • スタックトレースが一切ない

「スタックトレースが一切ない」というのが大きな特徴。例えば、ネットワークがオフラインになったことにより fetch が中断された場合は、以下のようにスタックトレース付きのエラーが throw される *1

TypeError: Failed to fetch
    at fetchResource (http://localhost:3000/1-basic:14:13)
    at HTMLButtonElement.<anonymous> (http://localhost:3000/1-basic:32:7)

しかし、ページ遷移で fetch が中断されて throw されたエラーには、スタックトレースが含まれない。

TypeError: NetworkError when attempting to fetch resource.

詳細は不明だが、多分ページをまたぐことで、なんやかんやあってスタックトレースが吹き飛んでいるのだと思う。これがブラウザのバグなのか、仕様なのかはよく分かってない。

意外と色々なところで起きている

多分似たようなことが様々なプロダクトで起きてると思う。社内でも、TypeError: Load failed, TypeError: NetworkError when attempting to fetch resource. というキーワードで Sentry の Issue を検索すると、それらしい Issue がいくつか見つかった。

折角なので、この機会に自分のプロダクトを見直してみると良いと思う。昔からエラーが報告されていたものの、スタックトレースが空で何が起きているかの原因が掴めなかった...というのが実はこれかもしれない。

以下に Sentry に届いたエラーが本現象に起因するものかどうかを見極めるポイントを書いておく。

  • エラーの name/messsage が TypeError: Load failedTypeError: NetworkError when attempting to fetch resource. になってる
  • スタックトレースが空になっている
  • エラーの発生ページが bfcache に対応している
    • Chrome なら、devtools の Appliaction > Background services > Back/forward Cache から bfcache 対応か確認できる
    • あとは pageshow イベントで得られる event.persisted を見るとか
    • bfcache される条件はブラウザによって微妙に違うので、後者で確認するのがより正確だが、手軽に確認するなら前者がオススメ
  • fetch 中にページ遷移したという行動履歴が Breadcrumbs に残っている
    • 何かしらのリソースを fetch 中にリンクをクリックしている、みたいな行動履歴とか

もし本現象が起きていたら、fetch を retry する仕組みを入れたり、あるいは retry 機構が組み込まれた fetch ライブラリを使ってみるなどを検討してみると良いかもしれない。

まとめ

  • fetch 中にページ遷移すると、(Firefox と Safari では) fetch が中断され、エラーが throw される
  • bfcache 対応ページだと、ブラウザバック時にエラーが throw された状態からページが再開されうる
  • fetch は retry しよう

追記 (2024-11-06)

Firefox と Safari にそれぞれ不具合として報告しておいた。

*1:Firefox のエラー出力を例に取り上げている

ESLint の Suggestions から学ぶ、コードの自動修正の奥深さ

これは、はてなエンジニアアドベントカレンダー2023 4日目の記事です。

3日目は id:mechairoi さんの「SQLiteでLinderaを使った日本語全文検索」でした。

blog.chairoi.me


今日のテーマは、JavaScript 向けの Linter 「ESLint」についてです。ESLint を使うと、JavaScript で書かれたコードを静的解析して、よくある間違いを検出したり、コーディングスタイルを統一できます。

通常、ESLint のルールによって報告された問題 (error や warn) は人が手で修正します。ただし、ルールが報告する問題の中には「fixable」な性質を持ったものがあります。こうした fixable な問題は、eslint --fix で自動修正できます。例えば、object-shorthand ルールによって報告された問題は、以下のように自動修正できます。

 const foo = 'str1';
 const obj = {
-   foo: foo,
+   foo,
   bar: 'str2',
 };

エディタの ESLint の拡張機能を使えば、ファイルの保存時に自動修正を適用する、なんてこともできます。

fixable なもの、fixable ではないもの

基本的に ESLint は、安全に修正できる問題にしか自動修正を提供しません。噛み砕いて言うと、コードの実行時の挙動を変えない修正方法がある問題だけが fixable になってます。

これは sort-imports ルールを触ってみると分かりやすいと思います。sort-imports は「import 文の { ... } の中がソートされているか」「from '...' で指定されたモジュール名がアルファベット順になるよう import 文がソートされているか」を検証するルールです。このうち前者は fixable ですが、import 文の順序を変えるとモジュールの評価順が変わり、実行時の挙動が変わる恐れがあるため、後者は fixable ではありません。

/*eslint sort-imports: "error"*/
import {d, c} from 'foo.js';
//      ^^^^
// `c, d` の順で書けとエラーが報告される。これは fixable。
import {a, b} from 'bar.js';
//                  ^^^^^^
//  `bar.js` は `foo.js` よりも前で import しろとエラーが報告される。
//  これは fixable ではない。

Suggestions

とはいえコードを自動修正できれば、日々のコーディングの手間を軽減できます。ときには実行時の挙動が変わってしまうことを覚悟してでも、自動修正したいこともあるでしょう。

そうしたケースのために、ESLint は「Suggestions」という機能を提供してます。

suggestions を持つ問題は、fixable な問題と同じようにコードを自動修正できますが、eslint --fix からはできません。エディタ上から手動でのみ自動修正を適用できます。

そんな問題を報告するルールあったっけ? と思っている人も多いかもしれませんが、皆さんご存知 eslint-plugin-react-hooks plugin の exhaustive-deps ルールが、まさにそれです。useEffect の第二引数の書き換えは実行時の挙動を変える恐れがあるため、この修正が suggestions として提供されてます。

exhaustive-deps ルールによる自動修正の様子。VS Code の Code Action のメニューからボタンを押すことで修正を適用する。
exhaustive-deps ルールによる自動修正の様子。VS Code の Code Action のメニューから手動で修正を適用する。

fixable な問題と違い、CLI から一括で適用するコマンドは用意されていません *1。本質的に安全ではない書き換えなので、人の目で確かめてから適用せよ、という訳です。

自動修正の候補が複数あるものも扱える

Suggestions が扱う自動修正は、なにも「実行時の挙動を変える恐れがあるもの」だけではありません。Suggestions は「問題の自動修正の候補が複数あるもの」も扱えます。

例えば no-useless-escape ルールは "\'" というコードを "'""\\'" のどちらかに修正できると suggestions を使って報告してくれます。

エディタの Code Action のメニューに自動修正の候補が複数表示される様子。

自動修正先が複数あるというの、言われてみたらたしかにあるか、という気持ちになりますが、普通そんなものが存在すると考えることもないと思います。こうしたエッジケースも ESLint でカバーされているというのは面白いですね。

そうは言っても Suggestions も一括適用したい!

「安全ではないから」「人がどれを適用するか見極める必要があるから」。そういった理由で suggestions は CLI から一括で自動修正を適用する方法が適用されていない訳ですが、そうはいっても一括で適用したい時はあるでしょう。

例えば sort-imports。import 文のソートをしない気持ちはまあ分かりますが...別にソートしたって大抵は挙動は変わりません。polyfill として読み込んでいるモジュールを先頭にすることさえ気をつければ、あとは自由に並べ替えてよいケースが多いでしょう。そうしたプロジェクトでは、挙動が変わるのを覚悟の上で一括適用する手段が欲しくなります。

...というような声を受けてか、3rd part の ESLint plugin では「安全ではない自動修正」を fixable として扱うものがあります。eslint-plugin-importorder ルールとかがそうです。

また、ESLint の Node.js API から suggestions の情報を取れるので、それを使って suggestions の一括適用ツールのようなものも作れます。というか作りました。大量の ESLint の問題を効率よく修正するeslint-interactive というツールがあるのですが、これに suggestions を一括適用するモードを実装してます。一括適用モードを起動すると suggestions をエディタ *2 でスクリプトファイルが開かれます。このスクリプトファイルでどの suggestions を適用するか、捨てるかを記述します。完成したらスクリプトファイルを保存して閉じると、一括適用が走ります (git commit する時にエディタが開いて commit message を編集できるあの機能を真似て作ってます。)。 格好良いですね。

Image from Gyazo

www.mizdra.net

余談: Biome における自動修正の分類

Biome には ESLint の Suggestions のような機能はありませんが、自動修正を安全なもの (safe fixes) と安全でないもの (unsafe fixes) に分けて扱うようになってます。safe fixes だけを適用する biome check --apply とは別に、safe fixes と unsafe fixes の両方を適用する biome check --apply-unsafe が用意されています。ESLint では安全ではない自動修正を CLI から一括適用することは (3rd-party の plugin を使わない限り) できないので、ここは Biome ならではという感じがします。

biomejs.dev

余談: Suggestions の実装の経緯

exhaustive-deps ルールが実装された当初は ESLint に Suggestions はありませんでした。しかしそれでは困るということで React 開発チームから ESLint 側に要望が出され、Suggestions が実装されたという背景があります *3。Suggestions の RFC の共著者に Dan Abramov が名を連ねているのはそのためです。

Authors: Ilya Volodin (@ilyavolodin), Dan Abramov (@gaearon) https://github.com/eslint/rfcs/blob/a6497bf1053e9a7ab30803ebbf405e9a91bafa40/designs/2019-suggestions/README.md#summary

余談: Suggestions が扱えるその他の自動修正

「実行時の挙動を変える恐れがあるもの」「問題の自動修正の候補が複数あるもの」以外にも、「自動修正はできるが、適用するかは議論が分かれる (opinionated) もの」なども suggestions で扱うのが適しています。他にも色々あるかもしれません。基本的には人が目で見て適用するか確認すべきものであれば、Suggestions で扱うべきでしょう。

まとめ

ひとえに自動修正、といっても色々あることを紹介しました。皆さんも Linter を触ったり、カスタムルールを書いたりする時に、ここで紹介したことを思い出してもらえればと思います。

以上、はてなエンジニアアドベントカレンダー2023 4日目の記事でした。 次は id:maku693 さんです。

*1:Suggestions の RFC でも、そのようなコマンドを用意しないと明に述べられています。 https://github.com/eslint/rfcs/blob/a6497bf1053e9a7ab30803ebbf405e9a91bafa40/designs/2019-suggestions/README.md#summary

*2:EDITOR 環境変数で指定しているエディタが起動します。

*3:もちろん、Suggestions 相当の機能の提案自体は昔から ESLint の issue で議論されていましたが、細かい設計や機能の形は決めきれていませんでした。そこに React 開発チームによる貢献があり、RFC が作られ、実装が開始されました。

GraphQL のレスポンスのモックデータの作成を補助する TypeScript ライブラリを作った

GraphQL を使って Web アプリケーションを実装していると、GraphQL API のリクエストをモックしたいことがあると思います。

  • ユニットテストのために、ダミーレスポンスに差し替えたい
  • ビジュアルリグレッションテストのために、ダミーレスポンスに差し替えたい
  • Storybook で story を書くために、ダミーレスポンスに差し替えたい
  • バックエンドの resolver 実装を待たずにフロントエンド側の開発を始めるために、ダミーレスポンスに差し替えたい

一般には GraphQL Client にモックするための機能が実装されてるので、そうしたものを使うことが多いと思います。

zenn.dev

また最近は Client よりも外側のレイヤーでリクエストを interrupt してモックする「msw」を使うケースも増えてきてます *1

blog.engineer.adways.net

モックデータ作成に手間がかかる

モックする時によく煩わしく感じるのが、モックデータの作成です。例えばビジュアルリグレッションテストのために GraphQL API をモックする場合、期待通りにぽページが描画されるようなモックデータを作成する必要があります。

import { graphql } from "msw";

// msw を使ったモックの例
export const handlers = [
  graphql.query("BookListPageQuery", (req, res, ctx) => {
    return res(
      ctx.data({
        books: [
          {
            __typename: "Book",
            id: "Book-0",
            title: "ゆゆ式 1巻",
            author: {
              __typename: "Author",
              id: "Author-0",
              name: "三上小又",
            },
          },
          {
            __typename: "Book",
            id: "Book-0",
            title: "ゆゆ式 2巻",
            author: {
              __typename: "Author",
              id: "Author-0",
              name: "三上小又",
            },
          },
        ],
      })
    );
  }),
  graphql.query("BookDetailPageQuery", (req, res, ctx) => {
    return res(
      ctx.data({
        book: {
          __typename: "Book",
          id: "Book-0",
          title: "ゆゆ式 1巻",
          author: {
            __typename: "Author",
            id: "Author-0",
            name: "三上小又",
          },
        },
      })
    );
  }),
];

ただ、このようにモックデータをベタ書きしていくと、コードの重複が増えていきます。例えば上記の例では、id: "Book-0" の node と id: "Author-0" の node が重複しています。今くらいの行数なら大したことはないですが、数が増えてくると可読性やメンテナンス性に影響が出てきます。

一般にはこうした問題を回避するために、type 単位のダミーレスポンスを作成する補助関数 (factory みたいなやつ) を自作している人が多いんじゃないかと思います。

// 事前に graphql-code-generator の typescript plugin で type ごとの型定義を生成しておく
import { Book, Author } from './__generated__/type.ts';
// Book: `{ __typename: 'Book', id: string, title: string, author?: Author }`

function fakeBook(args?: Partial<Book>): Book {
  return {
    __typename: 'Book',
    id: 'Book-0',
    title: 'ゆゆ式 0巻',
    ...args,
  };
}
function fakeAuthor(args?: Partial<Author>): Author {
  return {
    __typename: 'Author',
    id: 'Author-0',
    name: '0上小又',
    ...args,
  };
}

// こうやって使う
fakeBook({
  title: 'ゆゆ式 1巻',
  author: fakeAuthor({
    name: '1上小又',
  }),
});
// {
//   __typename: 'Book',
//   id: 'Book-0',
//   title: 'ゆゆ式 1巻',
//   author: { __typename: 'Author', id: 'Author-0', name: '1上小又' },
// }

これで幾分か楽になりますが、factory 関数を時前で実装したりメンテナンスしていくのは手間です。また、「id をオートインクリメントしつつモックデータを作成したい」「Book を N 個詰め込んだ配列を作って欲しい」など等色々な要件が出てくると、factory 関数の実装が複雑になってきます。

世の中には factory 関数を自動生成してくれる graphql-codegen-typescript-mock-data という GraphQL Code Generator のプラグインがあったりするのですが、微妙に使い勝手が悪く、個人的には理想的なものではないと感じています。

そこで graphql-codegen-typescript-fabbrica

もっと factory 関数を簡単に実装できるようにしたいと思い、graphql-codegen-typescript-fabbrica を作りました。

graphql-codegen-typescript-fabbrica は、factory 関数を定義するための utility を生成する GraphQL Code Generator プラグインです。これを使うと以下のような factory 関数を定義できます。

import { defineBookFactory, defineAuthorFactory, dynamic } from '../__generated__/fabbrica';
import { faker } from '@faker-js/faker';

const BookFactory = defineBookFactory({
  defaultFields: {
    __typename: 'Book',
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(() => faker.word.noun()),
    author: undefined,
  },
});
const AuthorFactory = defineAuthorFactory({
  defaultFields: {
    __typename: 'Author',
    id: dynamic(({ seq }) => `Author-${seq}`),
    name: dynamic(() => faker.person.firstName()),
    books: undefined,
  },
});

Factory の build() メソッドを呼び出すと、モックデータを生成できます。生成されるモックデータは、defaultFieldsbuild() メソッドに渡された field の値に応じて、厳密に型付けされます。

// simple
const book0 = await BookFactory.build();
expect(book0).toStrictEqual({
  __typename: 'Book',
  id: 'Book-0',
  title: expect.any(String),
  author: undefined,
});
expectTypeOf(book0).toEqualTypeOf<{
  __typename: 'Book';
  id: string;
  title: string;
  author: undefined;
}>();

// nested
const book1 = await BookFactory.build({
  author: await AuthorFactory.build(),
});
expect(book1).toStrictEqual({
  __typename: 'Book',
  id: 'Book-1',
  title: expect.any(String),
  author: {
    __typename: 'Author',
    id: 'Author-0',
    name: expect.any(String),
    books: undefined,
  },
});
expectTypeOf(book1).toEqualTypeOf<{
  __typename: 'Book';
  id: string;
  title: string;
  author: {
    __typename: 'Author';
    id: string;
    name: string;
    books: undefined;
  };
}>();

便利な機能一覧

factory 関数を片手間で自作するのでは実現が難しい、多数の便利な機能を提供しています。基本的には FactoryBot, prisma-fabbrica から輸入してきた機能です。

Sequences

seq パラメータを使うと、連番のモックデータを生成できます。id の生成に使うと便利です。

const BookFactory = defineBookFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(async ({ seq }) => Promise.resolve(`ゆゆ式 ${seq}巻`)),
  },
});
expect(await BookFactory.build()).toStrictEqual({
  id: 'Book-0',
  title: 'ゆゆ式 0巻',
});
expect(await BookFactory.build()).toStrictEqual({
  id: 'Book-1',
  title: 'ゆゆ式 1巻',
});

Dependent Fields

get 関数を使うと、別の field に依存した値を生成できます。ある field の値から別の field の値を自動生成したい時に便利です。

const UserFactory = defineUserFactory({
  defaultFields: {
    name: 'yukari',
    email: dynamic(async ({ get }) => `${(await get('name')) ?? 'defaultName'}@yuyushiki.net`),
  },
});
expect(await UserFactory.build()).toStrictEqual({
  name: 'yukari',
  email: 'yukari@yuyushiki.net',
});
expect(await UserFactory.build({ name: 'yui' })).toStrictEqual({
  name: 'yui',
  email: 'yui@yuyushiki.net',
});

Building lists

buildList 関数を使うと、モックデータを N 個詰め込んだ配列を生成できます。

const BookFactory = defineBookFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(({ seq }) => `ゆゆ式 ${seq}巻`),
  },
});
expect(await BookFactory.buildList(3)).toStrictEqual([
  { id: 'Book-0', title: 'ゆゆ式 0巻' },
  { id: 'Book-1', title: 'ゆゆ式 1巻' },
  { id: 'Book-2', title: 'ゆゆ式 2巻' },
]);

Associations (関連する type のモックデータの同時生成)

defaultFields には別の Factory を使ってモックデータを生成する関数を渡せます。これにより、関連する type のモックデータを同時に生成できます。セットで使うような type 同士の factory 関数を定義するのに便利です。

const BookFactory = defineBookFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(({ seq }) => `ゆゆ式 ${seq}巻`),
    author: undefined,
  },
});
const AuthorFactory = defineAuthorFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Author-${seq}`),
    name: dynamic(({ seq }) => `${seq}上小又`),
    // 関連する Book type のモックデータを生成
    books: dynamic(async () => BookFactory.buildList(1)),
  },
});
expect(await AuthorFactory.build()).toStrictEqual({
  id: 'Author-0',
  name: '0上小又',
  books: [{ id: 'Book-0', title: 'ゆゆ式 0巻', author: undefined }],
});

Transient Fields

Transient Fields は factory 関数内でのみ利用可能な field を定義する機能です。build() の引数に渡す事はできますが、返り値には含まれない特殊な field です。get 関数と組み合わせると、結構面白いことができます。

Traits

Traits は field のデフォルト値をグループ化しておく機能です。TypeFactory.use('traitName').build() と書くと、そのデフォルト値を適用したモックデータが得られます。

よく画像を表す Image type などを GraphQL スキーマで定義することがあると思いますが、「ユーザアバターを生成する trait」をそれぞれ定義しておけば、ImageFactory.use('avatar').build() で簡単にユーザアバターのモックデータを生成できます。

import I_SPACER from '../assets/spacer.gif';
import I_AVATAR from '../assets/dummy/avatar.png';
import I_BANNER from '../assets/dummy/banner.png';

const ImageFactory = defineImageFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Image-${seq}`),
    url: I_SPACER.src,
    width: I_SPACER.width,
    height: I_SPACER.height,
  },
  traits: {
    avatar: {
      defaultFields: {
        url: I_AVATAR.src,
        width: I_AVATAR.width,
        height: I_AVATAR.height,
      },
    },
    banner: {
      defaultFields: {
        url: I_BANNER.src,
        width: I_BANNER.width,
        height: I_BANNER.height,
      },
    },
  },
});
expect(await ImageFactory.build()).toStrictEqual({
  id: 'Image-0',
  url: I_SPACER.src,
  width: I_SPACER.width,
  height: I_SPACER.height,
});
expect(await ImageFactory.use('avatar').build()).toStrictEqual({
  id: 'Image-1',
  url: I_AVATAR.src,
  width: I_AVATAR.width,
  height: I_AVATAR.height,
});
expect(await ImageFactory.use('banner').build()).toStrictEqual({
  id: 'Image-2',
  url: I_BANNER.src,
  width: I_BANNER.width,
  height: I_BANNER.height,
});

おわりに

ミニマムな API セットながらも、いくつもの強力な機能が実装された面白いライブラリなんじゃないかと思います。ぜひ使ってみてください。

*1:msw は Node.js、ブラウザ両方で任意のリクエストを interrupt できるライブラリです。Node.js 上では fetch などネットワークリクエスト API にモンキーパッチを当てて、ブラウザでは ServiceWorker を使ってリクエストに介入し、モックを実現してます。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.