Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/next-core/src/browser_variant_modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub static BROWSER_VARIANT_MODULES: &[&str] = &[
"client/components/instant-samples",
"client/components/instant-validation/impl",
"client/components/navigation-dynamic-rendering",
"client/components/router-reducer/create-segment-key",
"client/components/server-async-storage",
"client/components/unstable-rethrow",
];
30 changes: 15 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -258,24 +258,24 @@
"pretty-ms": "7.0.0",
"random-seed": "0.3.0",
"react": "19.0.0",
"react-builtin": "npm:react@19.3.0-canary-ff8f88fc-20260915",
"react-builtin": "npm:react@19.3.0-canary-59aff3e1-20260918",
"react-dom": "19.0.0",
"react-dom-builtin": "npm:react-dom@19.3.0-canary-ff8f88fc-20260915",
"react-dom-experimental-builtin": "npm:react-dom@0.0.0-experimental-ff8f88fc-20260915",
"react-experimental-builtin": "npm:react@0.0.0-experimental-ff8f88fc-20260915",
"react-is-builtin": "npm:react-is@19.3.0-canary-ff8f88fc-20260915",
"react-server-dom-turbopack": "npm:react-server-dom-turbopack@19.3.0-canary-ff8f88fc-20260915",
"react-server-dom-turbopack-experimental": "npm:react-server-dom-turbopack@0.0.0-experimental-ff8f88fc-20260915",
"react-server-dom-webpack": "npm:react-server-dom-webpack@19.3.0-canary-ff8f88fc-20260915",
"react-server-dom-webpack-experimental": "npm:react-server-dom-webpack@0.0.0-experimental-ff8f88fc-20260915",
"react-dom-builtin": "npm:react-dom@19.3.0-canary-59aff3e1-20260918",
"react-dom-experimental-builtin": "npm:react-dom@0.0.0-experimental-59aff3e1-20260918",
"react-experimental-builtin": "npm:react@0.0.0-experimental-59aff3e1-20260918",
"react-is-builtin": "npm:react-is@19.3.0-canary-59aff3e1-20260918",
"react-server-dom-turbopack": "npm:react-server-dom-turbopack@19.3.0-canary-59aff3e1-20260918",
"react-server-dom-turbopack-experimental": "npm:react-server-dom-turbopack@0.0.0-experimental-59aff3e1-20260918",
"react-server-dom-webpack": "npm:react-server-dom-webpack@19.3.0-canary-59aff3e1-20260918",
"react-server-dom-webpack-experimental": "npm:react-server-dom-webpack@0.0.0-experimental-59aff3e1-20260918",
"react-ssr-prepass": "1.0.8",
"react-virtualized": "9.22.3",
"request-promise-core": "1.1.2",
"resolve-from": "5.0.0",
"sass": "1.54.0",
"satori": "0.29.0",
"scheduler-builtin": "npm:scheduler@0.28.0-canary-ff8f88fc-20260915",
"scheduler-experimental-builtin": "npm:scheduler@0.0.0-experimental-ff8f88fc-20260915",
"scheduler-builtin": "npm:scheduler@0.28.0-canary-59aff3e1-20260918",
"scheduler-experimental-builtin": "npm:scheduler@0.0.0-experimental-59aff3e1-20260918",
"seedrandom": "3.0.5",
"semver": "7.3.7",
"serve-handler": "6.1.6",
Expand Down Expand Up @@ -319,10 +319,10 @@
"@types/react-dom": "19.2.4",
"@types/retry": "0.12.0",
"jest-snapshot": "30.0.0-alpha.6",
"react": "npm:react@19.3.0-canary-ff8f88fc-20260915",
"react-dom": "npm:react-dom@19.3.0-canary-ff8f88fc-20260915",
"react-is": "npm:react-is@19.3.0-canary-ff8f88fc-20260915",
"scheduler": "npm:scheduler@0.28.0-canary-ff8f88fc-20260915"
"react": "npm:react@19.3.0-canary-59aff3e1-20260918",
"react-dom": "npm:react-dom@19.3.0-canary-59aff3e1-20260918",
"react-is": "npm:react-is@19.3.0-canary-59aff3e1-20260918",
"scheduler": "npm:scheduler@0.28.0-canary-59aff3e1-20260918"
},
"packageExtensions": {
"eslint-plugin-react-hooks@0.0.0-experimental-6de32a5a-20250822": {
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/build/browser-variant-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const browserVariantModules = [
'client/components/instant-samples',
'client/components/instant-validation/impl',
'client/components/navigation-dynamic-rendering',
'client/components/router-reducer/create-segment-key',
'client/components/server-async-storage',
'client/components/unstable-rethrow',
] as const
12 changes: 2 additions & 10 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -498,17 +498,9 @@ function Router({
//
// The `key` is used to remount the component whenever the head moves to
// a different segment.
const [headCacheNode, headKey, headKeyWithoutSearchParams] = matchingHead
const [headCacheNode, headKey] = matchingHead

head = (
<Head
key={
// Necessary for PPR: omit search params from the key to match prerendered keys
typeof window === 'undefined' ? headKeyWithoutSearchParams : headKey
}
headCacheNode={headCacheNode}
/>
)
head = <Head key={headKey} headCacheNode={headCacheNode} />
} else {
head = null
}
Expand Down
17 changes: 9 additions & 8 deletions packages/next/src/client/components/layout-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
InstantValidationBoundaryContext,
RenderValidationBoundaryAtThisLevel,
} from './instant-validation/boundary'
import { createRouterCacheKey } from './router-reducer/create-router-cache-key'
import { createSegmentKey } from './router-reducer/create-segment-key'
import {
useRouterBFCache,
type RouterBFCacheEntry,
Expand Down Expand Up @@ -564,11 +564,13 @@ export default function OuterLayoutRouter({
[parallelRouterKey]
: parentSegmentPath.concat([parentTreeSegment, parallelRouterKey])

// The "state" key of a segment is the one passed to React — it represents the
// identity of the UI tree. Whenever the state key changes, the tree is
// recreated and the state is reset. In the App Router model, search params do
// not cause state to be lost, so two segments with the same segment path but
// different search params should have the same state key.
// The "state" key of a segment is the one passed to React. In the browser it
// represents the identity of the UI tree. Whenever the state key changes,
// the tree is recreated and the state is reset. In the App Router model,
// search params do not cause state to be lost, so two segments with the same
// segment path but different search params should have the same state key.
// On the server, the key describes the segment's structure instead, so it
// stays the same when unknown params become known during HTML resume.
//
// The "cache" key of a segment, however, *does* include the search params, if
// it's possible that the segment accessed the search params on the server.
Expand All @@ -591,7 +593,7 @@ export default function OuterLayoutRouter({

const activeSegment = activeTree[0]
const activeCacheNode = maybeParentSlots![parallelRouterKey] ?? null
const activeStateKey = createRouterCacheKey(activeSegment, true) // no search params
const activeStateKey = createSegmentKey(activeSegment, true) // no search params

// At each level of the route tree, not only do we render the currently
// active segment — we also render the last N segments that were active at
Expand All @@ -610,7 +612,6 @@ export default function OuterLayoutRouter({
const cacheNode = bfcacheEntry.cacheNode
const stateKey = bfcacheEntry.stateKey
const segment = tree[0]

/*
- Error boundary
- Only renders error boundary if error component is provided.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// In the browser, React keys include concrete params so navigation preserves
// or resets component state according to the segment's actual identity.
export { createRouterCacheKey as createSegmentKey } from './create-router-cache-key'
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { createRouterCacheKey } from './create-router-cache-key'
import { createSegmentKey } from './create-segment-key'
import { createSegmentKey as createBrowserSegmentKey } from './create-segment-key.browser'

describe('createSegmentKey on the server', () => {
it.each(['d', 'c', 'oc', 'di(.)', 'ci(.)'] as const)(
'keeps %s keys stable between prerendering and resuming',
(paramType) => {
const fallbackKey = createSegmentKey([
'slug',
'%%drp:slug:abc123%%',
paramType,
null,
])

expect(fallbackKey).toBe(`slug|${paramType}`)
expect(createSegmentKey(['slug', 'first', paramType, null])).toBe(
fallbackKey
)
expect(createSegmentKey(['slug', 'second/third', paramType, null])).toBe(
fallbackKey
)
}
)

it('distinguishes param names and segment types', () => {
const keys = [
createSegmentKey(['slug', 'value', 'd', null]),
createSegmentKey(['other', 'value', 'd', null]),
createSegmentKey(['slug', 'value', 'c', null]),
createSegmentKey(['slug', 'value', 'oc', null]),
createSegmentKey(['slug', 'value', 'di(.)', null]),
createSegmentKey(['slug', 'value', 'ci(.)', null]),
]

expect(new Set(keys).size).toBe(keys.length)
})

it.each(['', 'catalog', '__DEFAULT__', '__PAGE__'])(
'preserves the static segment %j',
(segment) => {
expect(createSegmentKey(segment)).toBe(segment)
}
)

it.each([undefined, false, true])(
'always omits search params, even with withoutSearchParameters=%s',
(withoutSearchParameters) => {
expect(
createSegmentKey('__PAGE__?{"q":"first"}', withoutSearchParameters)
).toBe('__PAGE__')
expect(
createSegmentKey('__PAGE__?{"q":"second"}', withoutSearchParameters)
).toBe('__PAGE__')
}
)
})

describe('createSegmentKey in the browser', () => {
it('re-exports the router cache key implementation', () => {
expect(createBrowserSegmentKey).toBe(createRouterCacheKey)
})

it.each(['d', 'c', 'oc', 'di(.)', 'ci(.)'] as const)(
'includes concrete param values in %s keys',
(paramType) => {
expect(createBrowserSegmentKey(['slug', 'first', paramType, null])).toBe(
`slug|first|${paramType}`
)
expect(
createBrowserSegmentKey(['slug', 'second/third', paramType, null])
).toBe(`slug|second/third|${paramType}`)
}
)

it.each([undefined, false, true])(
'respects withoutSearchParameters=%s',
(withoutSearchParameters) => {
const segment = '__PAGE__?{"q":"first"}'
expect(createBrowserSegmentKey(segment, withoutSearchParameters)).toBe(
withoutSearchParameters ? '__PAGE__' : segment
)
}
)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Segment } from '../../../shared/lib/app-router-types'
import { createRouterCacheKey } from './create-router-cache-key'

// React uses these keys to find suspended subtrees when resuming HTML. They
// must be the same before and after route params and search params are known.
// The browser uses the concrete keys instead, so navigation still resets or
// preserves state as appropriate. Its bundle uses create-segment-key.browser.ts.
// React keys are not embedded in the HTML.
export function createSegmentKey(
segment: Segment,
// Server keys always omit search params, regardless of this browser option.
_withoutSearchParameters?: boolean
): string {
if (Array.isArray(segment)) {
return `${segment[0]}|${segment[2]}`
}

return createRouterCacheKey(segment, true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,24 @@ import type {
CacheNode,
} from '../../../../shared/lib/app-router-types'
import { DEFAULT_SEGMENT_KEY } from '../../../../shared/lib/segment'
import { createRouterCacheKey } from '../create-router-cache-key'
import { createSegmentKey } from '../create-segment-key'

export function findHeadInCache(
cache: CacheNode,
parallelRoutes: FlightRouterState[1]
): [CacheNode, string, string] | null {
return findHeadInCacheImpl(cache, parallelRoutes, '', '')
): [CacheNode, string] | null {
return findHeadInCacheImpl(cache, parallelRoutes, '')
}

function findHeadInCacheImpl(
cache: CacheNode,
parallelRoutes: FlightRouterState[1],
keyPrefix: string,
keyPrefixWithoutSearchParams: string
): [CacheNode, string, string] | null {
keyPrefix: string
): [CacheNode, string] | null {
const isLastItem = Object.keys(parallelRoutes).length === 0
if (isLastItem) {
// Returns the entire Cache Node of the segment whose head we will render.
return [cache, keyPrefix, keyPrefixWithoutSearchParams]
return [cache, keyPrefix]
}

// First try the 'children' parallel route if it exists
Expand Down Expand Up @@ -50,14 +49,14 @@ function findHeadInCacheImpl(
continue
}

const cacheKey = createRouterCacheKey(segment)
const cacheKeyWithoutSearchParams = createRouterCacheKey(segment, true)
// This key identifies the Head component, not a cache entry. On the
// server it must match even when resuming with previously unknown params.
const segmentKey = createSegmentKey(segment)

const item = findHeadInCacheImpl(
childCacheNode,
childParallelRoutes,
keyPrefix + '/' + cacheKey,
keyPrefix + '/' + cacheKeyWithoutSearchParams
keyPrefix + '/' + segmentKey
)

if (item) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33477,11 +33477,11 @@
};
(function () {
var isomorphicReactPackageVersion = React.version;
if ("19.3.0-experimental-ff8f88fc-20260915" !== isomorphicReactPackageVersion)
if ("19.3.0-experimental-59aff3e1-20260918" !== isomorphicReactPackageVersion)
throw Error(
'Incompatible React versions: The "react" and "react-dom" packages must have the exact same version. Instead got:\n - react: ' +
(isomorphicReactPackageVersion +
"\n - react-dom: 19.3.0-experimental-ff8f88fc-20260915\nLearn more: https://react.dev/warnings/version-mismatch")
"\n - react-dom: 19.3.0-experimental-59aff3e1-20260918\nLearn more: https://react.dev/warnings/version-mismatch")
);
})();
("function" === typeof Map &&
Expand Down Expand Up @@ -33518,10 +33518,10 @@
!(function () {
var internals = {
bundleType: 1,
version: "19.3.0-experimental-ff8f88fc-20260915",
version: "19.3.0-experimental-59aff3e1-20260918",
rendererPackageName: "react-dom",
currentDispatcherRef: ReactSharedInternals,
reconcilerVersion: "19.3.0-experimental-ff8f88fc-20260915"
reconcilerVersion: "19.3.0-experimental-59aff3e1-20260918"
};
internals.overrideHookState = overrideHookState;
internals.overrideHookStateDeletePath = overrideHookStateDeletePath;
Expand Down Expand Up @@ -33669,7 +33669,7 @@
listenToAllSupportedEvents(container);
return new ReactDOMHydrationRoot(initialChildren);
};
exports.version = "19.3.0-experimental-ff8f88fc-20260915";
exports.version = "19.3.0-experimental-59aff3e1-20260918";
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ &&
"function" ===
typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20472,14 +20472,14 @@ ReactDOMHydrationRoot.prototype.unstable_scheduleHydration = function (target) {
};
var isomorphicReactPackageVersion$jscomp$inline_2240 = React.version;
if (
"19.3.0-experimental-ff8f88fc-20260915" !==
"19.3.0-experimental-59aff3e1-20260918" !==
isomorphicReactPackageVersion$jscomp$inline_2240
)
throw Error(
formatProdErrorMessage(
527,
isomorphicReactPackageVersion$jscomp$inline_2240,
"19.3.0-experimental-ff8f88fc-20260915"
"19.3.0-experimental-59aff3e1-20260918"
)
);
ReactDOMSharedInternals.findDOMNode = function (componentOrElement) {
Expand All @@ -20501,10 +20501,10 @@ ReactDOMSharedInternals.findDOMNode = function (componentOrElement) {
};
var internals$jscomp$inline_2896 = {
bundleType: 0,
version: "19.3.0-experimental-ff8f88fc-20260915",
version: "19.3.0-experimental-59aff3e1-20260918",
rendererPackageName: "react-dom",
currentDispatcherRef: ReactSharedInternals,
reconcilerVersion: "19.3.0-experimental-ff8f88fc-20260915"
reconcilerVersion: "19.3.0-experimental-59aff3e1-20260918"
};
if ("undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__) {
var hook$jscomp$inline_2897 = __REACT_DEVTOOLS_GLOBAL_HOOK__;
Expand Down Expand Up @@ -20611,4 +20611,4 @@ exports.hydrateRoot = function (container, initialChildren, options) {
listenToAllSupportedEvents(container);
return new ReactDOMHydrationRoot(initialChildren);
};
exports.version = "19.3.0-experimental-ff8f88fc-20260915";
exports.version = "19.3.0-experimental-59aff3e1-20260918";
Loading
Loading