From 5da3aa210ca7177e3db22a0c4614f28cf67465d0 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:46:07 +1000 Subject: [PATCH 1/7] Fix parallel slot persistence and cache variants --- packages/vinext/src/entries/app-rsc-entry.ts | 41 ++- .../vinext/src/server/app-browser-entry.ts | 27 +- packages/vinext/src/server/app-elements.ts | 32 +++ packages/vinext/src/server/app-page-cache.ts | 56 ++-- packages/vinext/src/server/app-page-render.ts | 5 +- .../vinext/src/server/app-page-response.ts | 4 + .../src/server/app-page-route-wiring.tsx | 24 +- packages/vinext/src/shims/link.tsx | 9 +- packages/vinext/src/shims/navigation.ts | 46 +++- .../entry-templates.test.ts.snap | 246 ++++++++++++++++-- tests/app-browser-entry.test.ts | 35 +++ tests/app-page-cache.test.ts | 86 +++++- tests/app-page-route-wiring.test.ts | 136 ++++++++++ tests/prefetch-cache.test.ts | 44 ++++ 14 files changed, 725 insertions(+), 66 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 28c53d095..093a7f090 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -538,7 +538,22 @@ function __isrCacheKey(pathname, suffix) { return prefix + ":__hash:" + __isrFnv1a64(normalized) + ":" + suffix; } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } -function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRscKey(pathname, mountedSlotsHeader) { + if (!mountedSlotsHeader) return __isrCacheKey(pathname, "rsc"); + return __isrCacheKey(pathname, "rsc:" + __isrFnv1a64(mountedSlotsHeader)); +} +function __normalizeMountedSlotsHeader(raw) { + if (!raw) return null; + const normalized = Array.from( + new Set( + raw + .split(" ") + .map((slotId) => slotId.trim()) + .filter(Boolean), + ), + ).sort().join(" "); + return normalized || null; +} function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators @@ -911,7 +926,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; @@ -1024,9 +1039,18 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request?.headers?.get("x-vinext-mounted-slots"), + ); + const mountedSlotIds = __mountedSlotsHeader + ? new Set(__mountedSlotsHeader.split(" ")) + : null; + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: ${globalErrorVar ? globalErrorVar : "null"}, + isRscRequest, + mountedSlotIds, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -1778,6 +1802,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -2099,6 +2125,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -2132,6 +2161,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, isrSet: __isrSet, + mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache @@ -2152,6 +2182,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -2208,6 +2240,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -2260,7 +2294,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -2357,6 +2391,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, + mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 03e949082..c914f7eb3 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -35,6 +35,7 @@ import { restoreRscResponse, setClientParams, snapshotRscResponse, + setMountedSlotsHeader, setNavigationContext, toRscUrl, type CachedRscResponse, @@ -47,6 +48,7 @@ import { getVinextBrowserGlobal, } from "./app-browser-stream.js"; import { + getMountedSlotIdsHeader, normalizeAppElements, readAppElementsMetadata, type AppElements, @@ -218,6 +220,7 @@ function evictVisitedResponseCacheIfNeeded(): void { function getVisitedResponse( rscUrl: string, + mountedSlotsHeader: string | null, navigationKind: NavigationKind, ): VisitedResponseCacheEntry | null { const cached = visitedResponseCache.get(rscUrl); @@ -225,6 +228,11 @@ function getVisitedResponse( return null; } + if ((cached.response.mountedSlotsHeader ?? null) !== mountedSlotsHeader) { + visitedResponseCache.delete(rscUrl); + return null; + } + if (navigationKind === "refresh") { return null; } @@ -404,6 +412,7 @@ function BrowserRoot({ useLayoutEffect(() => { dispatchBrowserRouterAction = dispatchTreeState; browserRouterStateRef = stateRef; + setMountedSlotsHeader(getMountedSlotIdsHeader(stateRef.current.elements)); return () => { if (dispatchBrowserRouterAction === dispatchTreeState) { dispatchBrowserRouterAction = null; @@ -411,8 +420,9 @@ function BrowserRoot({ if (browserRouterStateRef === stateRef) { browserRouterStateRef = null; } + setMountedSlotsHeader(null); }; - }, [dispatchTreeState]); + }, [dispatchTreeState, treeState.elements]); const committedTree = createElement( NavigationCommitSignal, @@ -737,7 +747,9 @@ async function main(): Promise { const isSameRoute = stripBasePath(url.pathname, __basePath) === stripBasePath(window.location.pathname, __basePath); - const cachedRoute = getVisitedResponse(rscUrl, navigationKind); + const elementsAtNavStart = getBrowserRouterState().elements; + const mountedSlotsHeader = getMountedSlotIdsHeader(elementsAtNavStart); + const cachedRoute = getVisitedResponse(rscUrl, mountedSlotsHeader, navigationKind); if (cachedRoute) { // Check stale-navigation before and after createFromFetch. The pre-check // avoids wasted parse work; the post-check catches supersessions that @@ -777,10 +789,13 @@ async function main(): Promise { return; } + // Continue using the slot state captured at navigation start for fetches + // and prefetch compatibility decisions. + let navResponse: Response | undefined; let navResponseUrl: string | null = null; if (navigationKind !== "refresh") { - const prefetchedResponse = consumePrefetchResponse(rscUrl); + const prefetchedResponse = consumePrefetchResponse(rscUrl, mountedSlotsHeader); if (prefetchedResponse) { navResponse = restoreRscResponse(prefetchedResponse, false); navResponseUrl = prefetchedResponse.url; @@ -788,8 +803,12 @@ async function main(): Promise { } if (!navResponse) { + const rscFetchHeaders: Record = { Accept: "text/x-component" }; + if (mountedSlotsHeader) { + rscFetchHeaders["X-Vinext-Mounted-Slots"] = mountedSlotsHeader; + } navResponse = await fetch(rscUrl, { - headers: { Accept: "text/x-component" }, + headers: rscFetchHeaders, credentials: "include", }); } diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 250b2eaee..9c61f507d 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -17,6 +17,38 @@ export type AppElementsMetadata = { rootLayoutTreePath: string | null; }; +export function normalizeMountedSlotsHeader(header: string | null | undefined): string | null { + if (!header) { + return null; + } + + const slotIds = Array.from( + new Set( + header + .split(/\s+/) + .map((slotId) => slotId.trim()) + .filter(Boolean), + ), + ).sort(); + + return slotIds.length > 0 ? slotIds.join(" ") : null; +} + +export function getMountedSlotIds(elements: AppElements): string[] { + return Object.keys(elements) + .filter((key) => { + const value = elements[key]; + return ( + key.startsWith("slot:") && value !== null && value !== undefined && value !== UNMATCHED_SLOT + ); + }) + .sort(); +} + +export function getMountedSlotIdsHeader(elements: AppElements): string | null { + return normalizeMountedSlotsHeader(getMountedSlotIds(elements).join(" ")); +} + export function normalizeAppElements(elements: AppWireElements): AppElements { let needsNormalization = false; for (const [key, value] of Object.entries(elements)) { diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index 3520619cb..1380cf3b7 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -20,6 +20,7 @@ export type AppPageCacheRenderResult = { export type BuildAppPageCachedResponseOptions = { cacheState: "HIT" | "STALE"; isRscRequest: boolean; + mountedSlotsHeader?: string | null; revalidateSeconds: number; }; @@ -30,8 +31,9 @@ export type ReadAppPageCacheResponseOptions = { isrDebug?: AppPageDebugLogger; isrGet: AppPageCacheGetter; isrHtmlKey: (pathname: string) => string; - isrRscKey: (pathname: string) => string; + isrRscKey: (pathname: string, mountedSlotsHeader?: string | null) => string; isrSet: AppPageCacheSetter; + mountedSlotsHeader?: string | null; revalidateSeconds: number; renderFreshPageForCache: () => Promise; scheduleBackgroundRegeneration: AppPageBackgroundRegenerator; @@ -43,7 +45,7 @@ export type FinalizeAppPageHtmlCacheResponseOptions = { getPageTags: () => string[]; isrDebug?: AppPageDebugLogger; isrHtmlKey: (pathname: string) => string; - isrRscKey: (pathname: string) => string; + isrRscKey: (pathname: string, mountedSlotsHeader?: string | null) => string; isrSet: AppPageCacheSetter; revalidateSeconds: number; waitUntil?: (promise: Promise) => void; @@ -56,8 +58,9 @@ export type ScheduleAppPageRscCacheWriteOptions = { dynamicUsedDuringBuild: boolean; getPageTags: () => string[]; isrDebug?: AppPageDebugLogger; - isrRscKey: (pathname: string) => string; + isrRscKey: (pathname: string, mountedSlotsHeader?: string | null) => string; isrSet: AppPageCacheSetter; + mountedSlotsHeader?: string | null; revalidateSeconds: number; waitUntil?: (promise: Promise) => void; }; @@ -95,12 +98,17 @@ export function buildAppPageCachedResponse( return null; } + const rscHeaders: Record = { + "Content-Type": "text/x-component; charset=utf-8", + ...headers, + }; + if (options.mountedSlotsHeader) { + rscHeaders["X-Vinext-Mounted-Slots"] = options.mountedSlotsHeader; + } + return new Response(cachedValue.rscData, { status, - headers: { - "Content-Type": "text/x-component; charset=utf-8", - ...headers, - }, + headers: rscHeaders, }); } @@ -121,7 +129,7 @@ export async function readAppPageCacheResponse( options: ReadAppPageCacheResponseOptions, ): Promise { const isrKey = options.isRscRequest - ? options.isrRscKey(options.cleanPathname) + ? options.isrRscKey(options.cleanPathname, options.mountedSlotsHeader) : options.isrHtmlKey(options.cleanPathname); try { @@ -132,6 +140,7 @@ export async function readAppPageCacheResponse( const hitResponse = buildAppPageCachedResponse(cachedValue, { cacheState: "HIT", isRscRequest: options.isRscRequest, + mountedSlotsHeader: options.mountedSlotsHeader, revalidateSeconds: options.revalidateSeconds, }); @@ -153,27 +162,34 @@ export async function readAppPageCacheResponse( // the stale payload and will fall through to a fresh render. options.scheduleBackgroundRegeneration(options.cleanPathname, async () => { const revalidatedPage = await options.renderFreshPageForCache(); - - await Promise.all([ - options.isrSet( - options.isrHtmlKey(options.cleanPathname), - buildAppPageCacheValue(revalidatedPage.html, undefined, 200), - options.revalidateSeconds, - revalidatedPage.tags, - ), + const writes = [ options.isrSet( - options.isrRscKey(options.cleanPathname), + options.isrRscKey(options.cleanPathname, options.mountedSlotsHeader), buildAppPageCacheValue("", revalidatedPage.rscData, 200), options.revalidateSeconds, revalidatedPage.tags, ), - ]); + ]; + + if (!options.isRscRequest) { + writes.push( + options.isrSet( + options.isrHtmlKey(options.cleanPathname), + buildAppPageCacheValue(revalidatedPage.html, undefined, 200), + options.revalidateSeconds, + revalidatedPage.tags, + ), + ); + } + + await Promise.all(writes); options.isrDebug?.("regen complete", options.cleanPathname); }); const staleResponse = buildAppPageCachedResponse(cachedValue, { cacheState: "STALE", isRscRequest: options.isRscRequest, + mountedSlotsHeader: options.mountedSlotsHeader, revalidateSeconds: options.revalidateSeconds, }); @@ -209,7 +225,7 @@ export function finalizeAppPageHtmlCacheResponse( const [streamForClient, streamForCache] = response.body.tee(); const htmlKey = options.isrHtmlKey(options.cleanPathname); - const rscKey = options.isrRscKey(options.cleanPathname); + const rscKey = options.isrRscKey(options.cleanPathname, null); const cachePromise = (async () => { try { @@ -272,7 +288,7 @@ export function scheduleAppPageRscCacheWrite( return false; } - const rscKey = options.isrRscKey(options.cleanPathname); + const rscKey = options.isrRscKey(options.cleanPathname, options.mountedSlotsHeader); const cachePromise = (async () => { try { const rscData = await capturedRscDataPromise; diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 6f2aa55aa..e7b5753cf 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -68,7 +68,7 @@ export type RenderAppPageLifecycleOptions = { isRscRequest: boolean; isrDebug?: AppPageDebugLogger; isrHtmlKey: (pathname: string) => string; - isrRscKey: (pathname: string) => string; + isrRscKey: (pathname: string, mountedSlotsHeader?: string | null) => string; isrSet: AppPageCacheSetter; layoutCount: number; loadSsrHandler: () => Promise; @@ -91,6 +91,7 @@ export type RenderAppPageLifecycleOptions = { routePattern: string; runWithSuppressedHookWarning(probe: () => Promise): Promise; scriptNonce?: string; + mountedSlotsHeader?: string | null; waitUntil?: (promise: Promise) => void; element: ReactNode | Record; }; @@ -172,6 +173,7 @@ export async function renderAppPageLifecycle( }); const rscResponse = buildAppPageRscResponse(rscForResponse, { middlewareContext: options.middlewareContext, + mountedSlotsHeader: options.mountedSlotsHeader, params: options.params, policy: rscResponsePolicy, timing: buildResponseTiming({ @@ -193,6 +195,7 @@ export async function renderAppPageLifecycle( isrDebug: options.isrDebug, isrRscKey: options.isrRscKey, isrSet: options.isrSet, + mountedSlotsHeader: options.mountedSlotsHeader, revalidateSeconds: revalidateSeconds ?? 0, waitUntil(promise) { options.waitUntil?.(promise); diff --git a/packages/vinext/src/server/app-page-response.ts b/packages/vinext/src/server/app-page-response.ts index 1ecc94810..ebb756acd 100644 --- a/packages/vinext/src/server/app-page-response.ts +++ b/packages/vinext/src/server/app-page-response.ts @@ -40,6 +40,7 @@ export type AppPageHtmlResponsePolicy = { export type BuildAppPageRscResponseOptions = { middlewareContext: AppPageMiddlewareContext; + mountedSlotsHeader?: string | null; params?: Record; policy: AppPageResponsePolicy; timing?: AppPageResponseTiming; @@ -199,6 +200,9 @@ export function buildAppPageRscResponse( // HTTP ByteString constraint — Headers.set() rejects chars above U+00FF. headers.set("X-Vinext-Params", encodeURIComponent(JSON.stringify(options.params))); } + if (options.mountedSlotsHeader) { + headers.set("X-Vinext-Mounted-Slots", options.mountedSlotsHeader); + } if (options.policy.cacheControl) { headers.set("Cache-Control", options.policy.cacheControl); } diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index 2683213fb..c3d6415cf 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -107,6 +107,8 @@ export type BuildAppPageElementsOptions< TModule extends AppPageModule = AppPageModule, TErrorModule extends AppPageErrorModule = AppPageErrorModule, > = BuildAppPageRouteElementOptions & { + isRscRequest?: boolean; + mountedSlotIds?: ReadonlySet | null; routePath: string; }; @@ -451,10 +453,24 @@ export function buildAppPageElements< const slotId = `slot:${slotName}:${treePath}`; const slotOverride = resolveSlotOverride(slotKey, slotName); const slotParams = getEffectiveSlotParams(slotKey, slotName); - const slotComponent = - getDefaultExport(slotOverride?.pageModule) ?? - getDefaultExport(slot.page) ?? - getDefaultExport(slot.default); + const overrideOrPageComponent = + getDefaultExport(slotOverride?.pageModule) ?? getDefaultExport(slot.page); + const defaultComponent = getDefaultExport(slot.default); + + // On soft nav (RSC): omit key when only default.tsx exists and the slot is + // already mounted on the client. Absent key means the browser retains prior + // slot content rather than replacing it. When the slot is not yet mounted + // (first entry into this layout), include the key so default.tsx renders. + if ( + !overrideOrPageComponent && + defaultComponent && + options.isRscRequest && + options.mountedSlotIds?.has(slotId) + ) { + continue; + } + + const slotComponent = overrideOrPageComponent ?? defaultComponent; if (!slotComponent) { elements[slotId] = APP_UNMATCHED_SLOT_WIRE_VALUE; diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index db950de98..7e6855017 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -23,6 +23,7 @@ import React, { import { toRscUrl, getPrefetchedUrls, + getMountedSlotsHeader, navigateClientSide, prefetchRscResponse, } from "./navigation.js"; @@ -135,15 +136,21 @@ function prefetchUrl(href: string): void { schedule(() => { if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") { + const mountedSlotsHeader = getMountedSlotsHeader(); + const headers: Record = { Accept: "text/x-component" }; + if (mountedSlotsHeader) { + headers["X-Vinext-Mounted-Slots"] = mountedSlotsHeader; + } prefetchRscResponse( rscUrl, fetch(rscUrl, { - headers: { Accept: "text/x-component" }, + headers, credentials: "include", priority: "low" as const, // @ts-expect-error — purpose is a valid fetch option in some browsers purpose: "prefetch", }), + mountedSlotsHeader, ); } else if ((window.__NEXT_DATA__ as VinextNextData | undefined)?.__vinext?.pageModuleUrl) { // Pages Router: inject a prefetch link for the target page module diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index f4033bd2d..4d547fc8e 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -247,6 +247,7 @@ export const PREFETCH_CACHE_TTL = 30_000; export type CachedRscResponse = { buffer: ArrayBuffer; contentType: string; + mountedSlotsHeader?: string | null; paramsHeader: string | null; url: string; }; @@ -361,6 +362,7 @@ export async function snapshotRscResponse(response: Response): Promise): void { +export function prefetchRscResponse( + rscUrl: string, + fetchPromise: Promise, + mountedSlotsHeader: string | null = null, +): void { const cache = getPrefetchCache(); const prefetched = getPrefetchedUrls(); const now = Date.now(); @@ -410,7 +419,10 @@ export function prefetchRscResponse(rscUrl: string, fetchPromise: Promise { if (response.ok) { - entry.snapshot = await snapshotRscResponse(response); + entry.snapshot = { + ...(await snapshotRscResponse(response)), + mountedSlotsHeader, + }; } else { prefetched.delete(rscUrl); cache.delete(rscUrl); @@ -436,7 +448,10 @@ export function prefetchRscResponse(rscUrl: string, fetchPromise: Promise= PREFETCH_CACHE_TTL) { return null; } @@ -464,6 +482,7 @@ export function consumePrefetchResponse(rscUrl: string): CachedRscResponse | nul type NavigationListener = () => void; const _CLIENT_NAV_STATE_KEY = Symbol.for("vinext.clientNavigationState"); +const _MOUNTED_SLOTS_HEADER_KEY = Symbol.for("vinext.mountedSlotsHeader"); type ClientNavigationState = { listeners: Set; @@ -484,8 +503,21 @@ type ClientNavigationState = { type ClientNavigationGlobal = typeof globalThis & { [_CLIENT_NAV_STATE_KEY]?: ClientNavigationState; + [_MOUNTED_SLOTS_HEADER_KEY]?: string | null; }; +export function setMountedSlotsHeader(header: string | null): void { + if (isServer) return; + const globalState = window as ClientNavigationGlobal; + globalState[_MOUNTED_SLOTS_HEADER_KEY] = header; +} + +export function getMountedSlotsHeader(): string | null { + if (isServer) return null; + const globalState = window as ClientNavigationGlobal; + return globalState[_MOUNTED_SLOTS_HEADER_KEY] ?? null; +} + function getClientNavigationState(): ClientNavigationState | null { if (isServer) return null; @@ -1123,13 +1155,19 @@ const _appRouter = { const prefetched = getPrefetchedUrls(); if (prefetched.has(rscUrl)) return; prefetched.add(rscUrl); + const mountedSlotsHeader = getMountedSlotsHeader(); + const headers: Record = { Accept: "text/x-component" }; + if (mountedSlotsHeader) { + headers["X-Vinext-Mounted-Slots"] = mountedSlotsHeader; + } prefetchRscResponse( rscUrl, fetch(rscUrl, { - headers: { Accept: "text/x-component" }, + headers, credentials: "include", priority: "low" as RequestInit["priority"], }), + mountedSlotsHeader, ); }, }; diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 6045cb88a..8c54aa88d 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -230,7 +230,22 @@ function __isrCacheKey(pathname, suffix) { return prefix + ":__hash:" + __isrFnv1a64(normalized) + ":" + suffix; } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } -function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRscKey(pathname, mountedSlotsHeader) { + if (!mountedSlotsHeader) return __isrCacheKey(pathname, "rsc"); + return __isrCacheKey(pathname, "rsc:" + __isrFnv1a64(mountedSlotsHeader)); +} +function __normalizeMountedSlotsHeader(raw) { + if (!raw) return null; + const normalized = Array.from( + new Set( + raw + .split(" ") + .map((slotId) => slotId.trim()) + .filter(Boolean), + ), + ).sort().join(" "); + return normalized || null; +} function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators @@ -670,7 +685,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; @@ -783,9 +798,18 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request?.headers?.get("x-vinext-mounted-slots"), + ); + const mountedSlotIds = __mountedSlotsHeader + ? new Set(__mountedSlotsHeader.split(" ")) + : null; + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: null, + isRscRequest, + mountedSlotIds, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -1502,6 +1526,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -1793,6 +1819,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -1826,6 +1855,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, isrSet: __isrSet, + mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache @@ -1846,6 +1876,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -1902,6 +1934,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -1954,7 +1988,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -2051,6 +2085,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, + mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -2364,7 +2399,22 @@ function __isrCacheKey(pathname, suffix) { return prefix + ":__hash:" + __isrFnv1a64(normalized) + ":" + suffix; } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } -function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRscKey(pathname, mountedSlotsHeader) { + if (!mountedSlotsHeader) return __isrCacheKey(pathname, "rsc"); + return __isrCacheKey(pathname, "rsc:" + __isrFnv1a64(mountedSlotsHeader)); +} +function __normalizeMountedSlotsHeader(raw) { + if (!raw) return null; + const normalized = Array.from( + new Set( + raw + .split(" ") + .map((slotId) => slotId.trim()) + .filter(Boolean), + ), + ).sort().join(" "); + return normalized || null; +} function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators @@ -2804,7 +2854,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; @@ -2917,9 +2967,18 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request?.headers?.get("x-vinext-mounted-slots"), + ); + const mountedSlotIds = __mountedSlotsHeader + ? new Set(__mountedSlotsHeader.split(" ")) + : null; + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: null, + isRscRequest, + mountedSlotIds, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -3642,6 +3701,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -3933,6 +3994,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -3966,6 +4030,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, isrSet: __isrSet, + mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache @@ -3986,6 +4051,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -4042,6 +4109,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -4094,7 +4163,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -4191,6 +4260,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, + mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -4504,7 +4574,22 @@ function __isrCacheKey(pathname, suffix) { return prefix + ":__hash:" + __isrFnv1a64(normalized) + ":" + suffix; } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } -function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRscKey(pathname, mountedSlotsHeader) { + if (!mountedSlotsHeader) return __isrCacheKey(pathname, "rsc"); + return __isrCacheKey(pathname, "rsc:" + __isrFnv1a64(mountedSlotsHeader)); +} +function __normalizeMountedSlotsHeader(raw) { + if (!raw) return null; + const normalized = Array.from( + new Set( + raw + .split(" ") + .map((slotId) => slotId.trim()) + .filter(Boolean), + ), + ).sort().join(" "); + return normalized || null; +} function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators @@ -4945,7 +5030,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; @@ -5058,9 +5143,18 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request?.headers?.get("x-vinext-mounted-slots"), + ); + const mountedSlotIds = __mountedSlotsHeader + ? new Set(__mountedSlotsHeader.split(" ")) + : null; + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: mod_11, + isRscRequest, + mountedSlotIds, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -5777,6 +5871,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -6068,6 +6164,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -6101,6 +6200,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, isrSet: __isrSet, + mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache @@ -6121,6 +6221,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -6177,6 +6279,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -6229,7 +6333,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -6326,6 +6430,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, + mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -6639,7 +6744,22 @@ function __isrCacheKey(pathname, suffix) { return prefix + ":__hash:" + __isrFnv1a64(normalized) + ":" + suffix; } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } -function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRscKey(pathname, mountedSlotsHeader) { + if (!mountedSlotsHeader) return __isrCacheKey(pathname, "rsc"); + return __isrCacheKey(pathname, "rsc:" + __isrFnv1a64(mountedSlotsHeader)); +} +function __normalizeMountedSlotsHeader(raw) { + if (!raw) return null; + const normalized = Array.from( + new Set( + raw + .split(" ") + .map((slotId) => slotId.trim()) + .filter(Boolean), + ), + ).sort().join(" "); + return normalized || null; +} function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators @@ -7109,7 +7229,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; @@ -7222,9 +7342,18 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request?.headers?.get("x-vinext-mounted-slots"), + ); + const mountedSlotIds = __mountedSlotsHeader + ? new Set(__mountedSlotsHeader.split(" ")) + : null; + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: null, + isRscRequest, + mountedSlotIds, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -7944,6 +8073,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -8235,6 +8366,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -8268,6 +8402,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, isrSet: __isrSet, + mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache @@ -8288,6 +8423,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -8344,6 +8481,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -8396,7 +8535,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -8493,6 +8632,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, + mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -8806,7 +8946,22 @@ function __isrCacheKey(pathname, suffix) { return prefix + ":__hash:" + __isrFnv1a64(normalized) + ":" + suffix; } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } -function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRscKey(pathname, mountedSlotsHeader) { + if (!mountedSlotsHeader) return __isrCacheKey(pathname, "rsc"); + return __isrCacheKey(pathname, "rsc:" + __isrFnv1a64(mountedSlotsHeader)); +} +function __normalizeMountedSlotsHeader(raw) { + if (!raw) return null; + const normalized = Array.from( + new Set( + raw + .split(" ") + .map((slotId) => slotId.trim()) + .filter(Boolean), + ), + ).sort().join(" "); + return normalized || null; +} function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators @@ -9253,7 +9408,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; @@ -9366,9 +9521,18 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request?.headers?.get("x-vinext-mounted-slots"), + ); + const mountedSlotIds = __mountedSlotsHeader + ? new Set(__mountedSlotsHeader.split(" ")) + : null; + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: null, + isRscRequest, + mountedSlotIds, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -10085,6 +10249,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -10376,6 +10542,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -10409,6 +10578,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, isrSet: __isrSet, + mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache @@ -10429,6 +10599,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -10485,6 +10657,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -10537,7 +10711,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -10634,6 +10808,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, + mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -10947,7 +11122,22 @@ function __isrCacheKey(pathname, suffix) { return prefix + ":__hash:" + __isrFnv1a64(normalized) + ":" + suffix; } function __isrHtmlKey(pathname) { return __isrCacheKey(pathname, "html"); } -function __isrRscKey(pathname) { return __isrCacheKey(pathname, "rsc"); } +function __isrRscKey(pathname, mountedSlotsHeader) { + if (!mountedSlotsHeader) return __isrCacheKey(pathname, "rsc"); + return __isrCacheKey(pathname, "rsc:" + __isrFnv1a64(mountedSlotsHeader)); +} +function __normalizeMountedSlotsHeader(raw) { + if (!raw) return null; + const normalized = Array.from( + new Set( + raw + .split(" ") + .map((slotId) => slotId.trim()) + .filter(Boolean), + ), + ).sort().join(" "); + return normalized || null; +} function __isrRouteKey(pathname) { return __isrCacheKey(pathname, "route"); } // Verbose cache logging — opt in with NEXT_PRIVATE_DEBUG_CACHE=1. // Matches the env var Next.js uses for its own cache debug output so operators @@ -11387,7 +11577,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; @@ -11500,9 +11690,18 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request?.headers?.get("x-vinext-mounted-slots"), + ); + const mountedSlotIds = __mountedSlotsHeader + ? new Set(__mountedSlotsHeader.split(" ")) + : null; + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: null, + isRscRequest, + mountedSlotIds, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -12586,6 +12785,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -12877,6 +13078,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -12910,6 +13114,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, isrSet: __isrSet, + mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache @@ -12930,6 +13135,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -12986,6 +13193,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -13038,7 +13247,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -13135,6 +13344,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, + mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index dc0752cd8..5387cb393 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -3,6 +3,9 @@ import { describe, expect, it } from "vite-plus/test"; import { APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, + UNMATCHED_SLOT, + getMountedSlotIds, + getMountedSlotIdsHeader, normalizeAppElements, type AppElements, } from "../packages/vinext/src/server/app-elements.js"; @@ -219,3 +222,35 @@ describe("app browser entry state helpers", () => { expect(shouldHardNavigate("/", null)).toBe(false); }); }); + +describe("mounted slot helpers", () => { + it("collects only mounted slot ids", () => { + const elements: AppElements = createResolvedElements("route:/dashboard", "/", { + "layout:/": React.createElement("div", null, "layout"), + "slot:modal:/": React.createElement("div", null, "modal"), + "slot:sidebar:/": React.createElement("div", null, "sidebar"), + "slot:ghost:/": null, + "slot:missing:/": UNMATCHED_SLOT, + }); + + expect(getMountedSlotIds(elements)).toEqual(["slot:modal:/", "slot:sidebar:/"]); + }); + + it("serializes mounted slot ids into a stable header value", () => { + const elements: AppElements = createResolvedElements("route:/dashboard", "/", { + "slot:z:/": React.createElement("div", null, "z"), + "slot:a:/": React.createElement("div", null, "a"), + }); + + expect(getMountedSlotIdsHeader(elements)).toBe("slot:a:/ slot:z:/"); + }); + + it("returns null when there are no mounted slots", () => { + const elements: AppElements = createResolvedElements("route:/dashboard", "/", { + "slot:ghost:/": null, + "slot:missing:/": UNMATCHED_SLOT, + }); + + expect(getMountedSlotIdsHeader(elements)).toBeNull(); + }); +}); diff --git a/tests/app-page-cache.test.ts b/tests/app-page-cache.test.ts index 7746465b9..6bbe24919 100644 --- a/tests/app-page-cache.test.ts +++ b/tests/app-page-cache.test.ts @@ -124,7 +124,38 @@ describe("app page cache helpers", () => { expect(didClearRequestContext).toBe(true); }); - it("serves stale entries and regenerates HTML and RSC cache keys", async () => { + it("keys RSC cache reads by mounted-slot header and echoes the variant header", async () => { + const response = await readAppPageCacheResponse({ + cleanPathname: "/cached", + clearRequestContext() {}, + isRscRequest: true, + async isrGet(key) { + expect(key).toBe("rsc:/cached:slot:auth:/"); + return buildISRCacheEntry( + buildCachedAppPageValue("", new TextEncoder().encode("flight").buffer), + ); + }, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + isrRscKey(pathname, mountedSlotsHeader) { + return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}`; + }, + async isrSet() {}, + mountedSlotsHeader: "slot:auth:/", + revalidateSeconds: 60, + async renderFreshPageForCache() { + throw new Error("should not render"); + }, + scheduleBackgroundRegeneration() { + throw new Error("should not schedule regeneration"); + }, + }); + + expect(response?.headers.get("x-vinext-mounted-slots")).toBe("slot:auth:/"); + }); + + it("serves stale RSC entries and regenerates only the matching RSC cache key", async () => { const scheduledRegenerations: Array<() => Promise> = []; const isrSetCalls: Array<{ key: string; @@ -145,8 +176,8 @@ describe("app page cache helpers", () => { isrHtmlKey(pathname) { return "html:" + pathname; }, - isrRscKey(pathname) { - return "rsc:" + pathname; + isrRscKey(pathname, mountedSlotsHeader) { + return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}`; }, async isrSet(key, data, revalidateSeconds, tags) { isrSetCalls.push({ @@ -157,6 +188,7 @@ describe("app page cache helpers", () => { tags, }); }, + mountedSlotsHeader: "slot:auth:/", revalidateSeconds: 60, async renderFreshPageForCache() { return { @@ -177,14 +209,7 @@ describe("app page cache helpers", () => { expect(isrSetCalls).toEqual([ { - key: "html:/stale", - html: "

fresh

", - hasRscData: false, - revalidateSeconds: 60, - tags: ["/stale", "_N_T_/stale"], - }, - { - key: "rsc:/stale", + key: "rsc:/stale:slot:auth:/", html: "", hasRscData: true, revalidateSeconds: 60, @@ -193,6 +218,45 @@ describe("app page cache helpers", () => { ]); }); + it("serves stale HTML entries and regenerates HTML plus canonical RSC cache keys", async () => { + const scheduledRegenerations: Array<() => Promise> = []; + const isrSetCalls: Array = []; + const rscData = new TextEncoder().encode("fresh-flight").buffer; + + const response = await readAppPageCacheResponse({ + cleanPathname: "/stale-html", + clearRequestContext() {}, + isRscRequest: false, + async isrGet() { + return buildISRCacheEntry(buildCachedAppPageValue("

stale

"), true); + }, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + isrRscKey(pathname, mountedSlotsHeader) { + return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}`; + }, + async isrSet(key) { + isrSetCalls.push(key); + }, + revalidateSeconds: 60, + async renderFreshPageForCache() { + return { + html: "

fresh

", + rscData, + tags: ["/stale-html", "_N_T_/stale-html"], + }; + }, + scheduleBackgroundRegeneration(_key, renderFn) { + scheduledRegenerations.push(renderFn); + }, + }); + + expect(response?.headers.get("x-vinext-cache")).toBe("STALE"); + await scheduledRegenerations[0](); + expect(isrSetCalls).toEqual(["rsc:/stale-html:none", "html:/stale-html"]); + }); + it("still schedules stale regeneration when the stale payload is unusable for this request", async () => { const debugCalls: Array<[string, string]> = []; const scheduledRegenerations: Array<() => Promise> = []; diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 7b4b57e86..820c17dc5 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -428,6 +428,142 @@ describe("app page route wiring helpers", () => { expect(html).not.toContain('data-slot-page="ambiguous-override"'); }); + it("omits slot key on RSC request when slot has only default.tsx (no page) and slot is already mounted", () => { + const DefaultPage = () => createElement("p", null, "default-slot"); + const elements = buildAppPageElements({ + isRscRequest: true, + mountedSlotIds: new Set(["slot:team:/"]), + element: createElement(PageProbe), + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: {}, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null], + layoutTreePositions: [0], + layouts: [{ default: RootLayout }], + loading: null, + notFound: null, + notFounds: [null], + routeSegments: [], + slots: { + team: { + default: { default: DefaultPage }, + error: null, + layout: null, + layoutIndex: 0, + loading: null, + name: "team", + page: null, + routeSegments: [], + }, + }, + templateTreePositions: [], + templates: [], + }, + routePath: "/", + rootNotFoundModule: null, + }); + + // On RSC soft nav, a slot with only default.tsx (no page) should have its + // key absent so the browser retains prior content — but only when the slot + // is already mounted (browser told us via X-Vinext-Mounted-Slots header). + expect(elements["slot:team:/"]).toBeUndefined(); + }); + + it("renders slot default.tsx on RSC request when slot is not in mountedSlotIds (first entry)", () => { + const DefaultPage = () => createElement("p", null, "default-slot"); + const elements = buildAppPageElements({ + isRscRequest: true, + mountedSlotIds: new Set([]), + element: createElement(PageProbe), + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: {}, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null], + layoutTreePositions: [0], + layouts: [{ default: RootLayout }], + loading: null, + notFound: null, + notFounds: [null], + routeSegments: [], + slots: { + team: { + default: { default: DefaultPage }, + error: null, + layout: null, + layoutIndex: 0, + loading: null, + name: "team", + page: null, + routeSegments: [], + }, + }, + templateTreePositions: [], + templates: [], + }, + routePath: "/", + rootNotFoundModule: null, + }); + + // Even on an RSC request, when the slot has not been mounted on the client + // yet (first navigation into this layout), default.tsx must render so the + // initial slot content is populated. + expect(elements["slot:team:/"]).toBeDefined(); + }); + + it("renders slot default.tsx on hard navigation when slot has no page", () => { + const DefaultPage = () => createElement("p", null, "default-slot"); + const elements = buildAppPageElements({ + isRscRequest: false, + element: createElement(PageProbe), + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: {}, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null], + layoutTreePositions: [0], + layouts: [{ default: RootLayout }], + loading: null, + notFound: null, + notFounds: [null], + routeSegments: [], + slots: { + team: { + default: { default: DefaultPage }, + error: null, + layout: null, + layoutIndex: 0, + loading: null, + name: "team", + page: null, + routeSegments: [], + }, + }, + templateTreePositions: [], + templates: [], + }, + routePath: "/", + rootNotFoundModule: null, + }); + + // On hard navigation the default.tsx must render so the initial HTML is + // fully populated. + expect(elements["slot:team:/"]).toBeDefined(); + }); + it("does not deadlock when a layout renders without children", async () => { const elements = buildAppPageElements({ element: createElement("main", null, "Page content"), diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index b7fd73b1e..56e9edf17 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -13,6 +13,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test" type Navigation = typeof import("../packages/vinext/src/shims/navigation.js"); let storePrefetchResponse: Navigation["storePrefetchResponse"]; +let consumePrefetchResponse: Navigation["consumePrefetchResponse"]; let getPrefetchCache: Navigation["getPrefetchCache"]; let getPrefetchedUrls: Navigation["getPrefetchedUrls"]; let MAX_PREFETCH_CACHE_SIZE: Navigation["MAX_PREFETCH_CACHE_SIZE"]; @@ -33,6 +34,7 @@ beforeEach(async () => { vi.resetModules(); const nav = await import("../packages/vinext/src/shims/navigation.js"); storePrefetchResponse = nav.storePrefetchResponse; + consumePrefetchResponse = nav.consumePrefetchResponse; getPrefetchCache = nav.getPrefetchCache; getPrefetchedUrls = nav.getPrefetchedUrls; MAX_PREFETCH_CACHE_SIZE = nav.MAX_PREFETCH_CACHE_SIZE; @@ -68,6 +70,48 @@ function fillCache(count: number, timestamp: number, keyPrefix = "/page-"): void } describe("prefetch cache eviction", () => { + it("reuses a prefetched response only when mounted-slot context matches", () => { + const cache = getPrefetchCache(); + const prefetched = getPrefetchedUrls(); + const rscUrl = "/dashboard.rsc"; + const snapshot = { + buffer: new TextEncoder().encode("flight").buffer, + contentType: "text/x-component", + mountedSlotsHeader: "slot:auth:/", + paramsHeader: null, + url: rscUrl, + }; + + cache.set(rscUrl, { snapshot, timestamp: Date.now() }); + prefetched.add(rscUrl); + + expect(consumePrefetchResponse(rscUrl, "slot:auth:/")).toEqual(snapshot); + expect(cache.has(rscUrl)).toBe(false); + expect(prefetched.has(rscUrl)).toBe(false); + }); + + it("rejects a prefetched response when mounted-slot context differs", () => { + const cache = getPrefetchCache(); + const prefetched = getPrefetchedUrls(); + const rscUrl = "/dashboard.rsc"; + + cache.set(rscUrl, { + snapshot: { + buffer: new TextEncoder().encode("flight").buffer, + contentType: "text/x-component", + mountedSlotsHeader: "slot:auth:/", + paramsHeader: null, + url: rscUrl, + }, + timestamp: Date.now(), + }); + prefetched.add(rscUrl); + + expect(consumePrefetchResponse(rscUrl, "slot:nav:/")).toBeNull(); + expect(cache.has(rscUrl)).toBe(false); + expect(prefetched.has(rscUrl)).toBe(false); + }); + it("preserves X-Vinext-Params when replaying cached RSC responses", async () => { const response = new Response("flight", { headers: { From 8d8fbe13315ed2f2eb214c69ddac679cb6740a60 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:29:52 +1000 Subject: [PATCH 2/7] Address App Router review follow-ups --- packages/vinext/src/entries/app-rsc-entry.ts | 2 +- .../vinext/src/server/app-browser-entry.ts | 7 +++++-- packages/vinext/src/server/app-page-cache.ts | 2 ++ packages/vinext/src/shims/navigation.ts | 2 ++ .../__snapshots__/entry-templates.test.ts.snap | 18 ++++++++++++------ 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 093a7f090..ffbb530f4 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -547,7 +547,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(" ") + .split(/[ \t\r\n]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index c914f7eb3..1e7c3f8bc 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -412,7 +412,6 @@ function BrowserRoot({ useLayoutEffect(() => { dispatchBrowserRouterAction = dispatchTreeState; browserRouterStateRef = stateRef; - setMountedSlotsHeader(getMountedSlotIdsHeader(stateRef.current.elements)); return () => { if (dispatchBrowserRouterAction === dispatchTreeState) { dispatchBrowserRouterAction = null; @@ -422,7 +421,11 @@ function BrowserRoot({ } setMountedSlotsHeader(null); }; - }, [dispatchTreeState, treeState.elements]); + }, [dispatchTreeState]); + + useLayoutEffect(() => { + setMountedSlotsHeader(getMountedSlotIdsHeader(stateRef.current.elements)); + }, [treeState.elements]); const committedTree = createElement( NavigationCommitSignal, diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index 1380cf3b7..0fc03168d 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -172,6 +172,8 @@ export async function readAppPageCacheResponse( ]; if (!options.isRscRequest) { + // HTML remains canonical across slot-state variants; only RSC cache + // entries fan out by mounted-slot header. writes.push( options.isrSet( options.isrHtmlKey(options.cleanPathname), diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 4d547fc8e..3bade34a8 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -421,6 +421,8 @@ export function prefetchRscResponse( if (response.ok) { entry.snapshot = { ...(await snapshotRscResponse(response)), + // Prefetch compatibility is defined by the slot context at fetch + // time, not by whatever header a reused response happens to carry. mountedSlotsHeader, }; } else { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 8c54aa88d..e5484e21e 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -239,7 +239,8 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(" ") + .split(/[ +]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -2408,7 +2409,8 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(" ") + .split(/[ +]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -4583,7 +4585,8 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(" ") + .split(/[ +]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -6753,7 +6756,8 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(" ") + .split(/[ +]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -8955,7 +8959,8 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(" ") + .split(/[ +]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -11131,7 +11136,8 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(" ") + .split(/[ +]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), From 203a430e92a352a7ca552f9eb2b887f43cda0a2f Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:35:27 +1000 Subject: [PATCH 3/7] Fix generated mounted-slot regex escaping --- packages/vinext/src/entries/app-rsc-entry.ts | 2 +- .../__snapshots__/entry-templates.test.ts.snap | 18 ++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index ffbb530f4..4508e67e7 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -547,7 +547,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[ \t\r\n]+/) + .split(/[\\t\\r\\n ]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index e5484e21e..577edfa56 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -239,8 +239,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[ -]+/) + .split(/[\\t\\r\\n ]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -2409,8 +2408,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[ -]+/) + .split(/[\\t\\r\\n ]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -4585,8 +4583,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[ -]+/) + .split(/[\\t\\r\\n ]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -6756,8 +6753,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[ -]+/) + .split(/[\\t\\r\\n ]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -8959,8 +8955,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[ -]+/) + .split(/[\\t\\r\\n ]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -11136,8 +11131,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[ -]+/) + .split(/[\\t\\r\\n ]+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), From 7304bef1ad467f155e8a31ead67a077fef6592f8 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:48:12 +1000 Subject: [PATCH 4/7] Address review feedback on slot normalization and cache comments Align generated entry's __normalizeMountedSlotsHeader regex with the typed module in app-elements.ts (both now use /\s+/). Clarify the HTML vs RSC cache asymmetry comment in stale regen path. --- packages/vinext/src/entries/app-rsc-entry.ts | 2 +- packages/vinext/src/server/app-page-cache.ts | 6 ++++-- tests/__snapshots__/entry-templates.test.ts.snap | 12 ++++++------ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 4508e67e7..a9c0ffcca 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -547,7 +547,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[\\t\\r\\n ]+/) + .split(/\\s+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index 0fc03168d..c4dbb9538 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -172,8 +172,10 @@ export async function readAppPageCacheResponse( ]; if (!options.isRscRequest) { - // HTML remains canonical across slot-state variants; only RSC cache - // entries fan out by mounted-slot header. + // HTML cache is slot-state-independent (canonical), so only refresh it + // during HTML-triggered regens. RSC-triggered regens only update the + // requesting client's RSC slot variant; a stale HTML cache entry will + // be regenerated independently by the next full-page HTML request. writes.push( options.isrSet( options.isrHtmlKey(options.cleanPathname), diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 577edfa56..26219b8f0 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -239,7 +239,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[\\t\\r\\n ]+/) + .split(/\\s+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -2408,7 +2408,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[\\t\\r\\n ]+/) + .split(/\\s+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -4583,7 +4583,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[\\t\\r\\n ]+/) + .split(/\\s+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -6753,7 +6753,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[\\t\\r\\n ]+/) + .split(/\\s+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -8955,7 +8955,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[\\t\\r\\n ]+/) + .split(/\\s+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), @@ -11131,7 +11131,7 @@ function __normalizeMountedSlotsHeader(raw) { const normalized = Array.from( new Set( raw - .split(/[\\t\\r\\n ]+/) + .split(/\\s+/) .map((slotId) => slotId.trim()) .filter(Boolean), ), From 70720361572c5b925b5e4ddc82b40e26642d1d57 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:32:21 +1000 Subject: [PATCH 5/7] Remove redundant .map(trim) and clarify regen slot context split(/\s+/) never produces tokens with leading/trailing whitespace, so .map(trim) was a no-op in both normalizeMountedSlotsHeader and its generated counterpart. Also add a comment clarifying that background regen intentionally inherits the triggering request's slot context. --- packages/vinext/src/entries/app-rsc-entry.ts | 4 +++- packages/vinext/src/server/app-elements.ts | 9 +------ .../entry-templates.test.ts.snap | 24 ++++++++++++++----- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index a9c0ffcca..a083a1260 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -548,7 +548,6 @@ function __normalizeMountedSlotsHeader(raw) { new Set( raw .split(/\\s+/) - .map((slotId) => slotId.trim()) .filter(Boolean), ), ).sort().join(" "); @@ -2176,6 +2175,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + // Slot context (X-Vinext-Mounted-Slots) is inherited from the + // triggering request so the regen result is cached under the + // correct slot-variant key. const __revalElement = await buildPageElements( route, params, diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 9c61f507d..915964c29 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -22,14 +22,7 @@ export function normalizeMountedSlotsHeader(header: string | null | undefined): return null; } - const slotIds = Array.from( - new Set( - header - .split(/\s+/) - .map((slotId) => slotId.trim()) - .filter(Boolean), - ), - ).sort(); + const slotIds = Array.from(new Set(header.split(/\s+/).filter(Boolean))).sort(); return slotIds.length > 0 ? slotIds.join(" ") : null; } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 26219b8f0..8785b4536 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -240,7 +240,6 @@ function __normalizeMountedSlotsHeader(raw) { new Set( raw .split(/\\s+/) - .map((slotId) => slotId.trim()) .filter(Boolean), ), ).sort().join(" "); @@ -1870,6 +1869,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + // Slot context (X-Vinext-Mounted-Slots) is inherited from the + // triggering request so the regen result is cached under the + // correct slot-variant key. const __revalElement = await buildPageElements( route, params, @@ -2409,7 +2411,6 @@ function __normalizeMountedSlotsHeader(raw) { new Set( raw .split(/\\s+/) - .map((slotId) => slotId.trim()) .filter(Boolean), ), ).sort().join(" "); @@ -4045,6 +4046,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + // Slot context (X-Vinext-Mounted-Slots) is inherited from the + // triggering request so the regen result is cached under the + // correct slot-variant key. const __revalElement = await buildPageElements( route, params, @@ -4584,7 +4588,6 @@ function __normalizeMountedSlotsHeader(raw) { new Set( raw .split(/\\s+/) - .map((slotId) => slotId.trim()) .filter(Boolean), ), ).sort().join(" "); @@ -6215,6 +6218,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + // Slot context (X-Vinext-Mounted-Slots) is inherited from the + // triggering request so the regen result is cached under the + // correct slot-variant key. const __revalElement = await buildPageElements( route, params, @@ -6754,7 +6760,6 @@ function __normalizeMountedSlotsHeader(raw) { new Set( raw .split(/\\s+/) - .map((slotId) => slotId.trim()) .filter(Boolean), ), ).sort().join(" "); @@ -8417,6 +8422,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + // Slot context (X-Vinext-Mounted-Slots) is inherited from the + // triggering request so the regen result is cached under the + // correct slot-variant key. const __revalElement = await buildPageElements( route, params, @@ -8956,7 +8964,6 @@ function __normalizeMountedSlotsHeader(raw) { new Set( raw .split(/\\s+/) - .map((slotId) => slotId.trim()) .filter(Boolean), ), ).sort().join(" "); @@ -10593,6 +10600,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + // Slot context (X-Vinext-Mounted-Slots) is inherited from the + // triggering request so the regen result is cached under the + // correct slot-variant key. const __revalElement = await buildPageElements( route, params, @@ -11132,7 +11142,6 @@ function __normalizeMountedSlotsHeader(raw) { new Set( raw .split(/\\s+/) - .map((slotId) => slotId.trim()) .filter(Boolean), ), ).sort().join(" "); @@ -13129,6 +13138,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); + // Slot context (X-Vinext-Mounted-Slots) is inherited from the + // triggering request so the regen result is cached under the + // correct slot-variant key. const __revalElement = await buildPageElements( route, params, From fd44d3935c70845cef7f0d8ec1fdcaa07f422290 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:38:24 +1000 Subject: [PATCH 6/7] Clarify regen dedup and prefetch slot-mismatch tradeoffs Add comments explaining that pathname-only regen dedup is intentional (avoids concurrent regen storms across slot variants) and that consumePrefetchResponse unconditionally removes the entry before the slot-mismatch check (simplest correct behavior, prefetch is wasted on mismatch). --- packages/vinext/src/server/app-page-cache.ts | 3 +++ packages/vinext/src/shims/navigation.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index c4dbb9538..a9c8a2d6e 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -160,6 +160,9 @@ export async function readAppPageCacheResponse( // Preserve the legacy behavior from the inline generator: stale entries // still trigger background regeneration even if this request cannot use // the stale payload and will fall through to a fresh render. + // Dedup key is pathname-only: if multiple slot variants are stale + // concurrently, only one regen runs. Other variants refresh on + // their next STALE read. options.scheduleBackgroundRegeneration(options.cleanPathname, async () => { const revalidatedPage = await options.renderFreshPageForCache(); const writes = [ diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 3bade34a8..60ade1c9d 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -466,6 +466,8 @@ export function consumePrefetchResponse( if (entry.snapshot) { if ((entry.snapshot.mountedSlotsHeader ?? null) !== mountedSlotsHeader) { + // Entry was already removed above. Slot mismatch means the prefetch + // used stale slot context and cannot be safely reused. return null; } if (Date.now() - entry.timestamp >= PREFETCH_CACHE_TTL) { From 3ef5218e56033fcc39b507843a5e4c6cb402f09d Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:13:17 +1000 Subject: [PATCH 7/7] Document storePrefetchResponse slot-awareness gap Add JSDoc note that storePrefetchResponse is slot-unaware: the snapshot's mountedSlotsHeader comes from the response headers rather than the caller, so consumePrefetchResponse may reject the entry on slot context mismatch. --- packages/vinext/src/shims/navigation.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 60ade1c9d..9e1adf04b 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -331,8 +331,11 @@ function evictPrefetchCacheIfNeeded(): void { * (the caller falls back to a fresh fetch, which is acceptable). * * Prefer prefetchRscResponse() for new call-sites — it handles the full - * prefetch lifecycle including dedup. storePrefetchResponse() is kept for - * backward compatibility and test helpers. + * prefetch lifecycle including dedup and explicit slot context. + * storePrefetchResponse() is kept for backward compatibility and test + * helpers. It is slot-unaware: the snapshot's mountedSlotsHeader comes + * from the response headers, not the caller, so consumePrefetchResponse + * may reject the entry if the caller's slot context differs. * * NB: Caller is responsible for managing getPrefetchedUrls() — this * function only stores the response in the prefetch cache.