Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Next.js のページ遷移を全力で止める

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.
Avatar for ypresto ypresto
September 18, 2024

Next.js のページ遷移を全力で止める

Avatar for ypresto

ypresto

September 18, 2024
Tweet

More Decks by ypresto

Other Decks in Technology

Transcript

  1. © LayerX Inc. 3 ⾃⼰紹介 • LayerX バクラク事業部 ◦ 請求書受取‧仕訳チーム

    ◦ ソフトウェアエンジニア ◦ 経理の⽅が利⽤するプロダクトの開発 • フロントエンドやっていき!でも全部やる • 趣味は主に写真とスプラ、⼦とおでかけ ypresto (プレスト)
  2. © LayerX Inc. 5 • バクラクではNext.jsを採⽤しています フロントエンドビジョン by ギルド Reactを採⽤する理由の例

    • BEエンジニアを含むメンバーとの相性 • プロダクト間コンテキストスイッチの低減 • フレームワーク統⼀による基盤開発の促進 バクラクのフロントエンドが⽬指す姿
  3. © LayerX Inc. 8 「保存していない変更」を実装したい 変更の保存漏れを防ぐパターン2選 ユーザーがコントロールする 「確認ダイアログ」 勝⼿にいい感じにする 「⾃動保存」「下書き保存」

    申請や財務情報など、変更が慎重であるべきもの 公開や提出が済んでいる情報の編集 社内ドキュメントなど、戻せば許されるもの 下書きなど、⾃分にしか⾒えていない情報 BtoB SaaSでは必須!
  4. © LayerX Inc. 14 ライブラリの使い⽅ App Router & Pages Router

    に対応した「Navigation Guard」ライブラリ • app/layout.tsx か pages/_app.tsx で、<NavigationGuardProvider> で囲う • • useNavigationGuard({ enabled: isChanged, confirm: () => window.confirm() }) • • const navGuard = useNavigationGuard({ enabled: isChanged }) <Dialog show={navGuard.active}> <Button onClick={() => navGuard.accept()}>OK</Button> <Button onClick={() => navGuard.reject()}>OK</Button> </Dialog> • • enabled: () => isChanged() でもOK
  5. © LayerX Inc. 16 Next.js と History API SPA的でない遷移 •

    リロードボタン • 外部サイトへの遷移 SPA: アプリ内リンク等 • <Link> • router.push() • router.replace() • router.refresh() ページを離脱する遷移を⽌めたい:その分類 • 戻る‧進むボタン • router.back() • router.forward() SPA: back/forward
  6. © LayerX Inc. 17 Next.js と History API SPA的でない遷移 •

    リロードボタン • 外部サイトへの遷移 SPA: アプリ内リンク等 • <Link> • router.push() • router.replace() • router.refresh() ページを離脱する遷移を⽌めたい:その分類 • 戻る‧進むボタン • router.back() • router.forward() SPA: back/forward beforeunloadイベント 出典:MDN
  7. © LayerX Inc. 19 Next.js と History API SPA的でない遷移 •

    リロードボタン • 外部サイトへの遷移 SPA: アプリ内リンク等 • <Link> • router.push() • router.replace() • router.refresh() ページを離脱する遷移を⽌めたい:その分類 • 戻る‧進むボタン • router.back() • router.forward() SPA: back/forward beforeunloadイベント Next.js & History API
  8. © LayerX Inc. 20 Next.js と History API • ブラウザにフェッチさせずに、

    URLと履歴だけをJSから書き換える • JSで画⾯の中⾝を書き換える • history.stateで現在ページのstate (カスタ ムでセットした情報) が読める • 戻る‧進む操作でstackの位置が変わると URLが変わってから popstateイベントが発⾏される /posts/123 /posts アプリ側でページ遷移と履歴を⾃前で管理 SPA と History API /posts/124 state state state history.pushState() で積む history.replaceState() で上書き 進むボタン history.forward() / history.go(x) 戻るボタン history.back() / history.go(-x) 現在の ページ / state
  9. © LayerX Inc. 21 Next.js と History API SPA的でない遷移 •

    リロードボタン • 外部サイトへの遷移 SPA: アプリ内リンク等 • <Link> • router.push() • router.replace() • router.refresh() ページを離脱する遷移を⽌めたい:その分類 • 戻る‧進むボタン • router.back() • router.forward() SPA: back/forward 1. Next.jsの状態を更新 2. Historyを書き換え beforeunloadイベント
  10. © LayerX Inc. 22 Next.jsのページ遷移:アプリ内リンク等 App Router History API history.pushState()

    <Link> クリックでのページ遷移 <Link> が router.push() React Routerの状態を更新 新しいページを描画
  11. © LayerX Inc. 23 Next.jsのページ遷移:アプリ内リンク等 App Router History API history.pushState()

    <Link> クリックでのページ遷移 <Link> が router.push() React Routerの状態を更新 新しいページを描画 ここで⽌めたい
  12. © LayerX Inc. 26 Context 経由で Router を差し替える <AppRouterContext.Provider value={公式ルーター}>

    ... <AppRouterContext.Provider value={wrapしたルーター}> {アプリ} </AppRouterContext.Provider> ... </AppRouterContext.Provider> Contextの値は⼀番近い親のものが使われる アプリから使われるのは こっちになる 元のrouterオブジェクトを 破壊的に変更せずに 差し替えられる
  13. © LayerX Inc. 27 Next.jsのページ遷移:back/forward SPA的でない遷移 • リロードボタン • 外部サイトへの遷移

    SPA: アプリ内リンク等 • <Link> • router.push() • router.replace() • router.refresh() ページを離脱する遷移を⽌めたい:その分類 • 戻る‧進むボタン • router.back() • router.forward() SPA: back/forward 1. Next.jsの状態を更新 ←★ 2. Historyを書き換え beforeunloadイベント 1. Historyの位置が変わる 2. Next.jsの状態を更新 ←★
  14. © LayerX Inc. 28 Next.jsのページ遷移:back/forward App Router History API ブラウザの戻る‧進むを押した場合のページ遷移

    popstateイベント React 新しいページを描画 スタックの indexを変更 URLも変わる Routerの状態を更新
  15. © LayerX Inc. 29 Next.jsのページ遷移:back/forward App Router History API ブラウザの戻る‧進むを押した場合のページ遷移

    popstateイベント React 新しいページを描画 スタックの indexを変更 URLも変わる Routerの状態を更新 Historyの書き換えは キャンセルできない ここで⽌めれてもURLは 変わったまま
  16. © LayerX Inc. 30 Next.jsのページ遷移:back/forward スタックの位置を変更 History API 戻る‧進むされたイベントを握りつぶして、Historyを戻したい popstateイベント

    next-navigation-guard キャンセルしたいか確認 history.go(差分) popstateイベント イベントを ここで握りつぶしたい スタックの位置を変更 Next.jsが知らぬうちに 書き戻す
  17. © LayerX Inc. 31 Next.jsのページ遷移:back/forward / イベントを握りつぶす のび太「captureしてstopPropagation()でいけるのでは?」 <body> <div>

    <button> Capturing Phase Bubbling Phase <body> <div> <button> div.addEventListener("click", e => e.stopPropagation(), { capture: true }) div.addEventListener("click", e => e.stopPropagation()) <body> <div> <button>
  18. © LayerX Inc. 34 stopImmediatePropagation() Next.js より先に addEventListener() して stopImmediatePropagation()

    で⽌める 同じ要素に後から追加された Event Listenerを呼びださない window.addEventLisnter("popstate", () => ...) window.addEventLisnter("popstate", e => e.stopPropagation()) window.addEventLisnter("popstate", () => ...) window.addEventLisnter("popstate", () => ...) window.addEventLisnter("popstate", e => e.stopImmediatePropagation()) window.addEventLisnter("popstate", () => ...) ←Next.jsは呼ばれない
  19. © LayerX Inc. 35 Next.js より先に addEventListener() して stopImmediatePropagation() で⽌める

    popstateイベントを stopImmeidatePropagation() で握りつぶすことに成功 useEffect() より速い useLayoutEffect() で先に刺す 〜〜〜 App Router のコード
  20. © LayerX Inc. 37 Next.jsのページ遷移:back/forward スタックの位置を変更 History API 戻る‧進むされた分のスタック位置を元に戻したい popstateイベント

    next-navigation-guard 差分が0なら握りつぶす キャンセルしたいか確認 history.go(差分) popstateイベント 反映済みのindexと history.stateで引き算して 書き戻したい スタックの位置を変更 握りつぶせた!
  21. © LayerX Inc. 38 Next.jsのページ遷移:back/forward • Q. History APIだけで計算できますか ◦

    現在ページのindex取れません ◦ popstateで戻ったのか進んだのかさえ不明 • Q. Next.jsはつけてくれませんか ◦ Pages Routerでのinternalの情報しかない ◦ Next.jsはhistory.stateをあまり開放したくないように⾒える • Q. Navigation APIのnavigation.currentEntry.indexは? ◦ SafariとFirefoxでまだサポートされていません • とにかくそのままでは取れないんです!! どれくらい戻る‧進むされたか知りたい...
  22. © LayerX Inc. 40 Next.jsが呼び出す history.pushState() を 上書きして、stateにindexを⼊れる • 仕⽅がないので⾃前でつけた。さっきのpopのindexなぁに?

    🐐 • stackを積むたびにstateにindexを保存 • history.go(描画中のindex - stateのindex) で元の位置に戻せる ◦ 2つ戻されたら、2つ進めて、なかったことに 戻された数だけ進めるため、indexを保存 window.history.pushState = function (state, unused, url) { state = { …state, index: ++currentIndex } origPushState.call(this, state, unused, url) } /posts/123 /posts state.index: 1 state.index: 2 / state.index: 0
  23. © LayerX Inc. 42 • 全⼈類がページ遷移を⽌めたい oO(BtoB SaaSでは特に) • ⾏動指針

    “Bet Technology” なので、ないので作った • • ReactのContext、App Router、History API、それぞれの間に割り込むことで解決できた ◦ Hack #1: Context 経由で Router を差し替える ◦ Hack #2: stopImmediatePropagation() でイベントを⽌める ◦ Hack #3: history.pushState() を上書きしてstateにindexを⼊れる • • ライブラリ、フレームワーク、APIの仕様を把握しコードを読めば、 だいたいなんでも解決できる! • • (npm|yarn|pnpm) install next-navigation-guard • https://github.com/LayerXcom/next-navigation-guard • • 余談:history.go(-delta) は、Nuxtから拝借しました。Next.jsでも公式で実装して欲しいです! まとめ
  24. © LayerX Inc. 43 • Pages Routerの場合は? ◦ popstateに割り込まなくても、router.beforePopState(() =>

    ...) で割り込み可能 ◦ falseを返すとNext.js側の状態変更はしないが、stackの位置を元に戻してくれない ◦ App Router⽤にstackの位置を元に戻す処理を実装していたので、そのままPages Routerに転 ⽤ • window.confirm()は同期的だけど、カスタムダイアログは⾮同期。どうやって? ◦ stopImmediatePropagation() してから dispatchEvent(new PopStateEvent("popstate", { ... })) してます ちなみに