mizdra's blog

ぽよぐらみんぐ

閉じている details 要素に `#fragment` でジャンプして、ジャンプと同時に展開したい

最近用語集のようなページを作る機会があった。それぞれの用語の名前と説明がバーっと並んでて、説明部分は <details> 要素で隠されている。用語の名前をクリックすると <details> が展開されて、説明部分が読める、といった感じ。

JavaScript, C++, Python という3つの用語の説明が並んでいる。それぞれの用語の説明は details 要素で構成されていて、JavaScript と Python は展開されていて、C++ は展開されていない。
用語集ページの例

他のページから特定の用語の説明に飛べるように、用語を表す要素それぞれに id 属性が振ってある。例えば https://glossary.example.com/#javascript という URL でアクセスすると、「JavaScript」の用語がある位置にスクロールした状態で、用語集ページを開ける。

ただ (一部ブラウザで) https://glossary.example.com/#javascript でアクセスしても、JavaScript の説明の欄が閉じたままで困っていた。その用語の説明が見たいがためにしてきているので、最初から説明の欄は展開されていて欲しい。

Auto-expand details elements

実は、まさにこれを実現するための機能が標準化されている (「Auto-expand details elements」と呼ばれてる)。

厳密にはこの機能は、#id-or-name による navigation が発生した時だけでなく、Text Fragment (#:~:text=...) による navigation や、ページ検索の際にも、<details> 要素の自動展開を行う仕様となってる。

Chrome や Edge では実装済みで、Safari や Firefox では未実装。そのため、Chrome や Edge であれば、 https://glossary.example.com/#javascript でアクセスすれば、最初から JavaScript の説明欄が展開された状態でページを開ける。

shim

そのうち他のブラウザでも実装が進むだろうけど、待っていられないので shim *1 を書いた。

やってることはシンプルで、ページ表示時と <a> 要素をクリックした時に、閉じている <details> 要素に fragment navigation しようとしてたら展開する、という感じ。

/**
 * @param {Element | null} target
 */
function openAncestorDetailsRecursively(target) {
  if (!target) return;
  const details = target.closest("details");
  if (!details) return;
  if (!details.open) details.open = true;
  openAncestorDetailsRecursively(details.parentElement);
}
// ページが fragment 付きで読み込まれた時に、ターゲットとなる要素の祖先の details 要素を開く
window.addEventListener("DOMContentLoaded", () => {
  if (!location.hash.startsWith("#")) return;
  const target = document.querySelector(location.hash);
  openAncestorDetailsRecursively(target);
});
// フラグメント付きの a 要素がクリックされた時に、ターゲットとなる要素の祖先の details 要素を開く
document.querySelectorAll("a").forEach((a) => {
  a.addEventListener("click", () => {
    const href = a.getAttribute("href");
    if (!href || !href.startsWith("#")) return;
    const target = document.querySelector(href);
    openAncestorDetailsRecursively(target);
  });
});

以下のデモページからお試しいただけます。

注意点としては、「Auto-expand details elements」機能の全ての挙動を模倣している訳では無いということ。先述したように「Auto-expand details elements」機能にはページ検索や Scroll to Text Fragment で <details> 要素にヒットした時に、その <details> 要素を自動展開する挙動があるが、shim ではその挙動までは再現していない。そうした挙動まで再現するのは大変すぎるし、id:mizdra は別に必要としてないので。

あと View フレームワークによっては、DOMContentLoaded イベントリスナーが上手く設定できなくて、そのままでは shim が利用できないかも。また動的に <a> 要素がページに挿入される場合は、その <a> 要素にも a.addEventListener("click", ...) する必要がある。アプリケーションごとに事情が変わってくると思うので、アプリケーションごとに上手くやってください。

Node.js の --require/--import オプションについて

Node.js には --require=module--import=module というオプションがあります。このオプションを使うと、エントリポイントとなるプログラムよりも前に、任意のモジュールを実行できます。

例えば以下のようなコマンドを実行すると、Node.js ランタイムはまず最初に preload.cjs を実行し、それから main.mjs を実行できます。

node --require ./preload.cjs main.mjs

エントリポイントよりも前に、何かしらの処理を実行したい時に使うことを想定しています。

--require--import の違い

--import--require と同じように、モジュールをプリロードするためのオプションです。両者の違いはプリロードするモジュールの読み込み方です。

--requirerequire(...); 相当、--importimport '...'; 相当のコードでモジュールを読み込みます。

# `require('./preload.cjs');` 相当
node --require ./preload.cjs main.mjs
# `import './preload.mjs';` 相当
node --import ./preload.mjs main.mjs

ところで、import 文では ES module 形式だけでなく、CommonJS 形式のモジュールも読み込めます。つまり --import で CommonJS 形式のモジュールもプリロードできるのです。

node --import ./preload.cjs main.mjs

また、Node.js v22.0.0 からは --experimental-require-module オプションを用いることで、top-level await を含まない ES module 形式のモジュールに限って require(esm) が可能になりました (参考1, 参考2)。従って --experimental-require-module を併用することで、ES module 形式のモジュールを --require で読み込めます。

node --experimental-require-module=module --require ./preload.mjs main.mjs

従って、--experimental-require-module=module + --require--import のどちらを使っても、ほぼ変わりないです。 とはいえ、--experimental-require-module=module + --require は top-level await 非対応なので、基本的には --import を使っておくと良いのではないかと思います。

npm package を --require/--import で読み込む

--require/--import はユーザ定義のモジュールだけでなく、npm package も読み込めます。import '3rd-party-preload'; と書くように、node --import 3rd-party-preload とするだけです。

npm i -S 3rd-party-package
node --import 3rd-party-package main.mjs

複数のモジュールをプリロードする

--require/--import を複数個指定して、複数のモジュールをプリロードできます。プリロードされるモジュールは、左から順に実行されます。

例えば以下のようなコマンドを実行した場合、3rd-party-package-1 => 3rd-party-package-2 => 3rd-party-package-3 => main.mjs の順で実行されます。

node \
  --import 3rd-party-package-1 \
  --import 3rd-party-package-2 \
  --import 3rd-party-package-3 \
  main.mjs

--require/--import の主な用途

主にモジュールの読み込み方法をカスタマイズするために利用されます。

例えば ts-node を使うと、事前のトランスパイルをせずとも *1 Node.js で直接 .ts が読み込めるようになります。

npm install -D ts-node
node --import ts-node/register main.mts

更に tsconfig-paths と組み合わせると、tsconfig.jsoncompilerOptions.paths を Node.js でも解決できるようになります。

npm install -D ts-node tsconfig-paths
node \
  --import ts-node/register \
  --import tsconfig-paths/register \
  main.mts

僕が作った @mizdra/node-next-image-loader を使うと、画像を base64 化しつつモジュールとして読み込むこともできます。

npm install -D @mizdra/node-next-image-loader
node --import @mizdra/node-next-image-loader main.mjs
import burnAllGIFs from "./assets/burnallgifs.png";

console.log(burnAllGIFs);
// {
//   src: 'data:image/png;base64,...',
//   width: 199,
//   height: 117,
// }

また、power-assert をサポートするための @power-assert/node というパッケージもあります (以下、公式 example より引用)。

npm install -D @power-assert/node
node \
  --enable-source-maps \
  --import @power-assert/node \
  --test \
  demo.test.mjs
> node --enable-source-maps --import @power-assert/node --test demo.test.mjs

▶ power-assert demo
  ✖ Array#indexOf (8.774208ms)
    AssertionError [ERR_ASSERTION]:

    assert(ary.indexOf(zero) === two)
           |   |       |     |   |
           |   |       |     |   2
           |   |       |     false
           |   |       0
           |   0
           [0,1,2]

    0 === 2

        at TestContext.<anonymous> (/path/to/demo.test.mjs:9:5)
        at Test.runInAsyncScope (node:async_hooks:206:9)
        at Test.run (node:internal/test_runner/test:824:25)
        at Test.start (node:internal/test_runner/test:721:17)
        at node:internal/test_runner/test:1181:71
        at node:internal/per_context/primordials:488:82
        at new Promise (<anonymous>)
        at new SafePromise (node:internal/per_context/primordials:456:29)
        at node:internal/per_context/primordials:488:9
        at Array.map (<anonymous>) {
      generatedMessage: false,
      code: 'ERR_ASSERTION',
      actual: 0,
      expected: 2,
      operator: '==='
    }

余談: tsx を使う

完全に余談ですが、ts-node + tsconfig-paths 相当のことをやりたいのであれば、tsx を使いるのがオススメです。

npm install -D tsx
node --import tsx main.mjs

ts-node よりもトランスパイルが高速で、compilerOptions.paths を使ったモジュールの解決を組み込みでサポートしています。他にも色々と ts-node + tsconfig-paths との違いがあります。詳しくは以下の FAQ を参照してください。

--require/--import は合成可能

何個もモジュールをプリロードしていると、コマンドラインオプションのリストが長くなってしまいます。

node \
  --enable-source-maps \
  --import tsx \
  --import @mizdra/node-next-image-loader \
  --import @power-assert/node  \
  --test \
  demo.test.mts

ここで、--require/--importrequire(...);/import '...'; 相当の挙動をすることを思い出してみましょう。そう、以下のように書くと --require/--import を合成できるのです。

node --enable-source-maps --import ./preload.mjs  --test demo.test.mts
// preload.mjs
import 'tsx';
import '@mizdra/node-next-image-loader';
import '@power-assert/node';

プリロードしたいモジュールが増えても安心ですね。

プリロードされるモジュールをカスタマイズ可能にする

./preload.mjs を用意すればプログラマブルなことができるという点に着目すると、面白いことができます。例えば、@mizdra/node-next-image-loader から API を export しておいて、ユーザが自由に @mizdra/node-next-image-loader をカスタマイズできるように、といったことが可能です。

(理論上の話で、実際にはこの機能は @mizdra/node-next-image-loader に実装されてないことに注意)

import { registerCustomHook } from '@mizdra/node-next-image-loader/custom';
registerCustomHook({
  export: {
    src: {
      // base64 文字列ではなく Buffer として export する
      type: 'buffer',
    },
    // width, height は export しない
    width: false,
    height: false,
  },
});

あとがき

Node.js の --require/--import は、シンプルながらも柔軟なオプションです。本当に色々なことができて面白いので、皆さんも是非機会があれば触ってみてください。

*1:node コマンドを実行するよりも前にトランスパイルが不要なだけで、node コマンドの実行中にオンデマンドでトランスパイルしています。見かけ上のビルドプロセスが単純化されるメリットはありますが、最終的にトランスパイルしてから実行することに変わりはありません。

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-browser-compatibility-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 のエラー出力を例に取り上げている

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

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