diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 13c6811c20017..d66dc65eb2f13 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -35,7 +35,7 @@ import { PrefetchKind, } from './router-reducer/router-reducer-types' import type { - PushRef, + AppRouterState, ReducerActions, RouterChangeByServerResponse, RouterNavigate, @@ -49,6 +49,7 @@ import { import { useReducerWithReduxDevtools, useUnwrapState, + type ReduxDevtoolsSyncFn, } from './use-reducer-with-devtools' import { ErrorBoundary } from './error-boundary' import { createInitialRouterState } from './router-reducer/create-initial-router-state' @@ -110,17 +111,14 @@ function isExternalURL(url: URL) { } function HistoryUpdater({ - tree, - pushRef, - canonicalUrl, + appRouterState, sync, }: { - tree: FlightRouterState - pushRef: PushRef - canonicalUrl: string - sync: () => void + appRouterState: AppRouterState + sync: ReduxDevtoolsSyncFn }) { useInsertionEffect(() => { + const { tree, pushRef, canonicalUrl } = appRouterState const historyState = { ...(process.env.__NEXT_WINDOW_HISTORY_SUPPORT && pushRef.preserveCustomHistoryState @@ -148,8 +146,8 @@ function HistoryUpdater({ originalReplaceState(historyState, '', canonicalUrl) } } - sync() - }, [tree, pushRef, canonicalUrl, sync]) + sync(appRouterState) + }, [appRouterState, sync]) return null } @@ -589,9 +587,7 @@ function Router({ return ( <> diff --git a/packages/next/src/client/components/use-reducer-with-devtools.ts b/packages/next/src/client/components/use-reducer-with-devtools.ts index 1db2769684db3..dd75194c63a3b 100644 --- a/packages/next/src/client/components/use-reducer-with-devtools.ts +++ b/packages/next/src/client/components/use-reducer-with-devtools.ts @@ -9,6 +9,8 @@ import { } from './router-reducer/router-reducer-types' import { ActionQueueContext } from '../../shared/lib/router/action-queue' +export type ReduxDevtoolsSyncFn = (state: AppRouterState) => void + function normalizeRouterState(val: any): any { if (val instanceof Map) { const obj: { [key: string]: any } = {} @@ -86,13 +88,13 @@ export function useUnwrapState(state: ReducerState): AppRouterState { function useReducerWithReduxDevtoolsNoop( initialState: AppRouterState -): [ReducerState, Dispatch, () => void] { +): [ReducerState, Dispatch, ReduxDevtoolsSyncFn] { return [initialState, () => {}, () => {}] } function useReducerWithReduxDevtoolsImpl( initialState: AppRouterState -): [ReducerState, Dispatch, () => void] { +): [ReducerState, Dispatch, ReduxDevtoolsSyncFn] { const [state, setState] = React.useState(initialState) const actionQueue = useContext(ActionQueueContext) @@ -149,14 +151,20 @@ function useReducerWithReduxDevtoolsImpl( [actionQueue, initialState] ) - const sync = useCallback(() => { + // Sync is called after a state update in the HistoryUpdater, + // for debugging purposes. Since the reducer state may be a Promise, + // we let the app router use() it and sync on the resolved value if + // something changed. + // Using the `state` here would be referentially unstable and cause + // undesirable re-renders and history updates. + const sync = useCallback((resolvedState) => { if (devtoolsConnectionRef.current) { devtoolsConnectionRef.current.send( { type: 'RENDER_SYNC' }, - normalizeRouterState(state) + normalizeRouterState(resolvedState) ) } - }, [state]) + }, []) return [state, dispatch, sync] } diff --git a/test/e2e/app-dir/navigation/app/search-params/shallow/other/page.js b/test/e2e/app-dir/navigation/app/search-params/shallow/other/page.js new file mode 100644 index 0000000000000..642a064d8fb5b --- /dev/null +++ b/test/e2e/app-dir/navigation/app/search-params/shallow/other/page.js @@ -0,0 +1,5 @@ +import React from 'react' + +export default function Page() { + return

Prefetch target page

+} diff --git a/test/e2e/app-dir/navigation/app/search-params/shallow/page.js b/test/e2e/app-dir/navigation/app/search-params/shallow/page.js new file mode 100644 index 0000000000000..9f012b0ffc2d7 --- /dev/null +++ b/test/e2e/app-dir/navigation/app/search-params/shallow/page.js @@ -0,0 +1,19 @@ +'use client' + +import React from 'react' +import Link from 'next/link' + +export default function Page() { + const setShallowSearchParams = React.useCallback(() => { + // Maintain history state, but set a shallow search param + history.replaceState(history.state, '', '?foo=bar') + }, []) + return ( + <> + + + Then hover me + + + ) +} diff --git a/test/e2e/app-dir/navigation/navigation.test.ts b/test/e2e/app-dir/navigation/navigation.test.ts index 4bf3238bb5308..4a568331ca0f6 100644 --- a/test/e2e/app-dir/navigation/navigation.test.ts +++ b/test/e2e/app-dir/navigation/navigation.test.ts @@ -55,6 +55,17 @@ createNextDescribe( }, 'success') }) + it('should not reset shallow url updates on prefetch', async () => { + const browser = await next.browser('/search-params/shallow') + const button = await browser.elementByCss('button') + await button.click() + expect(await browser.url()).toMatch(/\?foo=bar$/) + const link = await browser.elementByCss('a') + await link.hover() + // Hovering a prefetch link should keep the URL intact + expect(await browser.url()).toMatch(/\?foo=bar$/) + }) + describe('useParams identity between renders', () => { async function runTests(page: string) { const browser = await next.browser(page)