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)