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

Cancel Next.js Page Navigation: Full Throttle

ypresto
September 28, 2024

Cancel Next.js Page Navigation: Full Throttle

LX Web Frontend Night: Unleash Next.js

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

This is English edition.
Japanese edition is here: https://speakerdeck.com/ypresto/hack-to-prevent-page-navigation-in-next-js

ypresto

September 28, 2024
Tweet

More Decks by ypresto

Other Decks in Programming

Transcript

  1. © LayerX Inc. Cancel Next.js Page Navigation: Full Throttle ypresto

    @ LX Web Frontend Night: Unleash Next.js (2024/09/18) (NOTE: This slide is English edition. Original talk was Japanese.)
  2. © LayerX Inc. 2 Self introduce • LayerX Bakuraku Div.

    ◦ Invoice Team ◦ SWE ◦ Creating a SaaS for accountants • Frontend-specialized & Full-stack • Splatoon, Photo, Going out with my child ypresto (yuya_presto)
  3. © LayerX Inc. 3 Bakuraku and Next.js We use React

    + Next.js for newer products Considering migration to Next.js Electronic Books Preservation Invoice Reception / Journal Entry Application / Expense SSO Corporate Credit Cards Invoice Issuing Attendance Management
  4. © LayerX Inc. 4 • By evolving FE development environment

    ... • More easy, comfortable and thrilled experience • To both our customers and developers Bakuraku and Next.js “Frontend Vision” by our FE Guild Why we use React for new products • Good for our team including BE SWE • Fewer context switch between products • Accelerates platform dev. with single FW Our statement for frontend development Base: Customer Perspective Performance a11y Multi-device
  5. © LayerX Inc. 5 “Unsaved Changes” dialog and us Did

    you implement “Unsaved Changes” dialog? example.com says You have unsaved changes that will be lost. Cancel OK
  6. © LayerX Inc. 6 “Unsaved Changes” dialog and us UI

    patterns to prevent losing unsaved data Controlled-by-User “Confirmation Dialog” Write-and-Forget “Auto Save” “Draft”
  7. © LayerX Inc. 7 “Unsaved Changes” dialog and us UI

    patterns to prevent losing unsaved data Controlled-by-User “Confirmation Dialog” Write-and-Forget “Auto Save” “Draft” Change-sensitive data like financial or application form Editing already published/submit data Revertible data like internal documents Private data like drafts Required in BtoB SaaS!
  8. © LayerX Inc. 8 “Unsaved Changes” dialog and us Then,

    is yours REALLY shows when back/forward is clicked? It is VERY hard to cancel page navigation with Next.js (which utilizes History API). example.com says You have unsaved changes that will be lost. Cancel OK
  9. © LayerX Inc. 9 “Unsaved Changes” dialog and us Discussions

    in Next.js “For me this worked!” ”No, it does not work when XXX” Infinite Loop https://github.com/vercel/next.js/discussions/9662 https://github.com/vercel/next.js/discussions/47020 App Router plz!!1! Pages Router plz!!
  10. © LayerX Inc. 12 Usage Navigation Guard library, supports both

    App Router and Pages Router • In app/layout.tsx or pages/_app.tsx, wrap with <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> • • * You can also use enabled: () => isChanged() if necessary.
  11. Next.js and History API * I’ll talk about App Router

    in this slide 3 Hacks to Cancel Page Navigation
  12. © LayerX Inc. 14 Next.js and History API Non-SPA transition

    • Reload Button • External site SPA: In-App Links • <Link> • router.push() • router.replace() • router.refresh() Let’s Cancel Page Navigation: Categolizing • Back / Forward Buttons • router.back() • router.forward() SPA: back/forward
  13. © LayerX Inc. 15 Next.js and History API Non-SPA transition

    • Reload Button • External site SPA: In-App Links • <Link> • router.push() • router.replace() • router.refresh() Let’s Cancel Page Navigation: Categolizing • Back / Forward Buttons • router.back() • router.forward() SPA: back/forward beforeunload event From MDN
  14. © LayerX Inc. 17 Next.js and History API Non-SPA transition

    • Reload Button • External site SPA: In-App Links • <Link> • router.push() • router.replace() • router.refresh() Let’s Cancel Page Navigation: Categolizing • Back / Forward Buttons • router.back() • router.forward() SPA: back/forward beforeunload event Next.js & History API
  15. © LayerX Inc. 18 Next.js and History API • Change

    URL and history from JS without refetching by browser • Then replace page content from JS • history.state Holds custom state data for current page • popstate event Published after stack index and URL changed by back/forward button /posts/123 /posts Self-manage Page Navigation and History by the App SPA and History API /posts/124 state state state history.pushState() to add history.replaceState() to overwrite Back Button history.forward() / history.go(x) Forward Button history.back() / history.go(-x) Current Page / state
  16. © LayerX Inc. 19 Next.js and History API Non-SPA transition

    • Reload Button • External site SPA: In-App Links • <Link> • router.push() • router.replace() • router.refresh() Let’s Cancel Page Navigation: Categolizing • Back / Forward Buttons • router.back() • router.forward() SPA: back/forward beforeunload event 1. Updates internal state of Next.js 2. Updates History
  17. © LayerX Inc. 20 Next.js Page Navigation : In-App Links

    App Router History API history.pushState() What happens when <Link> is clicked <Link> : router.push() React Update Router state Render next page
  18. © LayerX Inc. 21 Next.js Page Navigation : In-App Links

    App Router History API history.pushState() What happens when <Link> is clicked <Link> : router.push() React Update Router state Render next page Can we stop at here? (before state change)
  19. © LayerX Inc. 22 Next.js Page Navigation : In-App Links

    No official event hooks in Next.js Read the code of router.push() in App Router https://github.com/vercel/next.js/blob/9a1cd356dbafbfcf23d1b9ec05f772f766d05580/packages/next/src/client/components/app-router.tsx#L390-L394 Stop!!
  20. © LayerX Inc. 24 Replace the Router via Context.Provider <AppRouterContext.Provider

    value={OfficialRouter}> ... <AppRouterContext.Provider value={WrappedRouter}> {app} </AppRouterContext.Provider> ... </AppRouterContext.Provider> Nearest-parent rule of Context.Provider The {app} sees this one Patching without destructive change of original Router instance. It’s better.
  21. © LayerX Inc. 25 Next.js and History API Non-SPA transition

    • Reload Button • External site SPA: In-App Links • <Link> • router.push() • router.replace() • router.refresh() Let’s Cancel Page Navigation: Categolizing • Back / Forward Buttons • router.back() • router.forward() SPA: back/forward beforeunload event 1. Updates internal state of Next.js 2. Updates History 1. Update index of History 2. Updates state of Next.js Reverse order than <Link>
  22. © LayerX Inc. 26 Next.js Page Navigation : Back/Forward App

    Router History API What happens when Back/Forward button is clicked popstate event React Render next page Change the stack index and the URL Update Router state
  23. © LayerX Inc. 27 Next.js Page Navigation : Back/Forward App

    Router History API What happens when Back/Forward button is clicked popstate event React Render next page Change the stack index and the URL Update Router state We could not preventDefault() the history change. URL already changed even we can stop at here.
  24. © LayerX Inc. 28 Next.js Page Navigation : Back/Forward Change

    the stack index and the URL History API Suppress event then restore the history: Back to the Future! popstate event next-navigation-guard window.confirm() history.go(-delta) popstate event We want to suppress the event here Change the stack index and the URL Revert history change before Next.js realizes it
  25. © LayerX Inc. 29 Next.js Page Navigation : Back/Forward >

    Suppress the event You may know capturing listener + stopPropagation() hack. <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>
  26. © LayerX Inc. 30 Next.js Page Navigation : Back/Forward >

    Suppress the event No, Chrome does not. Firefox and Safari does 🤦 capture is not called first for popstate event in Chrome. In Firefox it works.
  27. © LayerX Inc. 32 stopImmediatePropagation() addEventListener() earlier than Next.js, then

    stopImmediatePropagation() to suppress Suppresses event listeners added later than this window.addEventLisnter("popstate", () => ...) window.addEventLisnter("popstate", e => e.stopPropagation()) window.addEventLisnter("popstate", () => ...) window.addEventLisnter("popstate", () => ...) window.addEventLisnter("popstate", e => e.stopImmediatePropagation()) window.addEventLisnter("popstate", () => ...) ←Listener in Next.js won’t be called
  28. © LayerX Inc. 33 addEventListener() earlier than Next.js, then stopImmediatePropagation()

    to suppress Successfully suppressed popstate event using stopImmeidatePropagation() useLayoutEffect() is faster than useEffect() 〜〜〜 Code of App Router
  29. © LayerX Inc. 34 Next.js Page Navigation : Back/Forward Change

    the stack index and the URL History API Restore stack position popstate event next-navigation-guard Suppress if delta === 0 window.confirm() history.go(-delta) popstate event Restore to original position delta = history.state.index - renderedIndex Change the stack index and the URL Suppressed!
  30. © LayerX Inc. 35 Next.js Page Navigation : Back/Forward •

    Q. History API? ◦ No, there is no index property. ◦ We even cannot know whether user backed or forwarded. • Q. Next.js? ◦ Internal state of Pages Router only. ◦ It looks like Next.js doesn’t want to expose history.state, maybe. • Q. Navigation API have navigation.currentEntry.index! ◦ No, Safari and Firefox does not support it. • Whatever we does not have any history index information!!! How many the user navigated back or forward…???
  31. © LayerX Inc. 37 Overwrite history.pushState() to set index in

    history.state • Add index information to state at every pushState() • history.go(renderedIndex - state.index) to restore original stack position ◦ If user backed 2, then forward 2 to revert it. Set index to calculate delta of back/forward 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
  32. © LayerX Inc. 39 • We Humanity needs Navigation Guard

    oO(Especially for BtoB SaaS) • Our principle is “Bet Technology”, so we made it. • • Hooked into React Context, App Router, History API ◦ Hack #1: Replace the Router via Context.Provider ◦ Hack #2: Suppress an event using stopImmediatePropagation() ◦ Hack #3: Overwrite history.pushState() to set index in history.state • • Use the source, document and specification of library, framework and API, Luke! Can solve almost anything with them! • • (npm|yarn|pnpm) install next-navigation-guard • https://github.com/LayerXcom/next-navigation-guard • • NOTE:history.go(-delta) is inspired by Nuxt. Please implement it in Next.js officially! Wrap up
  33. © LayerX Inc. 40 • Pages Router? ◦ Just return

    false in router.beforePopState(() => ...) suppresses router state change. ◦ But stack (URL) does not restore after that. ◦ Restoring stack position is the same as App Router’s one. • window.confirm() is synchronous, but custom dialog is async. How? ◦ stopImmediatePropagation() then dispatchEvent(new PopStateEvent("popstate", { ... })) Appendix