Slide 1

Slide 1 text

© 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.)

Slide 2

Slide 2 text

© 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)

Slide 3

Slide 3 text

© 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

Slide 4

Slide 4 text

© 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

Slide 5

Slide 5 text

© 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

Slide 6

Slide 6 text

© 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”

Slide 7

Slide 7 text

© 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!

Slide 8

Slide 8 text

© 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

Slide 9

Slide 9 text

© 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!!

Slide 10

Slide 10 text

Even when we are using Next.js We Humanity needs Navigation Guard

Slide 11

Slide 11 text

Navigation Guard library, supports both App Router and Pages Router We made it! Bet Technology!

Slide 12

Slide 12 text

© LayerX Inc. 12 Usage Navigation Guard library, supports both App Router and Pages Router ● In app/layout.tsx or pages/_app.tsx, wrap with ● ● useNavigationGuard({ enabled: isChanged, confirm: () => window.confirm() }) ● ● const navGuard = useNavigationGuard({ enabled: isChanged }) navGuard.accept()}>OK navGuard.reject()}>OK ● ● * You can also use enabled: () => isChanged() if necessary.

Slide 13

Slide 13 text

Next.js and History API * I’ll talk about App Router in this slide 3 Hacks to Cancel Page Navigation

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

© LayerX Inc. 15 Next.js and History API Non-SPA transition ● Reload Button ● External site SPA: In-App Links ● ● 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

Slide 16

Slide 16 text

Nothing we can do any more. ¯\_(ツ)_/¯

Slide 17

Slide 17 text

© LayerX Inc. 17 Next.js and History API Non-SPA transition ● Reload Button ● External site SPA: In-App Links ● ● 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

Slide 18

Slide 18 text

© 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

Slide 19

Slide 19 text

© LayerX Inc. 19 Next.js and History API Non-SPA transition ● Reload Button ● External site SPA: In-App Links ● ● 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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

© 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!!

Slide 23

Slide 23 text

Hack #1 Replace the Router via Context.Provider

Slide 24

Slide 24 text

© LayerX Inc. 24 Replace the Router via Context.Provider ... {app} ... Nearest-parent rule of Context.Provider The {app} sees this one Patching without destructive change of original Router instance. It’s better.

Slide 25

Slide 25 text

© LayerX Inc. 25 Next.js and History API Non-SPA transition ● Reload Button ● External site SPA: In-App Links ● ● 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

Slide 26

Slide 26 text

© 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

Slide 27

Slide 27 text

© 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.

Slide 28

Slide 28 text

© 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

Slide 29

Slide 29 text

© LayerX Inc. 29 Next.js Page Navigation : Back/Forward > Suppress the event You may know capturing listener + stopPropagation() hack.
Capturing Phase Bubbling Phase
div.addEventListener("click", e => e.stopPropagation(), { capture: true }) div.addEventListener("click", e => e.stopPropagation())

Slide 30

Slide 30 text

© 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.

Slide 31

Slide 31 text

Suppress an event using stopImmediatePropagation() Hack #2

Slide 32

Slide 32 text

© 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

Slide 33

Slide 33 text

© 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

Slide 34

Slide 34 text

© 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!

Slide 35

Slide 35 text

© 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…???

Slide 36

Slide 36 text

Overwrite history.pushState() to set index in history.state Hack #3

Slide 37

Slide 37 text

© 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

Slide 38

Slide 38 text

(npm|yarn|pnpm) install next-navigation-guard https://github.com/LayerXcom/next-navigation-guard We did it!!!

Slide 39

Slide 39 text

© 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

Slide 40

Slide 40 text

© 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