この記事で扱うこと
数日にわたって出会った問題が、結局は同じ一つの問いに集まった。間に挟まった層は何をそのまま通し、何を自分の判断で推測するのか。 特定の製品名や数値は省き、どこでも通用する一般的な概念だけをまとめる。
今回扱ったこと
| 作業 | 何をしたかったか | やったこと | 結果 |
|---|---|---|---|
| 無限リダイレクト | 自分自身へ跳ね返る経路を復旧したかった | curl でアップストリームの応答を直接確認し原因を確定 | rewrite が 3xx を通すと確認、宛先の差し替えで解決 |
| 日本語の早期確定 | 変換中に保存される値を防ぎたかった | 変換状態を追跡しコミットを変換終了後へ遅延 | ブラウザごとのイベント順序に依存せず動作 |
| バンドル削減 | ルートごとの JS 転送量を減らしたかった | provider とヘッダー認証 UI のクライアント境界を内側へ移動 | コードを消さず境界を移すだけで削減 |
| 画像比率 | 切れて見えるスクリーンショットを直したかった | ビルド時に原寸法を注入しフォールバックは維持 | レイアウトシフトと切れが同時に解消 |
1. rewrite はリダイレクトではなくプロキシだ
どんな状況だったか。 ある経路に入るとページが表示されず、ブラウザが「リダイレクトが多すぎます」で諦めた。しかしアプリのコードには、その経路をどこかへ送るリダイレクトが一行もなかった。
中心となる考え方。 リダイレクトはブラウザに「あちらへ行き直せ」と 3xx を渡すもので、アドレスバーが変わる。rewrite はアドレスバーをそのままにして サーバーが代わりに受け取ってくるプロキシ だ。落とし穴は rewrite がアップストリームの応答を加工しない点にある — アップストリームが 3xx を返せば、その 3xx がそのままブラウザへ渡る。多くの SaaS は URL の重複を避けるためテナントのアドレスを顧客の代表ドメインへ 308 で移動させるが、その代表ドメインが再び rewrite で SaaS を指すと往復が終わらない。
やったこと。 推測ではなく curl -I --max-redirs 0 <宛先> でアップストリームのステータスコードと location を直接出した。テナントのアドレスは 308 で自分のドメインを指し、ベンダーがカスタムドメイン向けに提供するプロキシ専用ホストは 200 だった。宛先をそちらへ変えて解決した。決め手は、同じ設定ファイルの中で すでに問題なく動いていた兄弟規則 が最初からそのホストを使っていたことだ。渡された設定スニペットは宛先の一行だけを採用した — 丸ごと貼っていれば既存のキャッチオール rewrite と画像・バンドラー設定が消え、サイト全体が落ちる。
教訓。 プロキシを挟む前に、宛先が 200 を返すかを先に確認する。そして壊れた規則の隣にある正常な兄弟規則を読む — 答えがすでに同じファイルにあることは多い。
→ rewrite プロキシとアップストリームのリダイレクトループ
2. 日本語入力のバグは値ではなく確定の時点の問題だ
どんな状況だったか。 値を手で直す入力欄で、日本語を打っている途中に別の場所をクリックしたり Enter を押したりすると、最後の文字が確定する前の値が保存された。
中心となる考え方。 日本語・韓国語・中国語には、複数のキー入力が集まって一文字になる変換の区間がある。この区間が終わる前に blur や Enter がコミット(確定保存)を実行すると、画面に見える値と保存された値がずれる。さらに厄介なのは、compositionend・keydown・blur の 到着順序がブラウザごとに異なる ことだ。ある環境を基準に順序を仮定して直すと、別の環境でそのまま再発する。
やったこと。 リッチテキストエディタを導入する選択肢は捨て、ネイティブの textarea を維持した。値が単なる文字列なのにエディタを載せれば、IME の問題は残ったまま層だけが増える。変換の開始・終了の合図で「いま変換中か」を状態として持ち、変換中の blur は即座に保存せず予約しておき、変換が終わってから保存するようにした。Enter も変換中なら無視する。値を文字列として整える方式(重複文字の除去など)は入れなかった — 利用者が本当に同じ文字を二回打った入力まで壊してしまう。
教訓。 イベントの順序を仮定せず状態で判断する。そして境界の問題を値の問題と誤診しない。
3. バンドル削減はコードを消すことではなく境界を移すことだ
どんな状況だったか。 一覧や詳細のように操作の少ないルートまで、共通の provider とヘッダーのせいで同じ量の JS を受け取っていた。
中心となる考え方。 "use client" はファイル一つをクライアント側にする印ではなく、そのファイルから始まる import グラフ全体 をブラウザへ送る境界だ。重いライブラリを一つ何気なく引き込むと、その境界の下にあるすべてのページが費用を分担することになる。provider を最上位に置くのも同じで、便宜であって既定値ではない。
やったこと。 データキャッシュの provider を実際に使う無限リストの機能側へ下ろし、ヘッダーからはログイン状態によって変わる部分だけを別のクライアント片として切り出した。重い関係グラフは必要なときに読み込むローダー経由にした。検証はユニットテストと既存の E2E で担保した。
教訓。 境界は「操作が実際に必要な最も内側」に引く。使うルートへ下ろせば、残りのルートはただで軽くなる。
4. 比率は推測せず元ファイルから知る
どんな状況だったか。 本文に入れた縦長のスクリーンショットが切れて見えた。レイアウトのぶれを防ぐために入れておいた固定比率のフォールバックが、今回はコンテンツを損なっていた。
中心となる考え方。 寸法のない画像は読み込み前の高さがゼロなので、到着が遅れると下の内容を押しやる(累積レイアウトシフト)。よくある対処の aspect-ratio: 16/9 はぶれは止めるが比率を 推測する ため、縦長画像を食ってしまう。元ファイルがローカルにあるなら推測する理由はない — ビルド時点でファイルを開いて実寸法を読めば、実行時コストもネットワークリクエストもゼロだ。
やったこと。 マークダウンを HTML へ変える段階に割り込み、ローカル画像の実寸法を width/height として注入するプラグインを入れた。外部 URL には触れない。スタイルは寸法のある画像だけ高さを自動にして原寸比で描き、寸法を得られなかった画像には既存のクロップフォールバックをそのまま残した。ユニットテストで「ローカルには付き、外部はそのまま」を固定した。
教訓。 新しい経路を入れるとき既存のフォールバックを消さない。そしてビルド時にできる計算を利用者の端末へ先送りしない。
5. 余談 — exit 0 なのに何もしない CLI
グローバルにインストールした CLI が、終了コード 0、出力も成果物もなしで終わった。原因は、npm のグローバル bin が シンボリックリンク であるため、「いま直接実行されたか」を判別するパス比較型のガードが静かに偽になっていたことだった。実経路で直接実行すると正常に動いた。失敗の合図が一つもないこの失敗モードが最も危険だ。