mizdra's blog

ぽよぐらみんぐ

似ているけどちょっと違うものをスタイリングする 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 しよう

*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 を使ってリクエストに介入し、モックを実現してます。

react-relay の useFragment で本来取得できるはずの field が欠損する現象について

react-relay を使っていると、稀に「本来取得できるはずの field が欠けた状態のオブジェクトが useFragment から返される」現象に遭遇することがあります。

例えば、以下のようなエントリーのタイトルと本文を表示するエントリーページがあったと仮定します。デフォルトではタイトルのみが表示されていて、「本文を表示」ボタンを押すと、GraphQL API から本文が fetch されて表示されます。

import {PreloadedQuery, usePreloadedQuery, graphql, useFragment, useLazyLoadQuery} from 'react-relay';
import { useLocation, AppInternalError, AppNotFoundError } from '../util';
import { type BlogTopPageQuery } from './__generated__/EntryPageQuery.graphql';
import { type EntryHeader_entry$key } from './__generated__/EntryHeader_entry.graphql';

import { type EntryBodyQuery } from './__generated__/EntryBodyQuery.graphql';

export function EntryPage () {
  const { query: { entryId } } = useLocation(); // entryId === 'entry:1'
  if (typeof entryId !== 'string') throw new AppInternalError('entryId(${entryId}) is not string');

  const { entry } = useLazyLoadQuery<BlogTopPageQuery>(graphql`
    query BlogTopPageQuery($entryId: ID!) {
      entry(id: $entryId) {
       ...EntryHeader_entry
      }
    }
  `, { entryId });
  if (entry === null) throw new AppNotFoundError(`Entry(${entryId}) not found`);

  return (
    <article>
      <EntryHeader entry={entry} />
      <EntryBody entryId={entryId} />
    </article>
  );
}

type EntryHeaderProps = {
  entry: EntryHeader_entry$key,
};
function EntryHeader(props: EntryHeaderProps) {
  const data = useFragment(graphql`
    fragment EntryHeader_entry on Entry {
      title
    }
  `, props.entry);
  return <h2>{data.title.toUpperCase()}</h2>;
}

const EntryBodyQuery = graphql`
  query EntryBodyQuery(entryId: ID!) {
    entry(id: $entryId) {
      body
    }
  }
`;
type EntryBodyProps = {
  entryId: string,
};
function EntryBody(props: EntryBodyProps) {
  const [queryReference, loadQuery] = useQueryLoader<EntryBodyQuery>(EntryBodyQuery);

  if (queryReference === null) {
    return <div><button onClick={() => loadQuery({ entryId: props.entryId })}>本文を表示</button></div>
  }
  return (
    <div>
      <Suspense fallback="Loading...">
        <EntryBodyInner queryReference={queryReference} />
      </Suspense>
    </div>
  );
}

type EntryBodyInnerProps = {
  queryReference: PreloadedQuery<EntryBodyQuery>,
};
function EntryBodyInner(props: EntryBodyInnerProps) {
  const data = usePreloadedQuery<EntryBodyQuery>(EntryBodyQuery, props.queryReference);
  return <div>{data.body}</div>;
}

このページは、全部で 2 回クエリが fetch される機会があります。

  1. ページにアクセスした直後に fetch される BlogTopPageQuery
    • 例: entry(id: "entry:1") { title }
  2. 「本文を表示」を押した時に fetch される EntryBodyQuery
    • 例: entry(id: "entry:1") { body }

しかしこの EntryBodyQuery の fetch が行われると、その直後に EntryHeader コンポーネント内で Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase') という実行時エラーが発生します。

  • TODO: あとでサンプルアプリケーションの URL を貼る

なぜ実行時エラーが起きるのか

この現象は、1 のクエリと 2 のクエリを実行した時に、Relay Client 内の id: entry:1 の node のキャッシュと <EntryHeader> 内で実行している useFragment の戻り値が、以下のように変化することに起因してます。

  1. BlogTopPageQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは" }
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  2. EntryBodyQuery が fetch される
    • cache: id: "entry:1", data: { body: "大吉でした" }
    • <EntryHeader>useFragment の戻り値: { }
  3. <EntryHeader> にて data.title === undefined となり、Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase') が出る

なんと後続のクエリで同一 id の node を fetch すると、前のクエリで fetch したフィールドの情報を吹き飛ばしてしまうんですね。マジかよ。

バグではなく仕様

公式の Issue を漁ってみたところ、以下でこの現象について議論されているようでした。

github.com

Relay の開発チームからのコメントも寄せられているのですが、それによるとこの現象は開発チームの間で「missing data」と呼ばれていて、「バグではなく仕様である」そうです。マジかよ。

長々と書いてありますが、簡単に主張をまとめると以下のようになります。

  • クライアントサイドのキャッシュを扱う際に、以下の3つの要素が求められる
    • データの完全性 (各コンポーネントが要求したすべてのデータを持っているか)
    • 一貫性 (すべてのコンポーネントが一貫した世界観を表現しているか)
    • パフォーマンス (適切な量のデータでこれらの性質を達成できるか)
  • ただしキャッシュの性質上、3つのうち最大2つまでしか同時に達成できない
  • missing data 現象においては、一貫性とパフォーマンスを優先し、完全性を諦める方針を取ってる

完全性を優先して、一貫性かパフォーマンスのどちらかを諦めた場合どうなるかを想像すると、より理解しやすいかなと思います。例えば完全性とパフォーマンスを取ることととし、キャッシュをマージする実装にした場合、途中でデータの更新が起きた際に一貫性が失われることになります。

  1. BlogTopPageQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは" }
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  2. ユーザがエントリーを以下のように更新した
    • {title: "2023年のおみくじは", body: "小吉でした" }
  3. EntryBodyQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは", body: "小吉でした" } (注: 前のキャッシュとマージしてる)
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  4. ページの表示が「2022年のおみくじは / 小吉でした」になる
    • 2023 年は大吉であるはずなので、データの不整合が発生してる (一貫性の喪失)

逆に完全性と一貫性を取ろうと思うと、常にエントリーが更新されていないか fetch し続ける必要があり、パフォーマンスの問題が発生します。

どちらも Relay としては許容できないということで、一貫性とパフォーマンスを優先し、完全性の欠如 (missing data 現象) を許容する選択を取っている訳です。

完全性の欠如に対する緩和策

とはいえ、突然実行時エラーが起きるようでは困ります。その緩和策として、Relay 開発チームは「後続のクエリに、前のクエリの fragment を含めて fetch せよ」と主張してます *1。つまり、EntryBodyQueryEntryHeader_entry を含めろと言っている訳です。

const EntryBodyQuery = graphql`
  query EntryBodyQuery(entryId: ID!) {
    entry(id: $entryId) {
      body
      ...EntryHeader_entry
    }
  }
`;

こうすれば、EntryBodyQuery を fetch した際に、BlogTopPageQuery の分の field もfetch され、ユーザレベルで完全性が担保できるようになります。

  1. BlogTopPageQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは" }
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  2. ユーザがエントリーを以下のように更新した
    • {title: "2023年のおみくじは", body: "小吉でした" }
  3. EntryBodyQuery が fetch される
    • cache: id: "entry:1", data: { title: "2023年のおみくじは", body: "小吉でした" } (注: title field も fetch されてる)
    • <EntryHeader>useFragment の戻り値: { title: "2023年のおみくじは" }
  4. ページの表示が「2023年のおみくじは / 小吉でした」になる
    • 一貫性も完全性もある状態になってる

感想

気持ちはわかるものの、いきなり Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase') とだけ言われても何が起きているのか分からないのが微妙な感じがします。「missing data 現象が発生しました。後続のクエリに前のクエリの fragment を含めてください。」みたいな丁寧なエラーメッセージを出すくらいはやってほしいかな...

Next.js で言語ごとに異なるアセット画像を埋め込む

多言語対応している Web アプリで、あらかじめファイルに書き出しておいた画像 (アセット) が言語別にあって、それをページに埋め込むにはどうするか、という話題です。基本は言語ごとに別々の画像を出すことないと思いますが、たまーにあるんですよね。例えばGoogle Play のバッジが言語ごとに違うので、これを言語ごとに出し分けたいとか。

色々方法があるので、それをいくつか紹介します。

方法1. import 文を言語の数だけ書く

シンプルにやるなら、言語の数だけ import 文を書いて、next/image<Image> コンポーネントで読み込むコードを書くかと思います。

// pages/index.ts
import Image from 'next/image';
import GooglePlayJaJP from '../assets/ja-JP/google-play.png';
import GooglePlayEnUS from '../assets/en-US/google-play.png';
import GooglePlayZhCN from '../assets/zh-CN/google-play.png';
import GooglePlayDeDE from '../assets/de-DE/google-play.png';
import GooglePlayItIT from '../assets/it-IT/google-play.png';
// ...

const GooglePlayMap = {
  'ja-JP': GooglePlayJaJP,
  'en-US': GooglePlayEnUS,
  'zh-CN': GooglePlayZhCN,
  'de-DE': GooglePlayDeDE,
  'it-IT': GooglePlayItIT,
  // ...
} as const

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <Image src={GooglePlayMap[lang]} alt="Google Play" />
    </div>
  );
}

Next.js way に沿っている一方で、言語の数に比例してコード量が増えるのがイマイチですね。

余談: import 文で読み込むと cache-busting パターン対応をやってくれる

import 文を使って画像を import すると、フレームワークのビルドツールが GooglePlay 変数にアセットへのパスを割り当て、<Image src="/assets/google-play.c53d93.png" alt="Google Play /> というコードに変換します (c53d93は content hash)。加えてアセットも <ビルド成果物の出力先ディレクトリ>/google-play./logo.c53d93.png へとコピーします。要は cache-busting パターンに対応した変換をやってくれる訳です。

方法2. public ディレクトリと img タグを使う

Next.js には public ディレクトリという static なファイル置き場があります。例えば public/assets/ja-JP/google-play.png にアセットを置くと、/assets/ja-JP/google-play.png という URL で配信されます。

これを利用すると、言語ごとに異なる画像を全部 public ディレクトリに置き、それを img タグで参照する、というアプローチが取れます。

// pages/index.ts

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <img src={`/assets/${lang}/google-play.png`} alt="Google Play" />
    </div>
  );
}

ただこのように書くと、画像の URL が /assets/ja-JP/google-play.png, /assets/en-US/google-play.png, ... などに固定されてしまいます。これは HTTPキャッシュを使用して不要なネットワーク要求を防ぐ のようにアセットを Cache-Control: max-age=31536000 で長期キャッシュしている場合に問題になります。もしそのように長期キャッシュしている場合、画像が新しい内容に置き換わっても、古いキャッシュがブラウザから使われ続けてしまうため、ユーザからは古い画像が見えたままになってしまいます。

そのため、もしこのアプローチを採用するなら、古いキャッシュが使われないよう工夫しなければいけません。例えば Cache-Control: no-cache で毎回キャッシュが古くなっていないか検証するよう要求するとかですね。

あるいは (雑ですが) アセットの URL の末尾に git の commit hash をつけるとかでも良いと思います。デプロイする度に変わる値なので、デプロイするごとにキャッシュが無効になってしまいますが、まあそれは仕方ないということで。ただ、git の commit hash を環境変数から参照できるようにする仕組みの整備はちょっと面倒だと思います。

// pages/index.ts

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <img src={`/assets/${lang}/google-play.png?${process.env.GIT_COMMIT_HASH}`} alt="Google Play" />
    </div>
  );
}

方法3. import 文を自動生成する

やっぱり Next.js のお作法に則って import 文を使った方法で書きたい、cache-busting パターンにも自動で対応させたい、という人向けの方法です。import 文を自動生成する CLI ツールを作りましょう。

// scripts/generate-i18n-asset-map.ts

// 言語ごとに異なるアセットの map を生成するツール。

const assetPatterns = [
  // 言語ごとに異なるアセットが増えたら、ここに追加してください。
  // プロジェクトのルートディレクトリからの相対パスで書くこと。
  'assets/[lang]/google-play.png',
]
const langs = ['ja-JP', 'en-US', 'zh-CN', 'de-DE', 'it-IT']

import {parse, resolve} from 'path'
import {mkdir, rm, writeFile} from 'fs/promises'

import {pascalCase} from 'change-case'


const rootDir = resolve(__dirname, '..')
const distDir = resolve(rootDir, '__generated__', 'i18n-asset-map')

async function generateI18nAssetMap(assetPattern: string) {
  // name は`'./assets/[lang]/google-play.png'` から `'google-play'` のみ取り出した文字列。
  const {name} = parse(assetPattern)
  const distFilename = resolve(distDir, name + '.ts')

  const importStatements = langs.map(lang => {
    const path = assetPattern.replace('[lang]', lang)
    const identifier = pascalCase(name + lang)
    return `import ${identifier} from '../../${path}'`
  })
  const importMapProps = langs.map(lang => {
    const key = lang
    const value = pascalCase(name + lang)
    return `  '${key}': ${value},`
  })
  const importMapName = pascalCase(name + 'Map')
  const content = `
${importStatements.join('\n')}

const ${importMapName} = {
${importMapProps.join('\n')}
} as const
export default ${importMapName}
  `.trim()

  await writeFile(distFilename, content)
}

// map ファイルの出力先は `__generated__/i18n-asset-map/<拡張子を除いたファイル名>.ts` なので、
// ファイル名が同じ assetPattern が複数あると、同じ場所に出力されてしまう。
// それを防ぐために、assetPattern のファイル名が被っていないかチェックする。
function validateAssetPatterns() {
  const visitedNames = new Set<string>()
  for (const assetPattern of assetPatterns) {
    const {name} = parse(assetPattern)
    if (visitedNames.has(name)) {
      console.error(
        `${name} という名前のファイルの assetPattern が複数検知されました。ファイル名は assetPattern ごとに被らないようにしてください。`
      )
      process.exit(1)
    }
    visitedNames.add(name)
  }
}

async function main() {
  validateAssetPatterns()

  await rm(distDir, {recursive: true, force: true}) // 古いファイルを削除
  await mkdir(distDir, {recursive: true})
  for (const assetPattern of assetPatterns) {
    await generateI18nAssetMap(assetPattern)
  }
}

main().catch(console.error)

これを ts-node scripts/generate-i18n-asset-map.ts で実行すると、以下のようなファイルが生成されます。

// ui/__generated__/i18n-asset-map/google-play.ts
import GooglePlayjaJp from '../../assets/ja-JP/google-play.png'
import GooglePlayenUs from '../../assets/en-US/google-play.png'
import GooglePlayzhCn from '../../assets/zh-CN/google-play.png'
import GooglePlaydeDe from '../../assets/de-DE/google-play.png'
import GooglePlayitIt from '../../assets/it-IT/google-play.png'

const GooglePlayMap = {
  'ja-JP': GooglePlayjaJp,
  'en-US': GooglePlayenUs,
  'zh-CN': GooglePlayzhCn,
  'de-DE': GooglePlaydeDe,
  'it-IT': GooglePlayitIt,
} as const
export default GooglePlayMap

こういう感じで使えます。

// pages/index.ts
import Image from 'next/image';
import GooglePlayMap from '../__generated__/i18n-asset-map/google-play';

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <Image src={GooglePlayMap[lang]} alt="Google Play" />
    </div>
  );
}

<Image> がそのまま使えて、かつ cache-busting パターン対応もやってくれます。良いですね。

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

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