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

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

ypresto
September 18, 2024

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

LX Frontend Night: Unleash Next.js

https://github.com/LayerXcom/next-navigation-guard

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.decline()}>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", { ... })) してます ちなみに