diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 9cc7e580c..e9c6a80ef 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -16,6 +16,7 @@ import { commitClientNavigationState, consumePrefetchResponse, createClientNavigationRenderSnapshot, + getBfcacheIdMapContext, getCurrentNextUrl, getCurrentInterceptionContext, getClientNavigationRenderContext, @@ -62,6 +63,8 @@ import { } from "./app-elements.js"; import { createHistoryStateWithPreviousNextUrl, + createInitialBfcacheIdMap, + readHistoryStateBfcacheIds, readHistoryStatePreviousNextUrl, resolveInterceptionContextFromPreviousNextUrl, resolveServerActionRequestState, @@ -136,6 +139,7 @@ const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000; const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000; const browserNavigationController = createAppBrowserNavigationController(); const NavigationCommitSignal = browserNavigationController.NavigationCommitSignal; +const BfcacheIdMapContext = getBfcacheIdMapContext(); // Parses a URI-encoded JSON value carried in a response header (e.g. // `X-Vinext-Params`). Returns `null` on missing or malformed input so callers @@ -217,13 +221,14 @@ function clearClientNavigationCaches(): void { } function createNavigationCommitEffect(options: { + bfcacheIds: Readonly>; href: string; historyUpdateMode: HistoryUpdateMode | undefined; navId: number; params: Record; previousNextUrl: string | null; }): () => void { - const { href, historyUpdateMode, navId, params, previousNextUrl } = options; + const { bfcacheIds, href, historyUpdateMode, navId, params, previousNextUrl } = options; return () => { // Only update URL if this is still the active navigation. @@ -241,12 +246,17 @@ function createNavigationCommitEffect(options: { const historyState = createHistoryStateWithPreviousNextUrl( preserveExistingState ? window.history.state : null, previousNextUrl, + bfcacheIds, ); if (historyUpdateMode === "replace" && window.location.href !== targetHref) { replaceHistoryStateWithoutNotify(historyState, "", href); } else if (historyUpdateMode === "push" && window.location.href !== targetHref) { pushHistoryStateWithoutNotify(historyState, "", href); + } else if (historyUpdateMode === undefined) { + // Traversal and refresh commits don't change the URL, but still persist + // the latest bfcache id map so future history entries can restore it. + replaceHistoryStateWithoutNotify(historyState, "", window.location.href); } // URL has been updated; the recovery hard-nav target is no longer needed. @@ -266,6 +276,7 @@ async function renderNavigationPayload( pendingRouterState: PendingBrowserRouterState | null, actionType: "navigate" | "replace" | "traverse" = "navigate", operationLane: OperationLane = "navigation", + restoredBfcacheIds?: Readonly> | null, ): Promise { try { return await browserNavigationController.renderNavigationPayload({ @@ -281,6 +292,7 @@ async function renderNavigationPayload( params, pendingRouterState, previousNextUrl, + restoredBfcacheIds, targetHref, navId, }); @@ -461,6 +473,7 @@ function BrowserRoot({ const initialMetadata = AppElementsWire.readMetadata(resolvedElements); const [treeStateValue, setTreeStateValue] = useState>({ activeOperation: null, + bfcacheIds: createInitialBfcacheIdMap(resolvedElements), elements: resolvedElements, interceptionContext: initialMetadata.interceptionContext, layoutIds: initialMetadata.layoutIds, @@ -510,13 +523,17 @@ function BrowserRoot({ } replaceHistoryStateWithoutNotify( - createHistoryStateWithPreviousNextUrl(window.history.state, treeState.previousNextUrl), + createHistoryStateWithPreviousNextUrl( + window.history.state, + treeState.previousNextUrl, + treeState.bfcacheIds, + ), "", window.location.href, ); - }, [treeState.previousNextUrl, treeState.renderId]); + }, [treeState.bfcacheIds, treeState.previousNextUrl, treeState.renderId]); - const innerTree = createElement( + const routeTree = createElement( RedirectBoundary, null, createElement( @@ -530,6 +547,10 @@ function BrowserRoot({ ), ); + const innerTree = BfcacheIdMapContext + ? createElement(BfcacheIdMapContext.Provider, { value: treeState.bfcacheIds }, routeTree) + : routeTree; + // In dev, wrap the route tree in a top-level recovery boundary. A render // error (e.g. a slot's RSC reference rejects) is caught here instead of // tearing down BrowserRoot, so HMR can dispatch the next payload — @@ -913,7 +934,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { latestClientParams, ); replaceHistoryStateWithoutNotify( - createHistoryStateWithPreviousNextUrl(window.history.state, null), + createHistoryStateWithPreviousNextUrl(window.history.state, null, null), "", window.location.href, ); @@ -999,6 +1020,8 @@ function bootstrapHydration(rscStream: ReadableStream): void { const requestState = getRequestState(navigationKind, currentPrevNextUrl); const requestInterceptionContext = requestState.interceptionContext; const requestPreviousNextUrl = requestState.previousNextUrl; + const restoredBfcacheIds = + navigationKind === "traverse" ? readHistoryStateBfcacheIds(window.history.state) : null; // Set this navigation as the pending pathname, overwriting any previous. // Pass navId so only this navigation (or a newer one) can clear it later. @@ -1056,6 +1079,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { pendingRouterState, toActionType(navigationKind), toOperationLane(navigationKind), + restoredBfcacheIds, ); return; } @@ -1210,6 +1234,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { pendingRouterState, toActionType(navigationKind), toOperationLane(navigationKind), + restoredBfcacheIds, ); if (renderOutcome !== "committed") return; // Don't cache the response if this navigation was superseded during diff --git a/packages/vinext/src/server/app-browser-navigation-controller.ts b/packages/vinext/src/server/app-browser-navigation-controller.ts index 1bd7d9f51..f3199df82 100644 --- a/packages/vinext/src/server/app-browser-navigation-controller.ts +++ b/packages/vinext/src/server/app-browser-navigation-controller.ts @@ -30,6 +30,7 @@ export type NavigationPayloadOutcome = "committed" | "no-commit" | "hard-navigat type HardNavigationMode = "assign" | "replace"; type BrowserNavigationCommitEffectFactory = (options: { + bfcacheIds: Readonly>; href: string; historyUpdateMode: HistoryUpdateMode | undefined; navId: number; @@ -68,6 +69,7 @@ type BrowserNavigationController = { params: Record; pendingRouterState: PendingBrowserRouterState | null; previousNextUrl: string | null; + restoredBfcacheIds?: Readonly> | null; targetHref: string; navId: number; }): Promise; @@ -462,6 +464,7 @@ export function createAppBrowserNavigationController( params: Record; pendingRouterState: PendingBrowserRouterState | null; previousNextUrl: string | null; + restoredBfcacheIds?: Readonly> | null; targetHref: string; navId: number; }): Promise { @@ -482,6 +485,7 @@ export function createAppBrowserNavigationController( operationLane: options.operationLane, previousNextUrl: options.previousNextUrl, renderId, + restoredBfcacheIds: options.restoredBfcacheIds, type: options.actionType, }); @@ -515,6 +519,7 @@ export function createAppBrowserNavigationController( renderId, options.createNavigationCommitEffect({ href: options.targetHref, + bfcacheIds: approvedCommit.action.bfcacheIds, historyUpdateMode: options.historyUpdateMode, navId: options.navId, params: options.params, diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index 02523ec2d..120582d9a 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -30,6 +30,7 @@ import { import type { ClientNavigationRenderSnapshot } from "vinext/shims/navigation"; const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; +const VINEXT_BFCACHE_IDS_HISTORY_STATE_KEY = "__vinext_bfcacheIds"; type HistoryStateRecord = { [key: string]: unknown; @@ -37,6 +38,8 @@ type HistoryStateRecord = { export type { OperationLane } from "./navigation-planner.js"; +export type BfcacheIdMap = Readonly>; + type OperationRecordBase = { id: number; lane: OperationLane; @@ -56,6 +59,7 @@ export type OperationRecord = PendingOperationRecord | CommittedOperationRecord; export type AppRouterState = { activeOperation: OperationRecord | null; + bfcacheIds: BfcacheIdMap; elements: AppElements; interceptionContext: string | null; layoutFlags: LayoutFlags; @@ -69,6 +73,7 @@ export type AppRouterState = { }; export type AppRouterAction = { + bfcacheIds: BfcacheIdMap; elements: AppElements; interceptionContext: string | null; layoutFlags: LayoutFlags; @@ -106,6 +111,9 @@ type PendingNavigationCommitDispositionDecision = | DispatchPendingNavigationCommitDispositionDecision | NonDispatchPendingNavigationCommitDispositionDecision; +let nextBfcacheId = 0; +const INITIAL_BFCACHE_ID = "0"; + function cloneHistoryState(state: unknown): HistoryStateRecord { if (!state || typeof state !== "object") { return {}; @@ -121,6 +129,7 @@ function cloneHistoryState(state: unknown): HistoryStateRecord { export function createHistoryStateWithPreviousNextUrl( state: unknown, previousNextUrl: string | null, + bfcacheIds?: BfcacheIdMap | null, ): HistoryStateRecord | null { const nextState = cloneHistoryState(state); @@ -130,6 +139,14 @@ export function createHistoryStateWithPreviousNextUrl( nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY] = previousNextUrl; } + if (bfcacheIds !== undefined) { + if (bfcacheIds === null || Object.keys(bfcacheIds).length === 0) { + delete nextState[VINEXT_BFCACHE_IDS_HISTORY_STATE_KEY]; + } else { + nextState[VINEXT_BFCACHE_IDS_HISTORY_STATE_KEY] = { ...bfcacheIds }; + } + } + return Object.keys(nextState).length > 0 ? nextState : null; } @@ -138,6 +155,126 @@ export function readHistoryStatePreviousNextUrl(state: unknown): string | null { return typeof value === "string" ? value : null; } +function rememberBfcacheId(value: string): void { + const match = /^_b_(\d+)_$/.exec(value); + if (!match) return; + nextBfcacheId = Math.max(nextBfcacheId, Number(match[1])); +} + +function mintBfcacheId(): string { + nextBfcacheId += 1; + return `_b_${nextBfcacheId}_`; +} + +function isBfcacheSegmentId(id: string): boolean { + const parsed = AppElementsWire.parseElementKey(id); + return ( + parsed?.kind === "layout" || + parsed?.kind === "page" || + parsed?.kind === "slot" || + parsed?.kind === "template" + ); +} + +function getPathSegments(pathname: string): string[] { + return pathname.split("/").filter(Boolean); +} + +function getVisibleTreePathSegments(treePath: string): string[] { + return treePath + .split("/") + .filter(Boolean) + .filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))); +} + +function getPathPrefix(pathname: string, segmentCount: number): string { + if (segmentCount === 0) return "/"; + const segments = getPathSegments(pathname).slice(0, segmentCount); + return `/${segments.join("/")}`; +} + +function createBfcacheSegmentIdentity(id: string, pathname: string): string | null { + const parsed = AppElementsWire.parseElementKey(id); + if (!parsed) return null; + + if (parsed.kind === "page") { + return `${id}@${pathname}`; + } + + if (parsed.kind === "layout" || parsed.kind === "slot" || parsed.kind === "template") { + const segmentCount = getVisibleTreePathSegments(parsed.treePath).length; + return `${id}@${getPathPrefix(pathname, segmentCount)}`; + } + + return null; +} + +function collectBfcacheSegmentIds(elements: AppElements): string[] { + const ids = new Set(Object.keys(elements)); + try { + for (const layoutId of AppElementsWire.readMetadata(elements).layoutIds) { + ids.add(layoutId); + } + } catch { + // Some low-level tests pass partial element maps without metadata. + } + + return Array.from(ids).filter(isBfcacheSegmentId); +} + +export function createInitialBfcacheIdMap(elements: AppElements): BfcacheIdMap { + const ids: Record = {}; + for (const id of collectBfcacheSegmentIds(elements)) { + ids[id] = INITIAL_BFCACHE_ID; + } + return ids; +} + +export function readHistoryStateBfcacheIds(state: unknown): BfcacheIdMap | null { + const value = cloneHistoryState(state)[VINEXT_BFCACHE_IDS_HISTORY_STATE_KEY]; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const ids: Record = {}; + for (const [key, id] of Object.entries(value)) { + if (!isBfcacheSegmentId(key) || typeof id !== "string") { + return null; + } + ids[key] = id; + rememberBfcacheId(id); + } + return ids; +} + +export function createNextBfcacheIdMap(options: { + current: BfcacheIdMap; + currentPathname: string; + elements: AppElements; + nextPathname: string; + restored?: BfcacheIdMap | null; +}): BfcacheIdMap { + for (const value of Object.values(options.current)) { + rememberBfcacheId(value); + } + for (const value of Object.values(options.restored ?? {})) { + rememberBfcacheId(value); + } + + const ids: Record = {}; + for (const id of collectBfcacheSegmentIds(options.elements)) { + const currentIdentity = createBfcacheSegmentIdentity(id, options.currentPathname); + const nextIdentity = createBfcacheSegmentIdentity(id, options.nextPathname); + const currentValue = currentIdentity === nextIdentity ? options.current[id] : undefined; + // History traversals restore persisted ids first, matching segments keep + // their current id, and newly-created segments mint a fresh opaque id. + const value = options.restored?.[id] ?? currentValue ?? mintBfcacheId(); + ids[id] = value; + rememberBfcacheId(value); + } + return ids; +} + function createOperationRecord(options: { id: number; lane: OperationLane; @@ -398,6 +535,7 @@ export async function createPendingNavigationCommit(options: { operationLane: OperationLane; previousNextUrl?: string | null; renderId: number; + restoredBfcacheIds?: BfcacheIdMap | null; type: "navigate" | "replace" | "traverse"; }): Promise { const elements = await options.nextElements; @@ -409,6 +547,13 @@ export async function createPendingNavigationCommit(options: { return { action: { + bfcacheIds: createNextBfcacheIdMap({ + current: options.currentState.bfcacheIds, + currentPathname: options.currentState.navigationSnapshot.pathname, + elements, + nextPathname: options.navigationSnapshot.pathname, + restored: options.restoredBfcacheIds, + }), elements, interceptionContext: metadata.interceptionContext, layoutIds: metadata.layoutIds, diff --git a/packages/vinext/src/server/app-browser-visible-commit.ts b/packages/vinext/src/server/app-browser-visible-commit.ts index 7e5e32461..130b931f0 100644 --- a/packages/vinext/src/server/app-browser-visible-commit.ts +++ b/packages/vinext/src/server/app-browser-visible-commit.ts @@ -115,6 +115,7 @@ function reduceApprovedVisibleCommitState( return commitVisibleRouterState( state, { + bfcacheIds: action.bfcacheIds, elements: mergeElements(state.elements, action.elements, { clearAbsentSlots: action.type === "traverse", preserveAbsentSlots: commit.decision.preserveAbsentSlots, @@ -139,6 +140,7 @@ function reduceApprovedVisibleCommitState( return commitVisibleRouterState( state, { + bfcacheIds: action.bfcacheIds, elements: action.elements, interceptionContext: action.interceptionContext, layoutFlags: action.layoutFlags, diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index 36b5bec80..b877bc920 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -10,6 +10,7 @@ import type { NavigationContext } from "vinext/shims/navigation"; import { ServerInsertedHTMLContext, clearServerInsertedHTML, + getBfcacheIdMapContext, renderServerInsertedHTML, setNavigationContext, useServerInsertedHTML, @@ -30,6 +31,9 @@ import { AppElementsWire, type AppWireElements } from "./app-elements.js"; import { ElementsContext, Slot } from "vinext/shims/slot"; import { createClientReferencePreloader } from "./app-client-reference-preloader.js"; import { RSC_FORM_STATE_GLOBAL } from "./app-browser-hydration.js"; +import { createInitialBfcacheIdMap } from "./app-browser-state.js"; + +const BfcacheIdMapContext = getBfcacheIdMapContext(); export type FontPreload = { href: string; @@ -215,11 +219,18 @@ export async function handleSsr( const wireElements = use(flightRoot); const elements = AppElementsWire.decode(wireElements); const metadata = AppElementsWire.readMetadata(elements); - return createReactElement( + const routeTree = createReactElement( ElementsContext.Provider, { value: elements }, createReactElement(Slot, { id: metadata.routeId }), ); + return BfcacheIdMapContext + ? createReactElement( + BfcacheIdMapContext.Provider, + { value: createInitialBfcacheIdMap(elements) }, + routeTree, + ) + : routeTree; } const root = createReactElement(VinextFlightRoot); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 54f06b558..7b3724aaa 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -34,6 +34,12 @@ import { assertSafeNavigationUrl } from "./url-safety.js"; // still line up if Vite loads this shim through multiple resolved module IDs. const _LAYOUT_SEGMENT_CTX_KEY = Symbol.for("vinext.layoutSegmentContext"); const _SERVER_INSERTED_HTML_CTX_KEY = Symbol.for("vinext.serverInsertedHTMLContext"); +const _BFCACHE_ID_MAP_CTX_KEY = Symbol.for("vinext.bfcacheIdMapContext"); +const _BFCACHE_SEGMENT_ID_CTX_KEY = Symbol.for("vinext.bfcacheSegmentIdContext"); +const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; +const VINEXT_BFCACHE_IDS_HISTORY_STATE_KEY = "__vinext_bfcacheIds"; +const VINEXT_SCROLL_X_HISTORY_STATE_KEY = "__vinext_scrollX"; +const VINEXT_SCROLL_Y_HISTORY_STATE_KEY = "__vinext_scrollY"; /** * Map of parallel route key → child segments below the current layout. @@ -46,6 +52,8 @@ const _SERVER_INSERTED_HTML_CTX_KEY = Symbol.for("vinext.serverInsertedHTMLConte export type SegmentMap = Readonly> & { readonly children: string[] }; type _LayoutSegmentGlobal = typeof globalThis & { + [_BFCACHE_ID_MAP_CTX_KEY]?: React.Context> | null> | null; + [_BFCACHE_SEGMENT_ID_CTX_KEY]?: React.Context | null; [_LAYOUT_SEGMENT_CTX_KEY]?: React.Context | null; [_SERVER_INSERTED_HTML_CTX_KEY]?: React.Context< ((callback: () => unknown) => void) | null @@ -85,6 +93,32 @@ export const ServerInsertedHTMLContext: React.Context< ((callback: () => unknown) => void) | null > | null = getServerInsertedHTMLContext(); +export function getBfcacheIdMapContext(): React.Context +> | null> | null { + if (typeof React.createContext !== "function") return null; + + const globalState = globalThis as _LayoutSegmentGlobal; + if (!globalState[_BFCACHE_ID_MAP_CTX_KEY]) { + globalState[_BFCACHE_ID_MAP_CTX_KEY] = React.createContext + > | null>(null); + } + + return globalState[_BFCACHE_ID_MAP_CTX_KEY] ?? null; +} + +export function getBfcacheSegmentIdContext(): React.Context | null { + if (typeof React.createContext !== "function") return null; + + const globalState = globalThis as _LayoutSegmentGlobal; + if (!globalState[_BFCACHE_SEGMENT_ID_CTX_KEY]) { + globalState[_BFCACHE_SEGMENT_ID_CTX_KEY] = React.createContext(null); + } + + return globalState[_BFCACHE_SEGMENT_ID_CTX_KEY] ?? null; +} + /** * Get or create the layout segment context. * Returns null in the RSC environment (createContext unavailable). @@ -977,10 +1011,24 @@ function scrollToHash(hash: string): void { window.scrollTo(0, 0); return; } - const id = hash.slice(1); - const element = document.getElementById(id); + const encodedId = hash.startsWith("#") ? hash.slice(1) : hash; + let id = encodedId; + try { + id = decodeURIComponent(encodedId); + } catch { + // Match browser resilience for malformed hashes: fall back to the raw fragment. + } + + if (id === "top") { + window.scrollTo(0, 0); + return; + } + + const element = document.getElementById(id) ?? document.getElementsByName(id)[0]; if (element) { element.scrollIntoView({ behavior: "auto" }); + } else { + window.scrollTo(0, 0); } } @@ -1085,11 +1133,30 @@ export function replaceHistoryStateWithoutNotify( function saveScrollPosition(): void { const state = window.history.state ?? {}; replaceHistoryStateWithoutNotify( - { ...state, __vinext_scrollX: window.scrollX, __vinext_scrollY: window.scrollY }, + { + ...state, + [VINEXT_SCROLL_X_HISTORY_STATE_KEY]: window.scrollX, + [VINEXT_SCROLL_Y_HISTORY_STATE_KEY]: window.scrollY, + }, "", ); } +function createHashOnlyHistoryState(state: unknown): unknown { + if (!state || typeof state !== "object") return null; + + const current = state as Record; + const next: Record = {}; + if (VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY in current) { + next[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY] = + current[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; + } + if (VINEXT_BFCACHE_IDS_HISTORY_STATE_KEY in current) { + next[VINEXT_BFCACHE_IDS_HISTORY_STATE_KEY] = current[VINEXT_BFCACHE_IDS_HISTORY_STATE_KEY]; + } + return Object.keys(next).length > 0 ? next : null; +} + /** * Restore scroll position from a history state object (used on popstate). * @@ -1173,10 +1240,11 @@ export async function navigateClientSide( // Hash-only change: update URL and scroll to target, skip RSC fetch if (isHashOnlyChange(fullHref)) { const hash = fullHref.includes("#") ? fullHref.slice(fullHref.indexOf("#")) : ""; + const historyState = createHashOnlyHistoryState(window.history.state); if (mode === "replace") { - replaceHistoryStateWithoutNotify(null, "", fullHref); + replaceHistoryStateWithoutNotify(historyState, "", fullHref); } else { - pushHistoryStateWithoutNotify(null, "", fullHref); + pushHistoryStateWithoutNotify(historyState, "", fullHref); } commitClientNavigationState(); if (scroll) { @@ -1225,13 +1293,11 @@ export async function navigateClientSide( } // --------------------------------------------------------------------------- -// App Router router singleton +// App Router router method singleton. // -// All methods close over module-level state (navigateClientSide, withBasePath, etc.) -// and carry no per-render data, so the object can be created once and reused. -// Next.js returns the same router reference on every call to useRouter(), which -// matters for components that rely on referential equality (e.g. useMemo / -// useEffect dependency arrays, React.memo bailouts). +// Methods close over module-level state (navigateClientSide, withBasePath, +// etc.) and carry no per-render data, so the method surface can be reused. +// useRouter() may wrap this object to attach the current segment's bfcacheId. // --------------------------------------------------------------------------- const _appRouter = { @@ -1324,16 +1390,58 @@ const _appRouter = { }, }; +const _appRouterByBfcacheId = new Map(); +const MAX_APP_ROUTER_BFCACHE_ID_CACHE_SIZE = 64; + +function createAppRouterForBfcacheId(bfcacheId: string): typeof _appRouter { + if (bfcacheId === _appRouter.bfcacheId) return _appRouter; + + const cached = _appRouterByBfcacheId.get(bfcacheId); + if (cached) { + _appRouterByBfcacheId.delete(bfcacheId); + _appRouterByBfcacheId.set(bfcacheId, cached); + return cached; + } + + const router = { ..._appRouter, bfcacheId }; + _appRouterByBfcacheId.set(bfcacheId, router); + if (_appRouterByBfcacheId.size > MAX_APP_ROUTER_BFCACHE_ID_CACHE_SIZE) { + const oldest = _appRouterByBfcacheId.keys().next().value; + if (oldest !== undefined) { + _appRouterByBfcacheId.delete(oldest); + } + } + return router; +} + +/* oxlint-disable eslint-plugin-react-hooks/rules-of-hooks */ +function readBfcacheIdFromContext(): string { + const segmentContext = getBfcacheSegmentIdContext(); + const idMapContext = getBfcacheIdMapContext(); + if (!segmentContext || !idMapContext) return "0"; + + try { + const segmentId = React.useContext(segmentContext); + const idMap = React.useContext(idMapContext); + if (!segmentId) return "0"; + return idMap?.[segmentId] ?? "0"; + } catch { + // Low-level tests and direct module calls can hit this outside render. + return "0"; + } +} +/* oxlint-enable eslint-plugin-react-hooks/rules-of-hooks */ + /** * App Router's useRouter — returns push/replace/back/forward/refresh. * Different from Pages Router's useRouter (next/router). * - * Returns a stable singleton: the same object reference on every call, - * matching Next.js behavior so components using referential equality - * (e.g. useMemo / useEffect deps, React.memo) don't re-render unnecessarily. + * bfcacheId is contextual: layouts read their layout segment id and pages read + * their page segment id. Outside the App Router tree it falls back to "0". */ export function useRouter() { - return _appRouter; + const bfcacheId = readBfcacheIdFromContext(); + return createAppRouterForBfcacheId(bfcacheId); } /** diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx index a138fe47b..c836b5b25 100644 --- a/packages/vinext/src/shims/slot.tsx +++ b/packages/vinext/src/shims/slot.tsx @@ -7,7 +7,7 @@ import { type AppElementValue, type AppElements, } from "../server/app-elements.js"; -import { notFound } from "./navigation.js"; +import { getBfcacheSegmentIdContext, notFound } from "./navigation.js"; const EMPTY_ELEMENTS: AppElements = Object.freeze({}); const warnedMissingEntryIds = new Set(); @@ -26,6 +26,7 @@ export const ChildrenContext = React.createContext(null); export const ParallelSlotsContext = React.createContext > | null>(null); +const BfcacheSegmentIdContext = getBfcacheSegmentIdContext(); type MergeElementsOptions = { clearAbsentSlots?: boolean; @@ -112,11 +113,17 @@ export function Slot({ notFound(); } - return ( + const content = ( {element} ); + + return BfcacheSegmentIdContext ? ( + {content} + ) : ( + content + ); } export function Children() { diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 3142ca447..a830fe0d1 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -29,7 +29,10 @@ import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shi import * as navigationShim from "../packages/vinext/src/shims/navigation.js"; import { createHistoryStateWithPreviousNextUrl, + createInitialBfcacheIdMap, + createNextBfcacheIdMap, createPendingNavigationCommit, + readHistoryStateBfcacheIds, readHistoryStatePreviousNextUrl, resolveInterceptionContextFromPreviousNextUrl, resolveServerActionRequestState, @@ -70,6 +73,7 @@ function createResolvedElements( function createState(overrides: Partial = {}): AppRouterState { return { + bfcacheIds: {}, elements: createResolvedElements("route:/initial", "/"), layoutIds: [AppElementsWire.encodeLayoutId("/")], layoutFlags: {}, @@ -2578,6 +2582,147 @@ describe("app browser entry previousNextUrl helpers", () => { }); }); +describe("app browser entry bfcacheId helpers", () => { + const rootLayoutId = AppElementsWire.encodeLayoutId("/"); + const groupLayoutId = AppElementsWire.encodeLayoutId("/[group]"); + const nestedGroupLayoutId = AppElementsWire.encodeLayoutId( + "/nextjs-compat/use-router-bfcache-id/[group]", + ); + const pageX1Id = AppElementsWire.encodePageId("/x/1", null); + const pageX2Id = AppElementsWire.encodePageId("/x/2", null); + const pageY1Id = AppElementsWire.encodePageId("/y/1", null); + + function createBfcacheElements(pageId: string): AppElements { + return createResolvedElements( + `route:${pageId.slice("page:".length)}`, + "/", + null, + { + [rootLayoutId]: React.createElement("div", null), + [groupLayoutId]: React.createElement("div", null), + [pageId]: React.createElement("main", null), + }, + [rootLayoutId, groupLayoutId], + ); + } + + it("initializes every visible segment with the hydration placeholder", () => { + expect(createInitialBfcacheIdMap(createBfcacheElements(pageX1Id))).toEqual({ + [rootLayoutId]: "0", + [groupLayoutId]: "0", + [pageX1Id]: "0", + }); + }); + + it("preserves shared segment ids and mints ids for fresh segments", () => { + const current = { + [rootLayoutId]: "0", + [groupLayoutId]: "_b_4_", + [pageX1Id]: "_b_5_", + }; + + const next = createNextBfcacheIdMap({ + current, + currentPathname: "/x/1", + elements: createBfcacheElements(pageX2Id), + nextPathname: "/x/2", + }); + + expect(next[rootLayoutId]).toBe("0"); + expect(next[groupLayoutId]).toBe("_b_4_"); + expect(next[pageX1Id]).toBeUndefined(); + expect(next[pageX2Id]).toMatch(/^_b_\d+_$/); + expect(next[pageX2Id]).not.toBe("_b_5_"); + }); + + it("mints a fresh layout id when a dynamic layout segment changes", () => { + const current = { + [rootLayoutId]: "0", + [groupLayoutId]: "_b_4_", + [pageX1Id]: "_b_5_", + }; + + const next = createNextBfcacheIdMap({ + current, + currentPathname: "/x/1", + elements: createBfcacheElements(pageY1Id), + nextPathname: "/y/1", + }); + + expect(next[rootLayoutId]).toBe("0"); + expect(next[groupLayoutId]).toMatch(/^_b_\d+_$/); + expect(next[groupLayoutId]).not.toBe("_b_4_"); + }); + + it("mints a fresh nested layout id when a dynamic layout segment changes", () => { + const current = { + [rootLayoutId]: "0", + [nestedGroupLayoutId]: "0", + [pageX1Id]: "0", + }; + + const next = createNextBfcacheIdMap({ + current, + currentPathname: "/nextjs-compat/use-router-bfcache-id/x/1", + elements: createResolvedElements( + "route:/nextjs-compat/use-router-bfcache-id/y/1", + "/", + null, + { + [pageY1Id]: React.createElement("main", null), + }, + [rootLayoutId, nestedGroupLayoutId], + ), + nextPathname: "/nextjs-compat/use-router-bfcache-id/y/1", + }); + + expect(next[nestedGroupLayoutId]).toMatch(/^_b_\d+_$/); + expect(next[nestedGroupLayoutId]).not.toBe("0"); + }); + + it("serializes and restores bfcache ids through history state", () => { + const state = createHistoryStateWithPreviousNextUrl({ __vinext_scrollY: 120 }, "/feed", { + [pageX1Id]: "_b_9_", + }); + + expect(state).toEqual({ + __vinext_bfcacheIds: { [pageX1Id]: "_b_9_" }, + __vinext_previousNextUrl: "/feed", + __vinext_scrollY: 120, + }); + expect(readHistoryStateBfcacheIds(state)).toEqual({ [pageX1Id]: "_b_9_" }); + }); + + it("uses restored history bfcache ids for traversal commits", async () => { + const currentState = createState({ + bfcacheIds: { + [rootLayoutId]: "0", + [groupLayoutId]: "_b_4_", + [pageX2Id]: "_b_8_", + }, + elements: createBfcacheElements(pageX2Id), + layoutIds: [rootLayoutId, groupLayoutId], + routeId: "route:/x/2", + }); + + const pending = await createPendingNavigationCommit({ + currentState, + nextElements: Promise.resolve(createBfcacheElements(pageX1Id)), + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/x/1", {}), + operationLane: "traverse", + renderId: 1, + restoredBfcacheIds: { + [rootLayoutId]: "0", + [groupLayoutId]: "_b_4_", + [pageX1Id]: "_b_5_", + }, + type: "traverse", + }); + + expect(pending.action.bfcacheIds[pageX1Id]).toBe("_b_5_"); + }); +}); + describe("devOnCaughtError (hydrateRoot dev handler)", () => { it("ignores redirect sentinels handled by RedirectBoundary", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/tests/e2e/app-router/nextjs-compat/use-router-bfcache-id.spec.ts b/tests/e2e/app-router/nextjs-compat/use-router-bfcache-id.spec.ts new file mode 100644 index 000000000..08858ebaf --- /dev/null +++ b/tests/e2e/app-router/nextjs-compat/use-router-bfcache-id.spec.ts @@ -0,0 +1,119 @@ +/** + * Next.js Compat E2E: useRouter().bfcacheId + * Ported from: https://github.com/vercel/next.js/blob/56d95137fd6d84f4bc1e5ef2bb31e0136d5fad9c/test/e2e/app-dir/use-router-bfcache-id/use-router-bfcache-id.test.ts + */ + +import { test, expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { waitForAppRouterHydration } from "../../helpers"; + +const BASE = "http://localhost:4174"; +const ROUTE = "/nextjs-compat/use-router-bfcache-id"; + +async function revealAndClick(page: Page, href: string) { + await page.locator(`input[data-link-accordion="${href}"]`).first().check(); + await page.locator(`a[href="${href}"]`).first().click(); +} + +test.describe("Next.js compat: useRouter().bfcacheId", () => { + test("mints bfcacheIds for fresh leaf navigations and restores them on history traversal", async ({ + page, + }) => { + await page.goto(`${BASE}${ROUTE}/x/1`); + await waitForAppRouterHydration(page); + + await expect(page.getByTestId("leaf-bfcache-id")).toHaveText("0"); + + await revealAndClick(page, `${ROUTE}/x/2`); + await expect(page.getByTestId("pathname")).toHaveText(`${ROUTE}/x/2`); + const x2BfcacheId = await page.getByTestId("leaf-bfcache-id").textContent(); + expect(x2BfcacheId).toMatch(/^_b_\d+_$/); + + await revealAndClick(page, `${ROUTE}/x/1`); + await expect(page.getByTestId("pathname")).toHaveText(`${ROUTE}/x/1`); + const freshX1BfcacheId = await page.getByTestId("leaf-bfcache-id").textContent(); + expect(freshX1BfcacheId).toMatch(/^_b_\d+_$/); + expect(freshX1BfcacheId).not.toBe(x2BfcacheId); + + await page.goBack(); + await expect(page.getByTestId("pathname")).toHaveText(`${ROUTE}/x/2`); + await expect(page.getByTestId("leaf-bfcache-id")).toHaveText(x2BfcacheId ?? ""); + }); + + test("resets leaf form state when re-entering a route via fresh push", async ({ page }) => { + await page.goto(`${BASE}${ROUTE}/x/1`); + await waitForAppRouterHydration(page); + + await page.getByTestId("leaf-input").fill("hello"); + await revealAndClick(page, `${ROUTE}/x/2`); + await expect(page.getByTestId("pathname")).toHaveText(`${ROUTE}/x/2`); + + await revealAndClick(page, `${ROUTE}/x/1`); + await expect(page.getByTestId("leaf-input")).toHaveValue(""); + }); + + test("preserves shared layout state across sibling leaf navigations", async ({ page }) => { + await page.goto(`${BASE}${ROUTE}/x/1`); + await waitForAppRouterHydration(page); + + await page.getByTestId("layout-input").fill("layout"); + const xLayoutBfcacheId = await page.getByTestId("layout-bfcache-id").textContent(); + + await revealAndClick(page, `${ROUTE}/x/2`); + await expect(page.getByTestId("pathname")).toHaveText(`${ROUTE}/x/2`); + await expect(page.getByTestId("layout-input")).toHaveValue("layout"); + await expect(page.getByTestId("layout-bfcache-id")).toHaveText(xLayoutBfcacheId ?? ""); + }); + + test("resets shared layout state when navigating across dynamic groups", async ({ page }) => { + await page.goto(`${BASE}${ROUTE}/x/1`); + await waitForAppRouterHydration(page); + + await page.getByTestId("layout-input").fill("layout"); + const xLayoutBfcacheId = await page.getByTestId("layout-bfcache-id").textContent(); + + await revealAndClick(page, `${ROUTE}/y/1`); + await expect(page.getByTestId("pathname")).toHaveText(`${ROUTE}/y/1`); + await expect(page.getByTestId("layout-input")).toHaveValue(""); + const yLayoutBfcacheId = await page.getByTestId("layout-bfcache-id").textContent(); + expect(yLayoutBfcacheId).not.toBe(xLayoutBfcacheId); + }); + + test("preserves bfcacheId across hash/search-param navigation and refresh", async ({ page }) => { + await page.goto(`${BASE}${ROUTE}/x/1`); + await waitForAppRouterHydration(page); + + const initialBfcacheId = await page.getByTestId("leaf-bfcache-id").textContent(); + await revealAndClick(page, `${ROUTE}/x/1#section`); + await expect(page.getByTestId("leaf-bfcache-id")).toHaveText(initialBfcacheId ?? ""); + + await revealAndClick(page, `${ROUTE}/x/1?q=2`); + await expect(page.getByTestId("search")).toHaveAttribute("data-value", "q=2"); + await expect(page.getByTestId("leaf-bfcache-id")).toHaveText(initialBfcacheId ?? ""); + + await page.getByTestId("refresh").click(); + await expect(page.getByTestId("leaf-bfcache-id")).toHaveText(initialBfcacheId ?? ""); + }); + + test("mints bfcacheIds for programmatic push and replace", async ({ page }) => { + await page.goto(`${BASE}${ROUTE}/x/1`); + await waitForAppRouterHydration(page); + + const pushInitialBfcacheId = await page.getByTestId("leaf-bfcache-id").textContent(); + await page.getByTestId("router-push-x-2").click(); + await expect(page.getByTestId("pathname")).toHaveText(`${ROUTE}/x/2`); + const pushedBfcacheId = await page.getByTestId("leaf-bfcache-id").textContent(); + expect(pushedBfcacheId).toMatch(/^_b_\d+_$/); + expect(pushedBfcacheId).not.toBe(pushInitialBfcacheId); + + await page.goto(`${BASE}${ROUTE}/x/1`); + await waitForAppRouterHydration(page); + + const replaceInitialBfcacheId = await page.getByTestId("leaf-bfcache-id").textContent(); + await page.getByTestId("router-replace-x-2").click(); + await expect(page.getByTestId("pathname")).toHaveText(`${ROUTE}/x/2`); + const replacedBfcacheId = await page.getByTestId("leaf-bfcache-id").textContent(); + expect(replacedBfcacheId).toMatch(/^_b_\d+_$/); + expect(replacedBfcacheId).not.toBe(replaceInitialBfcacheId); + }); +}); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/[group]/[page]/leaf-content.tsx b/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/[group]/[page]/leaf-content.tsx new file mode 100644 index 000000000..f3f48b5ee --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/[group]/[page]/leaf-content.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { LinkAccordion } from "../../components/link-accordion"; + +const base = "/nextjs-compat/use-router-bfcache-id"; + +export function LeafContent() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const search = searchParams.toString(); + + return ( + <> +

{pathname}

+ + {search} + +

{router.bfcacheId}

+
+ +
+ same page (?q=2) + same page (#section) + + + + + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/[group]/[page]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/[group]/[page]/page.tsx new file mode 100644 index 000000000..5bdd1084d --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/[group]/[page]/page.tsx @@ -0,0 +1,9 @@ +import { LeafContent } from "./leaf-content"; + +export default function Page() { + return ( +
+ +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/[group]/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/[group]/layout.tsx new file mode 100644 index 000000000..3a46667b9 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/[group]/layout.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Suspense, type ReactNode } from "react"; +import { LinkAccordion } from "../components/link-accordion"; + +const base = "/nextjs-compat/use-router-bfcache-id"; + +export default function GroupLayout({ children }: { children: ReactNode }) { + const { bfcacheId } = useRouter(); + + return ( +
+ +

{bfcacheId}

+
+ +
+ {children} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/components/link-accordion.tsx b/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/components/link-accordion.tsx new file mode 100644 index 000000000..5560494b4 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/components/link-accordion.tsx @@ -0,0 +1,20 @@ +"use client"; + +import Link from "next/link"; +import { useState, type ReactNode } from "react"; + +export function LinkAccordion({ children, href }: { children: ReactNode; href: string }) { + const [isVisible, setIsVisible] = useState(false); + + return ( + <> + setIsVisible((value) => !value)} + type="checkbox" + /> + {isVisible ? {children} : `${children} (link is hidden)`} + + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/page.tsx new file mode 100644 index 000000000..ff92ac530 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/use-router-bfcache-id/page.tsx @@ -0,0 +1,22 @@ +import { LinkAccordion } from "./components/link-accordion"; + +const base = "/nextjs-compat/use-router-bfcache-id"; + +export default function Page() { + return ( +
+

useRouter bfcacheId

+
    +
  • + /x/1 +
  • +
  • + /x/2 +
  • +
  • + /y/1 +
  • +
+
+ ); +} diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 3b0fb0aa1..5c955979c 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -58,6 +58,35 @@ describe("next/navigation shim", () => { expect(second.bfcacheId).toBe(first.bfcacheId); }); + it("useRouter() reads bfcacheId from the nearest segment context", async () => { + const React = await import("react"); + const { renderToStaticMarkup } = await import("react-dom/server"); + const navigation = await import("../packages/vinext/src/shims/navigation.js"); + const BfcacheIdMapContext = navigation.getBfcacheIdMapContext(); + const BfcacheSegmentIdContext = navigation.getBfcacheSegmentIdContext(); + if (!BfcacheIdMapContext || !BfcacheSegmentIdContext) { + throw new Error("Expected bfcache contexts"); + } + + function Probe() { + return React.createElement("span", null, navigation.useRouter().bfcacheId); + } + + expect( + renderToStaticMarkup( + React.createElement( + BfcacheIdMapContext.Provider, + { value: { "page:/x/1": "_b_7_" } }, + React.createElement( + BfcacheSegmentIdContext.Provider, + { value: "page:/x/1" }, + React.createElement(Probe), + ), + ), + ), + ).toBe("_b_7_"); + }); + // Next.js parity: refresh-reducer.ts invalidates the entire segment cache. // Our equivalent is clearClientNavigationCaches(), which router.refresh() // must call before re-fetching, or stale cached RSC payloads for sibling @@ -104,6 +133,99 @@ describe("next/navigation shim", () => { } }); + it("hash-only app router navigation preserves bfcache metadata without copying scroll restoration", async () => { + const previousWindow = (globalThis as any).window; + const previousDocument = (globalThis as any).document; + let historyState: unknown = { + __vinext_bfcacheIds: { "page:/current": "_b_1_" }, + __vinext_previousNextUrl: "/feed", + customState: "drop-me", + }; + const location = { + href: "http://localhost/current", + origin: "http://localhost", + pathname: "/current", + search: "", + hash: "", + assign: vi.fn(), + replace: vi.fn(), + }; + const applyUrl = (url: string | URL | null | undefined) => { + if (url == null) return; + const next = new URL(String(url), location.href); + location.href = next.href; + location.pathname = next.pathname; + location.search = next.search; + location.hash = next.hash; + }; + const pushState = vi.fn((data: unknown, _unused: string, url?: string | URL | null) => { + historyState = data; + applyUrl(url); + }); + const replaceState = vi.fn((data: unknown, _unused: string, url?: string | URL | null) => { + historyState = data; + applyUrl(url); + }); + + const win = { + location, + history: { + get state() { + return historyState; + }, + pushState, + replaceState, + }, + scrollX: 12, + scrollY: 345, + scrollTo: vi.fn(), + addEventListener: vi.fn(), + dispatchEvent: vi.fn(), + __VINEXT_RSC_NAVIGATE__: vi.fn(), + }; + const scrollIntoView = vi.fn(); + + (globalThis as any).window = win; + (globalThis as any).document = { + getElementById: vi.fn(() => ({ scrollIntoView })), + getElementsByName: vi.fn(() => []), + }; + + try { + vi.resetModules(); + const { navigateClientSide } = await import("../packages/vinext/src/shims/navigation.js"); + + await navigateClientSide("#content", "push", true, true); + + expect(replaceState).toHaveBeenCalledWith( + expect.objectContaining({ __vinext_scrollX: 12, __vinext_scrollY: 345 }), + "", + undefined, + ); + expect(pushState).toHaveBeenCalledWith( + { + __vinext_bfcacheIds: { "page:/current": "_b_1_" }, + __vinext_previousNextUrl: "/feed", + }, + "", + "/current#content", + ); + expect(scrollIntoView).toHaveBeenCalled(); + } finally { + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + if (previousDocument === undefined) { + delete (globalThis as any).document; + } else { + (globalThis as any).document = previousDocument; + } + vi.resetModules(); + } + }); + it("keeps pending render snapshot active when external history.pushState syncs the URL", async () => { const previousWindow = (globalThis as any).window; const win = {