diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 28c53d09..a083a126 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -538,7 +538,21 @@ 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(/\\s+/) + .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 +925,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 +1038,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 +1801,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -2099,6 +2124,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 +2160,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 @@ -2146,12 +2175,17 @@ 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, cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -2208,6 +2242,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -2260,7 +2296,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 +2393,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 03e94908..1e7c3f8b 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; } @@ -411,9 +419,14 @@ function BrowserRoot({ if (browserRouterStateRef === stateRef) { browserRouterStateRef = null; } + setMountedSlotsHeader(null); }; }, [dispatchTreeState]); + useLayoutEffect(() => { + setMountedSlotsHeader(getMountedSlotIdsHeader(stateRef.current.elements)); + }, [treeState.elements]); + const committedTree = createElement( NavigationCommitSignal, { renderId: treeState.renderId }, @@ -737,7 +750,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 +792,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 +806,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 250b2eae..915964c2 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -17,6 +17,31 @@ 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+/).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 3520619c..a9c8a2d6 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, }); @@ -151,29 +160,43 @@ 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(); - - await Promise.all([ + const writes = [ options.isrSet( - options.isrHtmlKey(options.cleanPathname), - buildAppPageCacheValue(revalidatedPage.html, undefined, 200), - options.revalidateSeconds, - revalidatedPage.tags, - ), - options.isrSet( - options.isrRscKey(options.cleanPathname), + options.isrRscKey(options.cleanPathname, options.mountedSlotsHeader), buildAppPageCacheValue("", revalidatedPage.rscData, 200), options.revalidateSeconds, revalidatedPage.tags, ), - ]); + ]; + + if (!options.isRscRequest) { + // 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), + 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 +232,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 +295,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 6f2aa55a..e7b5753c 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 1ecc9481..ebb756ac 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 2683213f..c3d6415c 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 db950de9..7e685501 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 f4033bd2..9e1adf04 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; }; @@ -330,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. @@ -361,6 +365,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 +422,12 @@ export function prefetchRscResponse(rscUrl: string, fetchPromise: Promise { if (response.ok) { - entry.snapshot = await snapshotRscResponse(response); + 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 { prefetched.delete(rscUrl); cache.delete(rscUrl); @@ -436,7 +453,10 @@ export function prefetchRscResponse(rscUrl: string, fetchPromise: Promise= PREFETCH_CACHE_TTL) { return null; } @@ -464,6 +489,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 +510,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 +1162,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 6045cb88..8785b453 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -230,7 +230,21 @@ 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(/\\s+/) + .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 +684,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 +797,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 +1525,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -1793,6 +1818,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 +1854,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 @@ -1840,12 +1869,17 @@ 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, cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -1902,6 +1936,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -1954,7 +1990,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 +2087,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 +2401,21 @@ 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(/\\s+/) + .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 +2855,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 +2968,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 +3702,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -3933,6 +3995,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 +4031,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 @@ -3980,12 +4046,17 @@ 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, cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -4042,6 +4113,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -4094,7 +4167,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 +4264,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 +4578,21 @@ 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(/\\s+/) + .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 +5033,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 +5146,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 +5874,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -6068,6 +6167,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 +6203,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 @@ -6115,12 +6218,17 @@ 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, cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -6177,6 +6285,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -6229,7 +6339,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 +6436,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 +6750,21 @@ 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(/\\s+/) + .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 +7234,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 +7347,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 +8078,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -8235,6 +8371,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 +8407,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 @@ -8282,12 +8422,17 @@ 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, cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -8344,6 +8489,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -8396,7 +8543,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 +8640,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 +8954,21 @@ 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(/\\s+/) + .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 +9415,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 +9528,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 +10256,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -10376,6 +10549,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 +10585,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 @@ -10423,12 +10600,17 @@ 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, cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -10485,6 +10667,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -10537,7 +10721,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 +10818,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 +11132,21 @@ 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(/\\s+/) + .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 +11586,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 +11699,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 +12794,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, undefined, url.searchParams, + isRscRequest, + request, ); } else { const _actionRouteId = "route:" + cleanPathname; @@ -12877,6 +13087,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 +13123,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 @@ -12924,12 +13138,17 @@ 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, cleanPathname, undefined, new URLSearchParams(), + isRscRequest, + request, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -12986,6 +13205,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { cleanPathname, interceptOpts, interceptSearchParams, + isRscRequest, + request, ); }, cleanPathname, @@ -13038,7 +13259,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 +13356,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 dc0752cd..5387cb39 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 7746465b..6bbe2491 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 7b4b57e8..820c17dc 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 b7fd73b1..56e9edf1 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: {