0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

hydration の前に押されたリンクは React の onClick を通らない

0
Posted at

こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。

このサイトは 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 が起きない状態で固定した
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?