From d52d36d3b3d3d9f7ded6cec62d8eaf2ac68f07a0 Mon Sep 17 00:00:00 2001 From: Zack Tanner Date: Tue, 2 Jan 2024 19:49:55 -0800 Subject: [PATCH] gracefully handle client router segment mismatches (#60141) ### What? Next.js throws a hard `SEGMENT MISMATCH` error when the reducers were unable to apply the a patch to the router tree from the server response. ### How? Rather than crashing the router, this will treat segment mismatches as a MPA navigation, to restore the client router into a working state. ### Test Plan If there are specific scenarios where Next.js throws this error, it should most likely be fixed in Next and not in user-land. Since it's not currently obvious what scenarios will trigger this error, this PR serves to recover from a mismatch more gracefully and provides some debug information rather than crashing the application. As such, there's no easy way to create an E2E test for this and I've instead opted for a simple unit test. Closes NEXT-1878 [slack x-ref](https://vercel.slack.com/archives/C017QMYC5FB/p1704214439768469) [slack x-ref](https://vercel.slack.com/archives/C03KAR5DCKC/p1702565978694519) --------- Co-authored-by: JJ Kasper --- .../router-reducer/handle-segment-mismatch.ts | 28 +++++++++ .../reducers/fast-refresh-reducer.ts | 3 +- .../reducers/refresh-reducer.ts | 3 +- .../reducers/server-action-reducer.ts | 3 +- .../reducers/server-patch-reducer.test.tsx | 58 +++++++++++++++++++ .../reducers/server-patch-reducer.ts | 3 +- 6 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 packages/next/src/client/components/router-reducer/handle-segment-mismatch.ts diff --git a/packages/next/src/client/components/router-reducer/handle-segment-mismatch.ts b/packages/next/src/client/components/router-reducer/handle-segment-mismatch.ts new file mode 100644 index 0000000000000..6ee410e7028ff --- /dev/null +++ b/packages/next/src/client/components/router-reducer/handle-segment-mismatch.ts @@ -0,0 +1,28 @@ +import type { FlightRouterState } from '../../../server/app-render/types' +import { handleExternalUrl } from './reducers/navigate-reducer' +import type { + ReadonlyReducerState, + ReducerActions, +} from './router-reducer-types' + +/** + * Handles the case where the client router attempted to patch the tree but, due to a mismatch, the patch failed. + * This will perform an MPA navigation to return the router to a valid state. + */ +export function handleSegmentMismatch( + state: ReadonlyReducerState, + action: ReducerActions, + treePatch: FlightRouterState +) { + if (process.env.NODE_ENV === 'development') { + console.warn( + 'Performing hard navigation because your application experienced an unrecoverable error. If this keeps occurring, please file a Next.js issue.\n\n' + + 'Reason: Segment mismatch\n' + + `Last Action: ${action.type}\n\n` + + `Current Tree: ${JSON.stringify(state.tree)}\n\n` + + `Tree Patch Payload: ${JSON.stringify(treePatch)}` + ) + } + + return handleExternalUrl(state, {}, state.canonicalUrl, true) +} diff --git a/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts index 4bb8219421472..127da795505ab 100644 --- a/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts @@ -13,6 +13,7 @@ import { handleMutable } from '../handle-mutable' import { applyFlightData } from '../apply-flight-data' import type { CacheNode } from '../../../../shared/lib/app-router-context.shared-runtime' import { createEmptyCacheNode } from '../../app-router' +import { handleSegmentMismatch } from '../handle-segment-mismatch' // A version of refresh reducer that keeps the cache around instead of wiping all of it. function fastRefreshReducerImpl( @@ -71,7 +72,7 @@ function fastRefreshReducerImpl( ) if (newTree === null) { - throw new Error('SEGMENT MISMATCH') + return handleSegmentMismatch(state, action, treePatch) } if (isNavigatingToNewRootLayout(currentTree, newTree)) { diff --git a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts index e6b0c0b54797c..d5a0bb15c95cd 100644 --- a/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/refresh-reducer.ts @@ -13,6 +13,7 @@ import { handleMutable } from '../handle-mutable' import type { CacheNode } from '../../../../shared/lib/app-router-context.shared-runtime' import { fillLazyItemsTillLeafWithHead } from '../fill-lazy-items-till-leaf-with-head' import { createEmptyCacheNode } from '../../app-router' +import { handleSegmentMismatch } from '../handle-segment-mismatch' export function refreshReducer( state: ReadonlyReducerState, @@ -69,7 +70,7 @@ export function refreshReducer( ) if (newTree === null) { - throw new Error('SEGMENT MISMATCH') + return handleSegmentMismatch(state, action, treePatch) } if (isNavigatingToNewRootLayout(currentTree, newTree)) { diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index fd6433f4b2114..912b172c6e360 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -38,6 +38,7 @@ import { handleMutable } from '../handle-mutable' import { fillLazyItemsTillLeafWithHead } from '../fill-lazy-items-till-leaf-with-head' import { createEmptyCacheNode } from '../../app-router' import { extractPathFromFlightRouterState } from '../compute-changed-path' +import { handleSegmentMismatch } from '../handle-segment-mismatch' type FetchServerActionResult = { redirectLocation: URL | undefined @@ -224,7 +225,7 @@ export function serverActionReducer( ) if (newTree === null) { - throw new Error('SEGMENT MISMATCH') + return handleSegmentMismatch(state, action, treePatch) } if (isNavigatingToNewRootLayout(currentTree, newTree)) { diff --git a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx index 171cbd0503bdd..47773163997aa 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx +++ b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.test.tsx @@ -501,4 +501,62 @@ describe('serverPatchReducer', () => { } `) }) + + it("should gracefully recover if the server patch doesn't match the current tree", async () => { + const initialTree = getInitialRouterStateTree() + const initialCanonicalUrl = '/linking' + const children = ( + + + Root layout + + ) + + const state = createInitialRouterState({ + buildId, + initialTree, + initialHead: null, + initialCanonicalUrl, + initialSeedData: ['', {}, children], + initialParallelRoutes: new Map(), + isServer: false, + location: new URL('/linking/about', 'https://localhost') as any, + }) + + const action: ServerPatchAction = { + type: ACTION_SERVER_PATCH, + // this flight data is intentionally completely unrelated to the existing tree + flightData: [ + [ + 'children', + 'tree-patch-failure', + 'children', + 'new-page', + ['new-page', { children: ['__PAGE__', {}] }], + null, + null, + ], + ], + previousTree: [ + '', + { + children: [ + 'linking', + { + children: ['about', { children: ['', {}] }], + }, + ], + }, + undefined, + undefined, + true, + ], + overrideCanonicalUrl: undefined, + } + + const newState = await serverPatchReducer(state, action) + expect(newState.pushRef.pendingPush).toBe(true) + expect(newState.pushRef.mpaNavigation).toBe(true) + expect(newState.canonicalUrl).toBe('/linking/about') + }) }) diff --git a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts index 08a5634a9b9ba..aafa98fcc2c46 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-patch-reducer.ts @@ -12,6 +12,7 @@ import { applyFlightData } from '../apply-flight-data' import { handleMutable } from '../handle-mutable' import type { CacheNode } from '../../../../shared/lib/app-router-context.shared-runtime' import { createEmptyCacheNode } from '../../app-router' +import { handleSegmentMismatch } from '../handle-segment-mismatch' export function serverPatchReducer( state: ReadonlyReducerState, @@ -49,7 +50,7 @@ export function serverPatchReducer( ) if (newTree === null) { - throw new Error('SEGMENT MISMATCH') + return handleSegmentMismatch(state, action, treePatch) } if (isNavigatingToNewRootLayout(currentTree, newTree)) {