diff --git a/packages/next/src/client/components/router-reducer/ppr-navigations.ts b/packages/next/src/client/components/router-reducer/ppr-navigations.ts index 3dedac570bb63..4e47d05852201 100644 --- a/packages/next/src/client/components/router-reducer/ppr-navigations.ts +++ b/packages/next/src/client/components/router-reducer/ppr-navigations.ts @@ -9,7 +9,10 @@ import type { ChildSegmentMap, ReadyCacheNode, } from '../../../shared/lib/app-router-context.shared-runtime' -import { DEFAULT_SEGMENT_KEY } from '../../../shared/lib/segment' +import { + DEFAULT_SEGMENT_KEY, + PAGE_SEGMENT_KEY, +} from '../../../shared/lib/segment' import { matchSegment } from '../match-segments' import { createRouterCacheKey } from './create-router-cache-key' import type { FetchServerResponseResult } from './fetch-server-response' @@ -108,13 +111,50 @@ export function updateCacheNodeOnNavigation( const newSegmentChild = newRouterStateChild[0] const newSegmentKeyChild = createRouterCacheKey(newSegmentChild) + const oldSegmentChild = + oldRouterStateChild !== undefined ? oldRouterStateChild[0] : undefined + const oldCacheNodeChild = oldSegmentMapChild !== undefined ? oldSegmentMapChild.get(newSegmentKeyChild) : undefined let taskChild: Task | null - if (matchSegment(newSegmentChild, oldRouterStateChild[0])) { + if (newSegmentChild === PAGE_SEGMENT_KEY) { + // This is a leaf segment — a page, not a shared layout. We always apply + // its data. + taskChild = spawnPendingTask( + newRouterStateChild, + prefetchDataChild !== undefined ? prefetchDataChild : null, + prefetchHead, + isPrefetchStale + ) + } else if (newSegmentChild === DEFAULT_SEGMENT_KEY) { + // This is another kind of leaf segment — a default route. + // + // Default routes have special behavior. When there's no matching segment + // for a parallel route, Next.js preserves the currently active segment + // during a client navigation — but not for initial render. The server + // leaves it to the client to account for this. So we need to handle + // it here. + if (oldRouterStateChild !== undefined) { + // Reuse the existing Router State for this segment. We spawn a "task" + // just to keep track of the updated router state; unlike most, it's + // already fulfilled and won't be affected by the dynamic response. + taskChild = spawnReusedTask(oldRouterStateChild) + } else { + // There's no currently active segment. Switch to the "create" path. + taskChild = spawnPendingTask( + newRouterStateChild, + prefetchDataChild !== undefined ? prefetchDataChild : null, + prefetchHead, + isPrefetchStale + ) + } + } else if ( + oldSegmentChild !== undefined && + matchSegment(newSegmentChild, oldSegmentChild) + ) { if ( oldCacheNodeChild !== undefined && oldRouterStateChild !== undefined @@ -150,27 +190,13 @@ export function updateCacheNodeOnNavigation( ) } } else { - // The segment does not match. - if (newSegmentChild === DEFAULT_SEGMENT_KEY) { - // This is a special case related to default routes. When there's no - // matching segment for a parallel route, Next.js preserves the - // currently active segment during a client navigation — but not for - // initial render. The server leaves it to the client to account for - // this. So we need to handle it here. - // - // Reuse the existing Router State for this segment. We spawn a "task" - // just to keep track of the updated router state; unlike most, it's - // already fulfilled and won't be affected by the dynamic response. - taskChild = spawnReusedTask(oldRouterStateChild) - } else { - // This is a new tree. Switch to the "create" path. - taskChild = spawnPendingTask( - newRouterStateChild, - prefetchDataChild !== undefined ? prefetchDataChild : null, - prefetchHead, - isPrefetchStale - ) - } + // This is a new tree. Switch to the "create" path. + taskChild = spawnPendingTask( + newRouterStateChild, + prefetchDataChild !== undefined ? prefetchDataChild : null, + prefetchHead, + isPrefetchStale + ) } if (taskChild !== null) { diff --git a/test/e2e/app-dir/ppr-navigations/ppr-navigations.test.ts b/test/e2e/app-dir/ppr-navigations/ppr-navigations.test.ts index d65eed42de51d..11877171bb1da 100644 --- a/test/e2e/app-dir/ppr-navigations/ppr-navigations.test.ts +++ b/test/e2e/app-dir/ppr-navigations/ppr-navigations.test.ts @@ -176,6 +176,27 @@ describe('ppr-navigations', () => { expect(await dynamicContainer.innerText()).toBe('Some data [dynamic]') } ) + + test( + 'updates page data during a nav even if no shared layouts have changed ' + + '(e.g. updating a search param on the current page)', + async () => { + next = await createNext({ + files: __dirname + '/search-params', + }) + const browser = await next.browser('/') + + // Click a link that updates the current page's search params. + const link = await browser.elementByCss('a') + await link.click() + + // Confirm that the page re-rendered with the new search params. + const searchParamsContainer = await browser.elementById('search-params') + expect(await searchParamsContainer.innerText()).toBe( + 'Search params: {"blazing":"good"}' + ) + } + ) }) // NOTE: I've intentionally not yet moved these helpers into a separate diff --git a/test/e2e/app-dir/ppr-navigations/search-params/app/layout.tsx b/test/e2e/app-dir/ppr-navigations/search-params/app/layout.tsx new file mode 100644 index 0000000000000..a3a86a5ca1e12 --- /dev/null +++ b/test/e2e/app-dir/ppr-navigations/search-params/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Root({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/ppr-navigations/search-params/app/page.tsx b/test/e2e/app-dir/ppr-navigations/search-params/app/page.tsx new file mode 100644 index 0000000000000..51bc57820d938 --- /dev/null +++ b/test/e2e/app-dir/ppr-navigations/search-params/app/page.tsx @@ -0,0 +1,19 @@ +import Link from 'next/link' + +export default async function Page({ + searchParams, +}: { + searchParams: { [key: string]: string | string[] | undefined } +}) { + const hasParams = Object.keys(searchParams).length > 0 + return ( + <> + Go + {hasParams ? ( +
+ Search params: {JSON.stringify(searchParams)} +
+ ) : null} + + ) +} diff --git a/test/e2e/app-dir/ppr-navigations/search-params/next.config.js b/test/e2e/app-dir/ppr-navigations/search-params/next.config.js new file mode 100644 index 0000000000000..016ac8833b57f --- /dev/null +++ b/test/e2e/app-dir/ppr-navigations/search-params/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { + ppr: true, + }, +} + +module.exports = nextConfig