こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。
このサイトは Next.js の静的書き出しで作っていて、記事を「ふつう」と「やさしい」の 2 つの難易度で出しています。ページの上には難易度のタブがあり、押すと別の難易度の URL へ移動し、同時に「この読者はこの難易度を選んだ」ことを localStorage に保存します。次にトップを開いたとき、サイト内のリンクの行き先をその難易度に合わせるためです。
この保存が、読み込み直後にタブを押した回だけ書かれていませんでした。結論を先に書くと、原因は保存を React の onClick で書いていたことです。タブは別 URL への <a href> なので、hydration の前に押されると、ブラウザの標準の動作で移動だけが起き、onClick は一度も呼ばれません。保存は、HTML の <head> に置いたインラインスクリプトが document に登録した click の listener で書く形に移しました。
症状
読者の側から見ると、「やさしい」を押して移動したのに、次にサイトを開いたときには「ふつう」に戻っている、という形で出ます。毎回起きるのではなく、ページを開いてすぐにタブを押したときだけ起きます。
保存を書く経路は 2 つありました。
-
<head>のインラインスクリプト。最初のペイントより前に動き、保存が空のときだけ、開いた URL の難易度を書きます。 - タブの
onClick。押したタブの難易度を書きます。
1 つ目は保存が空のときしか書かないので、一度保存された後は、タブの onClick が保存を書き換える唯一の経路になります。この onClick が呼ばれないと、保存は前の値のまま残ります。
外れた仮説: テストの待ちが足りない
最初にこれが見えたのは、読者からではなく E2E テストからでした。タブを押した後に localStorage の値を確かめるテストが、ときどき落ちていました。
最初はテストの待ちが足りないのだと考えました。保存は移動先のページで hydration が済んだ後に書かれる、と思い込んでいたので、確かめる側を 10 秒の poll に延ばしました。それでも落ちました。
次に、押す前に hydration が済むのを待つことにしました。タブの部品が useEffect の中で data-* 属性を付け、テストはその属性が現れてから押す形です。これでテストは通るようになりましたが、通るようになったのはテストだけで、読者の側の不具合は残っていました。
待ちを延ばしても直らなかったのは、待っていても保存が書かれることが無いからです。hydration の前に押した回は、移動した時点で onClick を呼ぶ機会がなくなっています。移動先のページでどれだけ待っても、前のページの onClick が後から呼ばれることはありません。テストは読者と同じ不具合を正しく見つけていて、直すべきなのは製品の側でした。
原因
<a href> の移動は、JavaScript が 1 行も動いていなくてもブラウザが行います。タブのような別 URL へのリンクでは、hydration の前に押されると、React の onClick が呼ばれるより前に移動が起きます。移動した時点で、そのページの onClick を呼ぶ機会は無くなります。
その結果、移動という本来の動作だけが起き、付随する処理だけが抜け落ちます。画面は期待どおりに切り替わるので、見た目からは抜け落ちたことが分かりません。
静的書き出しのサイトでは、HTML が先に届いて表示され、React のチャンクはその後から読み込まれます。回線や端末が遅いほど、表示されてから hydration が済むまでの間が長くなり、その間に押される可能性が高くなります。
対処: 保存を書く処理を、最初のペイントより前に登録する
保存を書く処理を、<head> のインラインスクリプトに移しました。このスクリプトは最初のペイントより前に動くので、読者がタブを押せる時点では listener が必ず登録されています。
タブのリンクには、どの難易度を選ぶリンクかを示す data-* 属性を付けます。インラインスクリプトは document に capture で click の listener を登録し、押された要素から closest でその属性を持つリンクを探します。次のコードは実際の処理を短くしたものです。
document.addEventListener('click', function (ev) {
try {
var tg = ev.target;
if (!tg || typeof tg.closest !== 'function') { return; }
var a = tg.closest('[data-segment]');
if (!a || !a.getAttribute('href')) { return; }
var sg = a.getAttribute('data-segment');
if (sg !== 'easy' && sg !== 'normal') { return; }
try { localStorage.setItem('difficulty', sg); } catch (e) {}
document.documentElement.dataset.difficulty = sg;
} catch (e) {}
}, true);
タブの部品の側は、onClick を消して属性を付けるだけにしました。保存を書く経路を React の側とインラインスクリプトの側の 2 つ持つと、片方だけを直して食い違う事故が起きるので、1 つにしています。
document への委譲にしたのは、インラインスクリプトが動く時点ではタブの要素がまだ DOM に無いためです。<head> で動くスクリプトは個々の要素に listener を付けられないので、document に 1 つだけ登録し、押されたときに属性で見分けます。
採らなかった案: 移動先の URL から保存を書く
もう 1 つの案は、移動先のページのインラインスクリプトが、URL の難易度と保存が食い違っていたら URL に合わせて保存を書き直す形でした。押す操作に頼らないので、hydration とは無関係に動きます。
この案は採りませんでした。保存の意味が変わってしまうためです。URL から書くと、共有されたリンクや検索結果から難易度付きのページに来た読者の保存まで上書きされます。保存が表すものが「読者が最後に選んだ難易度」から「読者が最後に見たページの難易度」に変わります。このサイトでは、読者が明示的に選んだときだけ保存を書く、と決めていたので、それと食い違います。
hydration の前のクリックを取りこぼさないことが目的であれば、クリックの時点で書ける場所へ書く処理を移せば足ります。保存する条件まで変える必要はありませんでした。
テスト: hydration が起きない状態を作って固定する
不具合を固定するテストは、hydration との競争にしない形で書きました。hydration の前に押すタイミングを狙うと、テストの結果が実行環境の速さで変わります。そこで、React のチャンクの取得をすべて止め、hydration がそもそも起きない状態を作ってから押します。
let blocked = 0;
await page.route('**/_next/static/chunks/**', route => {
blocked += 1;
return route.abort();
});
await page.goto('/');
expect(await page.evaluate(() => localStorage.getItem('difficulty'))).toBe('normal');
await page.getByRole('tablist', { name: '難易度切替' }).first().getByRole('tab', { name: 'やさしい' }).click();
await expect(page).toHaveURL('/easy/');
expect(await page.evaluate(() => localStorage.getItem('difficulty'))).toBe('easy');
await expect(page.locator('html')).toHaveAttribute('data-difficulty', 'easy');
expect(blocked).toBeGreaterThan(0);
コメントは省いています。最後の行で、止めた取得の数が 0 でないことを確かめています。パスの指定を誤って何も止めていなければ、このテストは hydration が済んだ状態で通ってしまい、確かめたいことを確かめていません。
このテストが本当に不具合を検出できることも確かめました。インラインスクリプトの listener がリンクを拾わないように書き換えると、次のように落ちます。
Expected: "easy"
Received: "normal"
押す前の保存は、初回にインラインスクリプトが URL から書いた normal です。押しても書き換わらないので、normal のまま残ります。読者の側で起きていたことと同じ形です。
一般化できること
-
<a href>に付けた React のonClickは、hydration の前には呼ばれません。しかし移動は起きます。リンクの押下に付随する処理(保存・計測・状態の更新)をonClickに置くと、読み込み直後の操作でその処理だけが抜け落ちます。 - 抜け落ちても画面は期待どおりに切り替わるので、見た目からは気づけません。気づく機会はテストか、読者の「設定が戻る」という報告です。
- テストの待ちを延ばしても直らないときは、待っている対象が本当に後から起きるのかを確かめます。起きない処理は、いくら待っても起きません。
- hydration に依存しないことを確かめるテストは、hydration と競争させず、チャンクの取得を止めて hydration が起きない状態で書きます。止めた数を数えて、止め損ねにも気づけるようにします。
まとめ
- 読み込み直後にタブを押すと、移動だけが起きて難易度の保存が書かれなかった
- テストの待ちを延ばす・hydration の完了を待つ、はテストを通しただけで、不具合は製品の側にあった
- 原因は、保存を書く処理が hydration 後にしか呼ばれない
onClickにあったこと - 保存は最初のペイントより前に登録する
documentの capture の click 1 か所で書き、React の側の処理は消した - 移動先の URL から保存を書く案は、共有リンクから来た読者の保存まで上書きするので採らなかった
- テストは React のチャンクの取得を止め、hydration が起きない状態で固定した