diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 811be17db..137a6f9eb 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -55,6 +55,7 @@ const appPageBoundaryRenderPath = resolveEntryPath( "../server/app-page-boundary-render.js", import.meta.url, ); +const appElementsPath = resolveEntryPath("../server/app-elements.js", import.meta.url); const appPageRouteWiringPath = resolveEntryPath( "../server/app-page-route-wiring.js", import.meta.url, @@ -385,6 +386,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from ${JSON.stringify(appPageBoundaryRenderPath)}; +import { + APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, + createAppPayloadRouteId as __createAppPayloadRouteId, +} from ${JSON.stringify(appElementsPath)}; import { buildAppPageElements as __buildAppPageElements, createAppPageTreePath as __createAppPageTreePath, @@ -928,7 +933,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { // Compute the root layout tree path for this error payload using the @@ -937,6 +943,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); } return { + [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext, __route: _noExportRouteId, __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), @@ -1056,6 +1063,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: ${rootNotFoundVar ? rootNotFoundVar : "null"}, route, @@ -1399,6 +1407,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); // Middleware response headers and custom rewrite status are stored in @@ -1807,8 +1816,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { request, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -2285,6 +2295,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlotKey: intercept.slotKey, interceptPage: intercept.page, interceptParams: intercept.matchedParams, diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index 2e85310ce..1b3584b31 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -93,6 +93,7 @@ declare global { redirectDepth?: number, navigationKind?: "navigate" | "traverse" | "refresh", historyUpdateMode?: "push" | "replace", + previousNextUrlOverride?: string | null, ) => Promise) | undefined; diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1e7c3f8bc..23be98ec1 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -26,6 +26,8 @@ import { commitClientNavigationState, consumePrefetchResponse, createClientNavigationRenderSnapshot, + getCurrentNextUrl, + getCurrentInterceptionContext, getClientNavigationRenderContext, getPrefetchCache, getPrefetchedUrls, @@ -48,15 +50,20 @@ import { getVinextBrowserGlobal, } from "./app-browser-stream.js"; import { + createAppPayloadCacheKey, getMountedSlotIdsHeader, normalizeAppElements, readAppElementsMetadata, + resolveVisitedResponseInterceptionContext, type AppElements, type AppWireElements, } from "./app-elements.js"; import { + createHistoryStateWithPreviousNextUrl, createPendingNavigationCommit, + readHistoryStatePreviousNextUrl, resolveAndClassifyNavigationCommit, + resolveInterceptionContextFromPreviousNextUrl, resolvePendingNavigationCommitDisposition, routerReducer, type AppRouterAction, @@ -136,8 +143,7 @@ function stageClientParams(params: Record): void { // read latestClientParams, so a // server action fired during this window would get the pending (not yet // committed) params. This is acceptable because the commit effect fires - // synchronously in the same React commit phase, keeping the window - // vanishingly small. + // before hooks observe the new URL state, keeping the window vanishingly small. latestClientParams = params; replaceClientParamsWithoutNotify(params); } @@ -193,15 +199,21 @@ function createNavigationCommitEffect( href: string, historyUpdateMode: HistoryUpdateMode | undefined, params: Record, + previousNextUrl: string | null, ): () => void { return () => { const targetHref = new URL(href, window.location.origin).href; stageClientParams(params); + const preserveExistingState = historyUpdateMode === "replace"; + const historyState = createHistoryStateWithPreviousNextUrl( + preserveExistingState ? window.history.state : null, + previousNextUrl, + ); if (historyUpdateMode === "replace" && window.location.href !== targetHref) { - replaceHistoryStateWithoutNotify(null, "", href); + replaceHistoryStateWithoutNotify(historyState, "", href); } else if (historyUpdateMode === "push" && window.location.href !== targetHref) { - pushHistoryStateWithoutNotify(null, "", href); + pushHistoryStateWithoutNotify(historyState, "", href); } commitClientNavigationState(); @@ -220,16 +232,18 @@ function evictVisitedResponseCacheIfNeeded(): void { function getVisitedResponse( rscUrl: string, + interceptionContext: string | null, mountedSlotsHeader: string | null, navigationKind: NavigationKind, ): VisitedResponseCacheEntry | null { - const cached = visitedResponseCache.get(rscUrl); + const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext); + const cached = visitedResponseCache.get(cacheKey); if (!cached) { return null; } if ((cached.response.mountedSlotsHeader ?? null) !== mountedSlotsHeader) { - visitedResponseCache.delete(rscUrl); + visitedResponseCache.delete(cacheKey); return null; } @@ -240,41 +254,101 @@ function getVisitedResponse( if (navigationKind === "traverse") { const createdAt = cached.expiresAt - VISITED_RESPONSE_CACHE_TTL; if (Date.now() - createdAt >= MAX_TRAVERSAL_CACHE_TTL) { - visitedResponseCache.delete(rscUrl); + visitedResponseCache.delete(cacheKey); return null; } // LRU: promote to most-recently-used (delete + re-insert moves to end of Map) - visitedResponseCache.delete(rscUrl); - visitedResponseCache.set(rscUrl, cached); + visitedResponseCache.delete(cacheKey); + visitedResponseCache.set(cacheKey, cached); return cached; } if (cached.expiresAt > Date.now()) { // LRU: promote to most-recently-used - visitedResponseCache.delete(rscUrl); - visitedResponseCache.set(rscUrl, cached); + visitedResponseCache.delete(cacheKey); + visitedResponseCache.set(cacheKey, cached); return cached; } - visitedResponseCache.delete(rscUrl); + visitedResponseCache.delete(cacheKey); return null; } function storeVisitedResponseSnapshot( rscUrl: string, + interceptionContext: string | null, snapshot: CachedRscResponse, params: Record, ): void { - visitedResponseCache.delete(rscUrl); + const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext); + visitedResponseCache.delete(cacheKey); evictVisitedResponseCacheIfNeeded(); const now = Date.now(); - visitedResponseCache.set(rscUrl, { + visitedResponseCache.set(cacheKey, { params, expiresAt: now + VISITED_RESPONSE_CACHE_TTL, response: snapshot, }); } +type NavigationRequestState = { + interceptionContext: string | null; + previousNextUrl: string | null; +}; + +function getRequestState( + navigationKind: NavigationKind, + previousNextUrlOverride?: string | null, +): NavigationRequestState { + if (previousNextUrlOverride !== undefined) { + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + previousNextUrlOverride, + __basePath, + ), + previousNextUrl: previousNextUrlOverride, + }; + } + + switch (navigationKind) { + case "navigate": + return { + interceptionContext: getCurrentInterceptionContext(), + previousNextUrl: getCurrentNextUrl(), + }; + case "traverse": { + const previousNextUrl = readHistoryStatePreviousNextUrl(window.history.state); + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + previousNextUrl, + __basePath, + ), + previousNextUrl, + }; + } + case "refresh": + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + getBrowserRouterState().previousNextUrl, + __basePath, + ), + previousNextUrl: getBrowserRouterState().previousNextUrl, + }; + default: { + const _exhaustive: never = navigationKind; + throw new Error("[vinext] Unknown navigation kind: " + String(_exhaustive)); + } + } +} + +function createRscRequestHeaders(interceptionContext: string | null): Headers { + const headers = new Headers({ Accept: "text/x-component" }); + if (interceptionContext !== null) { + headers.set("X-Vinext-Interception-Context", interceptionContext); + } + return headers; +} + /** * Resolve all pending navigation commits with renderId <= the committed renderId. * Note: Map iteration handles concurrent deletion safely — entries are visited in @@ -358,6 +432,8 @@ async function commitSameUrlNavigatePayload( navigationSnapshot, pending.action.renderId, "navigate", + pending.interceptionContext, + pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, false, @@ -389,7 +465,9 @@ function BrowserRoot({ const initialMetadata = readAppElementsMetadata(resolvedElements); const [treeState, dispatchTreeState] = useReducer(routerReducer, { elements: resolvedElements, + interceptionContext: initialMetadata.interceptionContext, navigationSnapshot: initialNavigationSnapshot, + previousNextUrl: null, renderId: 0, rootLayoutTreePath: initialMetadata.rootLayoutTreePath, routeId: initialMetadata.routeId, @@ -427,6 +505,18 @@ function BrowserRoot({ setMountedSlotsHeader(getMountedSlotIdsHeader(stateRef.current.elements)); }, [treeState.elements]); + useLayoutEffect(() => { + if (treeState.renderId !== 0) { + return; + } + + replaceHistoryStateWithoutNotify( + createHistoryStateWithPreviousNextUrl(window.history.state, treeState.previousNextUrl), + "", + window.location.href, + ); + }, [treeState.previousNextUrl, treeState.renderId]); + const committedTree = createElement( NavigationCommitSignal, { renderId: treeState.renderId }, @@ -454,6 +544,8 @@ function dispatchBrowserTree( navigationSnapshot: ClientNavigationRenderSnapshot, renderId: number, actionType: "navigate" | "replace", + interceptionContext: string | null, + previousNextUrl: string | null, routeId: string, rootLayoutTreePath: string | null, useTransitionMode: boolean, @@ -463,7 +555,9 @@ function dispatchBrowserTree( const applyAction = () => dispatch({ elements, + interceptionContext, navigationSnapshot, + previousNextUrl, renderId, rootLayoutTreePath, routeId, @@ -482,7 +576,9 @@ async function renderNavigationPayload( navigationSnapshot: ClientNavigationRenderSnapshot, targetHref: string, navId: number, - prePaintEffect: (() => void) | null = null, + historyUpdateMode: HistoryUpdateMode | undefined, + params: Record, + previousNextUrl: string | null, useTransition = true, actionType: "navigate" | "replace" = "navigate", ): Promise { @@ -498,6 +594,7 @@ async function renderNavigationPayload( currentState, nextElements: payload, navigationSnapshot, + previousNextUrl, renderId, type: actionType, }); @@ -522,7 +619,10 @@ async function renderNavigationPayload( return; } - queuePrePaintNavigationEffect(renderId, prePaintEffect); + queuePrePaintNavigationEffect( + renderId, + createNavigationCommitEffect(targetHref, historyUpdateMode, params, pending.previousNextUrl), + ); activateNavigationSnapshot(); snapshotActivated = true; dispatchBrowserTree( @@ -530,6 +630,8 @@ async function renderNavigationPayload( navigationSnapshot, renderId, actionType, + pending.interceptionContext, + pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, useTransition, @@ -642,6 +744,8 @@ function registerServerActionCallback(): void { const temporaryReferences = createTemporaryReferenceSet(); const body = await encodeReply(args, { temporaryReferences }); + // Intentionally omit interception context for server action re-renders in + // this PR. Durable intercepted refresh/action parity belongs to PR 5. const fetchResponse = await fetch(toRscUrl(window.location.pathname + window.location.search), { method: "POST", headers: { "x-rsc-action": id }, @@ -707,6 +811,11 @@ async function main(): Promise { window.location.href, latestClientParams, ); + replaceHistoryStateWithoutNotify( + createHistoryStateWithPreviousNextUrl(window.history.state, null), + "", + window.location.href, + ); window.__VINEXT_RSC_ROOT__ = hydrateRoot( document, @@ -723,6 +832,7 @@ async function main(): Promise { redirectDepth = 0, navigationKind: NavigationKind = "navigate", historyUpdateMode?: HistoryUpdateMode, + previousNextUrlOverride?: string | null, ): Promise { if (redirectDepth > 10) { console.error( @@ -739,6 +849,9 @@ async function main(): Promise { try { const url = new URL(href, window.location.origin); const rscUrl = toRscUrl(url.pathname + url.search); + const requestState = getRequestState(navigationKind, previousNextUrlOverride); + const requestInterceptionContext = requestState.interceptionContext; + const requestPreviousNextUrl = requestState.previousNextUrl; // Use startTransition for same-route navigations (searchParam changes) // so React keeps the old UI visible during the transition. For cross-route // navigations (different pathname), use synchronous updates — React's @@ -752,7 +865,12 @@ async function main(): Promise { stripBasePath(window.location.pathname, __basePath); const elementsAtNavStart = getBrowserRouterState().elements; const mountedSlotsHeader = getMountedSlotIdsHeader(elementsAtNavStart); - const cachedRoute = getVisitedResponse(rscUrl, mountedSlotsHeader, navigationKind); + const cachedRoute = getVisitedResponse( + rscUrl, + requestInterceptionContext, + mountedSlotsHeader, + navigationKind, + ); if (cachedRoute) { // Check stale-navigation before and after createFromFetch. The pre-check // avoids wasted parse work; the post-check catches supersessions that @@ -781,7 +899,9 @@ async function main(): Promise { cachedNavigationSnapshot, href, navId, - createNavigationCommitEffect(href, historyUpdateMode, cachedParams), + historyUpdateMode, + cachedParams, + requestPreviousNextUrl, isSameRoute, ); } finally { @@ -798,7 +918,11 @@ async function main(): Promise { let navResponse: Response | undefined; let navResponseUrl: string | null = null; if (navigationKind !== "refresh") { - const prefetchedResponse = consumePrefetchResponse(rscUrl, mountedSlotsHeader); + const prefetchedResponse = consumePrefetchResponse( + rscUrl, + requestInterceptionContext, + mountedSlotsHeader, + ); if (prefetchedResponse) { navResponse = restoreRscResponse(prefetchedResponse, false); navResponseUrl = prefetchedResponse.url; @@ -806,12 +930,12 @@ async function main(): Promise { } if (!navResponse) { - const rscFetchHeaders: Record = { Accept: "text/x-component" }; + const requestHeaders = createRscRequestHeaders(requestInterceptionContext); if (mountedSlotsHeader) { - rscFetchHeaders["X-Vinext-Mounted-Slots"] = mountedSlotsHeader; + requestHeaders.set("X-Vinext-Mounted-Slots", mountedSlotsHeader); } navResponse = await fetch(rscUrl, { - headers: rscFetchHeaders, + headers: requestHeaders, credentials: "include", }); } @@ -823,7 +947,11 @@ async function main(): Promise { if (finalUrl.pathname !== requestedUrl.pathname) { const destinationPath = finalUrl.pathname.replace(/\.rsc$/, "") + finalUrl.search; - replaceHistoryStateWithoutNotify(null, "", destinationPath); + replaceHistoryStateWithoutNotify( + createHistoryStateWithPreviousNextUrl(null, requestPreviousNextUrl), + "", + destinationPath, + ); const navigate = window.__VINEXT_RSC_NAVIGATE__; if (!navigate) { @@ -834,7 +962,13 @@ async function main(): Promise { // The URL has already been updated via replaceHistoryStateWithoutNotify above, // so the recursive navigation should NOT push/replace again. Pass undefined // for historyUpdateMode to make the commit effect a no-op for history updates. - return navigate(destinationPath, redirectDepth + 1, navigationKind, undefined); + return navigate( + destinationPath, + redirectDepth + 1, + navigationKind, + undefined, + requestPreviousNextUrl, + ); } let navParams: Record = {}; @@ -869,7 +1003,9 @@ async function main(): Promise { navigationSnapshot, href, navId, - createNavigationCommitEffect(href, historyUpdateMode, navParams), + historyUpdateMode, + navParams, + requestPreviousNextUrl, isSameRoute, ); } finally { @@ -887,7 +1023,17 @@ async function main(): Promise { // If we stored it before and renderNavigationPayload threw, a future // back/forward navigation could replay a snapshot from a navigation that // never actually rendered successfully. - storeVisitedResponseSnapshot(rscUrl, responseSnapshot, navParams); + const resolvedElements = await rscPayload; + const metadata = readAppElementsMetadata(resolvedElements); + storeVisitedResponseSnapshot( + rscUrl, + resolveVisitedResponseInterceptionContext( + requestInterceptionContext, + metadata.interceptionContext, + ), + responseSnapshot, + navParams, + ); return; } catch (error) { // Only decrement counter if snapshot was activated but not yet committed. @@ -951,6 +1097,8 @@ async function main(): Promise { navigationSnapshot, pending.action.renderId, "replace", + pending.interceptionContext, + pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, false, diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index 672a569d0..5a1381482 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -1,9 +1,18 @@ import { mergeElements } from "../shims/slot.js"; +import { stripBasePath } from "../utils/base-path.js"; import { readAppElementsMetadata, type AppElements } from "./app-elements.js"; import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js"; +const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; + +type HistoryStateRecord = { + [key: string]: unknown; +}; + export type AppRouterState = { elements: AppElements; + interceptionContext: string | null; + previousNextUrl: string | null; renderId: number; navigationSnapshot: ClientNavigationRenderSnapshot; rootLayoutTreePath: string | null; @@ -12,7 +21,9 @@ export type AppRouterState = { export type AppRouterAction = { elements: AppElements; + interceptionContext: string | null; navigationSnapshot: ClientNavigationRenderSnapshot; + previousNextUrl: string | null; renderId: number; rootLayoutTreePath: string | null; routeId: string; @@ -21,6 +32,8 @@ export type AppRouterAction = { export type PendingNavigationCommit = { action: AppRouterAction; + interceptionContext: string | null; + previousNextUrl: string | null; rootLayoutTreePath: string | null; routeId: string; }; @@ -31,12 +44,58 @@ export type ClassifiedPendingNavigationCommit = { pending: PendingNavigationCommit; }; +function cloneHistoryState(state: unknown): HistoryStateRecord { + if (!state || typeof state !== "object") { + return {}; + } + + const nextState: HistoryStateRecord = {}; + for (const [key, value] of Object.entries(state)) { + nextState[key] = value; + } + return nextState; +} + +export function createHistoryStateWithPreviousNextUrl( + state: unknown, + previousNextUrl: string | null, +): HistoryStateRecord | null { + const nextState = cloneHistoryState(state); + + if (previousNextUrl === null) { + delete nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; + } else { + nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY] = previousNextUrl; + } + + return Object.keys(nextState).length > 0 ? nextState : null; +} + +export function readHistoryStatePreviousNextUrl(state: unknown): string | null { + const value = cloneHistoryState(state)[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; + return typeof value === "string" ? value : null; +} + +export function resolveInterceptionContextFromPreviousNextUrl( + previousNextUrl: string | null, + basePath: string = "", +): string | null { + if (previousNextUrl === null) { + return null; + } + + const parsedUrl = new URL(previousNextUrl, "http://localhost"); + return stripBasePath(parsedUrl.pathname, basePath); +} + export function routerReducer(state: AppRouterState, action: AppRouterAction): AppRouterState { switch (action.type) { case "navigate": return { elements: mergeElements(state.elements, action.elements), + interceptionContext: action.interceptionContext, navigationSnapshot: action.navigationSnapshot, + previousNextUrl: action.previousNextUrl, renderId: action.renderId, rootLayoutTreePath: action.rootLayoutTreePath, routeId: action.routeId, @@ -44,7 +103,9 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A case "replace": return { elements: action.elements, + interceptionContext: action.interceptionContext, navigationSnapshot: action.navigationSnapshot, + previousNextUrl: action.previousNextUrl, renderId: action.renderId, rootLayoutTreePath: action.rootLayoutTreePath, routeId: action.routeId, @@ -91,21 +152,27 @@ export async function createPendingNavigationCommit(options: { currentState: AppRouterState; nextElements: Promise; navigationSnapshot: ClientNavigationRenderSnapshot; + previousNextUrl?: string | null; renderId: number; type: "navigate" | "replace"; }): Promise { const elements = await options.nextElements; const metadata = readAppElementsMetadata(elements); + const previousNextUrl = options.previousNextUrl ?? options.currentState.previousNextUrl; return { action: { elements, + interceptionContext: metadata.interceptionContext, navigationSnapshot: options.navigationSnapshot, + previousNextUrl, renderId: options.renderId, rootLayoutTreePath: metadata.rootLayoutTreePath, routeId: metadata.routeId, type: options.type, }, + interceptionContext: metadata.interceptionContext, + previousNextUrl, rootLayoutTreePath: metadata.rootLayoutTreePath, routeId: metadata.routeId, }; @@ -116,6 +183,7 @@ export async function resolveAndClassifyNavigationCommit(options: { currentState: AppRouterState; navigationSnapshot: ClientNavigationRenderSnapshot; nextElements: Promise; + previousNextUrl?: string | null; renderId: number; startedNavigationId: number; type: "navigate" | "replace"; @@ -124,6 +192,7 @@ export async function resolveAndClassifyNavigationCommit(options: { currentState: options.currentState, nextElements: options.nextElements, navigationSnapshot: options.navigationSnapshot, + previousNextUrl: options.previousNextUrl, renderId: options.renderId, type: options.type, }); diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 915964c29..00a0b9b99 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -1,5 +1,8 @@ import type { ReactNode } from "react"; +const APP_INTERCEPTION_SEPARATOR = "\0"; + +export const APP_INTERCEPTION_CONTEXT_KEY = "__interceptionContext"; export const APP_ROUTE_KEY = "__route"; export const APP_ROOT_LAYOUT_KEY = "__rootLayout"; export const APP_UNMATCHED_SLOT_WIRE_VALUE = "__VINEXT_UNMATCHED_SLOT__"; @@ -13,6 +16,7 @@ export type AppElements = Readonly>; export type AppWireElements = Readonly>; export type AppElementsMetadata = { + interceptionContext: string | null; routeId: string; rootLayoutTreePath: string | null; }; @@ -42,6 +46,40 @@ export function getMountedSlotIdsHeader(elements: AppElements): string | null { return normalizeMountedSlotsHeader(getMountedSlotIds(elements).join(" ")); } +function appendInterceptionContext(identity: string, interceptionContext: string | null): string { + return interceptionContext === null + ? identity + : `${identity}${APP_INTERCEPTION_SEPARATOR}${interceptionContext}`; +} + +export function createAppPayloadRouteId( + routePath: string, + interceptionContext: string | null, +): string { + return appendInterceptionContext(`route:${routePath}`, interceptionContext); +} + +export function createAppPayloadPageId( + routePath: string, + interceptionContext: string | null, +): string { + return appendInterceptionContext(`page:${routePath}`, interceptionContext); +} + +export function createAppPayloadCacheKey( + rscUrl: string, + interceptionContext: string | null, +): string { + return appendInterceptionContext(rscUrl, interceptionContext); +} + +export function resolveVisitedResponseInterceptionContext( + requestInterceptionContext: string | null, + payloadInterceptionContext: string | null, +): string | null { + return payloadInterceptionContext ?? requestInterceptionContext; +} + export function normalizeAppElements(elements: AppWireElements): AppElements { let needsNormalization = false; for (const [key, value] of Object.entries(elements)) { @@ -70,6 +108,15 @@ export function readAppElementsMetadata(elements: AppElements): AppElementsMetad throw new Error("[vinext] Missing __route string in App Router payload"); } + const interceptionContext = elements[APP_INTERCEPTION_CONTEXT_KEY]; + if ( + interceptionContext !== undefined && + interceptionContext !== null && + typeof interceptionContext !== "string" + ) { + throw new Error("[vinext] Invalid __interceptionContext in App Router payload"); + } + const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY]; if (rootLayoutTreePath === undefined) { throw new Error("[vinext] Missing __rootLayout key in App Router payload"); @@ -79,6 +126,7 @@ export function readAppElementsMetadata(elements: AppElements): AppElementsMetad } return { + interceptionContext: interceptionContext ?? null, routeId, rootLayoutTreePath, }; diff --git a/packages/vinext/src/server/app-page-boundary-render.ts b/packages/vinext/src/server/app-page-boundary-render.ts index fea9ea26c..4fe5ad8e9 100644 --- a/packages/vinext/src/server/app-page-boundary-render.ts +++ b/packages/vinext/src/server/app-page-boundary-render.ts @@ -24,7 +24,13 @@ import { renderAppPageHtmlResponse, type AppPageSsrHandler, } from "./app-page-stream.js"; -import { APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, type AppElements } from "./app-elements.js"; +import { + APP_INTERCEPTION_CONTEXT_KEY, + APP_ROOT_LAYOUT_KEY, + APP_ROUTE_KEY, + createAppPayloadRouteId, + type AppElements, +} from "./app-elements.js"; import { createAppPageLayoutEntries } from "./app-page-route-wiring.js"; // oxlint-disable-next-line @typescript-eslint/no-explicit-any @@ -234,9 +240,10 @@ function resolveAppPageBoundaryRootLayoutTreePath function createAppPageBoundaryRscPayload( options: AppPageBoundaryRscPayloadOptions, ): AppElements { - const routeId = `route:${options.pathname}`; + const routeId = createAppPayloadRouteId(options.pathname, null); return { + [APP_INTERCEPTION_CONTEXT_KEY]: null, [APP_ROUTE_KEY]: routeId, [APP_ROOT_LAYOUT_KEY]: resolveAppPageBoundaryRootLayoutTreePath(options.route), [routeId]: options.element, diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index c3d6415cf..93999bea5 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -1,8 +1,11 @@ import { Suspense, type ComponentType, type ReactNode } from "react"; import { + APP_INTERCEPTION_CONTEXT_KEY, APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, + createAppPayloadPageId, + createAppPayloadRouteId, type AppElements, } from "./app-elements.js"; import { ErrorBoundary, NotFoundBoundary } from "../shims/error-boundary.js"; @@ -107,6 +110,7 @@ export type BuildAppPageElementsOptions< TModule extends AppPageModule = AppPageModule, TErrorModule extends AppPageErrorModule = AppPageErrorModule, > = BuildAppPageRouteElementOptions & { + interceptionContext?: string | null; isRscRequest?: boolean; mountedSlotIds?: ReadonlySet | null; routePath: string; @@ -301,8 +305,9 @@ export function buildAppPageElements< TErrorModule extends AppPageErrorModule, >(options: BuildAppPageElementsOptions): AppElements { const elements: Record = {}; - const routeId = `route:${options.routePath}`; - const pageId = `page:${options.routePath}`; + const interceptionContext = options.interceptionContext ?? null; + const routeId = createAppPayloadRouteId(options.routePath, interceptionContext); + const pageId = createAppPayloadPageId(options.routePath, interceptionContext); const layoutEntries = createAppPageLayoutEntries(options.route); const templateEntries = createAppPageTemplateEntries(options.route); const layoutEntriesByTreePosition = new Map>(); @@ -378,6 +383,7 @@ export function buildAppPageElements< } elements[APP_ROUTE_KEY] = routeId; + elements[APP_INTERCEPTION_CONTEXT_KEY] = interceptionContext; elements[APP_ROOT_LAYOUT_KEY] = rootLayoutTreePath; elements[pageId] = renderAfterAppDependencies(options.element, pageDependencies); diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 7e6855017..50c03cf36 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -21,12 +21,14 @@ import React, { // Import shared RSC prefetch utilities from navigation shim (relative path // so this resolves both via the Vite plugin and in direct vitest imports) import { + getCurrentInterceptionContext, toRscUrl, getPrefetchedUrls, getMountedSlotsHeader, navigateClientSide, prefetchRscResponse, } from "./navigation.js"; +import { createAppPayloadCacheKey } from "../server/app-elements.js"; import { isDangerousScheme } from "./url-safety.js"; import { resolveRelativeHref, @@ -125,21 +127,26 @@ function prefetchUrl(href: string): void { const fullHref = toBrowserNavigationHref(prefetchHref, window.location.href, __basePath); - // Don't prefetch the same URL twice (keyed by rscUrl so the browser - // entry can clear the key when a cache entry is consumed) + // Distinguish the same visible URL when it is prefetched from different + // interception sources such as /feed vs /gallery. const rscUrl = toRscUrl(fullHref); + const interceptionContext = getCurrentInterceptionContext(); + const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext); const prefetched = getPrefetchedUrls(); - if (prefetched.has(rscUrl)) return; - prefetched.add(rscUrl); + if (prefetched.has(cacheKey)) return; + prefetched.add(cacheKey); const schedule = window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100)); schedule(() => { if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") { const mountedSlotsHeader = getMountedSlotsHeader(); - const headers: Record = { Accept: "text/x-component" }; + const headers = new Headers({ Accept: "text/x-component" }); if (mountedSlotsHeader) { - headers["X-Vinext-Mounted-Slots"] = mountedSlotsHeader; + headers.set("X-Vinext-Mounted-Slots", mountedSlotsHeader); + } + if (interceptionContext !== null) { + headers.set("X-Vinext-Interception-Context", interceptionContext); } prefetchRscResponse( rscUrl, @@ -150,6 +157,7 @@ function prefetchUrl(href: string): void { // @ts-expect-error — purpose is a valid fetch option in some browsers purpose: "prefetch", }), + interceptionContext, mountedSlotsHeader, ); } else if ((window.__NEXT_DATA__ as VinextNextData | undefined)?.__vinext?.pageModuleUrl) { diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 9e1adf04b..371692d43 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -12,6 +12,7 @@ // bindings are just `undefined` on the namespace object and we can guard at runtime. import * as React from "react"; import { notifyAppRouterTransitionStart } from "../client/instrumentation-client-state.js"; +import { createAppPayloadCacheKey } from "../server/app-elements.js"; import { toBrowserNavigationHref, toSameOriginAppPath } from "./url-utils.js"; import { stripBasePath } from "../utils/base-path.js"; import { ReadonlyURLSearchParams } from "./readonly-url-search-params.js"; @@ -243,6 +244,8 @@ export const MAX_PREFETCH_CACHE_SIZE = 50; /** TTL for prefetch cache entries in ms (matches Next.js static prefetch TTL). */ export const PREFETCH_CACHE_TTL = 30_000; +export const VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY = "__vinext_interceptionContext"; + /** A buffered RSC response stored as an ArrayBuffer for replay. */ export type CachedRscResponse = { buffer: ArrayBuffer; @@ -274,6 +277,34 @@ export function toRscUrl(href: string): string { return normalizedPath + ".rsc" + query; } +export function readHistoryStateInterceptionContext(state: unknown): string | null { + if (!state || typeof state !== "object") { + return null; + } + + const value = Reflect.get(state, VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY); + return typeof value === "string" ? value : null; +} + +export function getCurrentInterceptionContext(): string | null { + if (isServer) { + return null; + } + + return ( + readHistoryStateInterceptionContext(window.history.state) ?? + stripBasePath(window.location.pathname, __basePath) + ); +} + +export function getCurrentNextUrl(): string { + if (isServer) { + return "/"; + } + + return window.location.pathname + window.location.search; +} + /** Get or create the shared in-memory RSC prefetch cache on window. */ export function getPrefetchCache(): Map { if (isServer) return new Map(); @@ -285,7 +316,7 @@ export function getPrefetchCache(): Map { /** * Get or create the shared set of already-prefetched RSC URLs on window. - * Keyed by rscUrl so that the browser entry can clear entries when consumed. + * Keyed by interception-aware cache key so distinct source routes do not alias. */ export function getPrefetchedUrls(): Set { if (isServer) return new Set(); @@ -340,7 +371,12 @@ function evictPrefetchCacheIfNeeded(): void { * NB: Caller is responsible for managing getPrefetchedUrls() — this * function only stores the response in the prefetch cache. */ -export function storePrefetchResponse(rscUrl: string, response: Response): void { +export function storePrefetchResponse( + rscUrl: string, + response: Response, + interceptionContext: string | null = null, +): void { + const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext); evictPrefetchCacheIfNeeded(); const entry: PrefetchCacheEntry = { timestamp: Date.now() }; entry.pending = snapshotRscResponse(response) @@ -348,12 +384,12 @@ export function storePrefetchResponse(rscUrl: string, response: Response): void entry.snapshot = snapshot; }) .catch(() => { - getPrefetchCache().delete(rscUrl); + getPrefetchCache().delete(cacheKey); }) .finally(() => { entry.pending = undefined; }); - getPrefetchCache().set(rscUrl, entry); + getPrefetchCache().set(cacheKey, entry); } /** @@ -411,8 +447,10 @@ export function restoreRscResponse(cached: CachedRscResponse, copy = true): Resp export function prefetchRscResponse( rscUrl: string, fetchPromise: Promise, + interceptionContext: string | null = null, mountedSlotsHeader: string | null = null, ): void { + const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext); const cache = getPrefetchCache(); const prefetched = getPrefetchedUrls(); const now = Date.now(); @@ -429,13 +467,13 @@ export function prefetchRscResponse( mountedSlotsHeader, }; } else { - prefetched.delete(rscUrl); - cache.delete(rscUrl); + prefetched.delete(cacheKey); + cache.delete(cacheKey); } }) .catch(() => { - prefetched.delete(rscUrl); - cache.delete(rscUrl); + prefetched.delete(cacheKey); + cache.delete(cacheKey); }) .finally(() => { entry.pending = undefined; @@ -444,7 +482,7 @@ export function prefetchRscResponse( // Insert the new entry before evicting. FIFO evicts from the front of the // Map (oldest insertion order), so the just-appended entry is safe — only // entries inserted before it are candidates for removal. - cache.set(rscUrl, entry); + cache.set(cacheKey, entry); evictPrefetchCacheIfNeeded(); } @@ -455,17 +493,19 @@ export function prefetchRscResponse( */ export function consumePrefetchResponse( rscUrl: string, + interceptionContext: string | null = null, mountedSlotsHeader: string | null = null, ): CachedRscResponse | null { + const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext); const cache = getPrefetchCache(); - const entry = cache.get(rscUrl); + const entry = cache.get(cacheKey); if (!entry) return null; // Don't consume pending entries — let the navigation fetch independently. if (entry.pending) return null; - cache.delete(rscUrl); - getPrefetchedUrls().delete(rscUrl); + cache.delete(cacheKey); + getPrefetchedUrls().delete(cacheKey); if (entry.snapshot) { if ((entry.snapshot.mountedSlotsHeader ?? null) !== mountedSlotsHeader) { @@ -1159,13 +1199,18 @@ const _appRouter = { // prefetchRscResponse only manages the cache Map, not the URL set. const fullHref = toBrowserNavigationHref(href, window.location.href, __basePath); const rscUrl = toRscUrl(fullHref); + const interceptionContext = getCurrentInterceptionContext(); + const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext); const prefetched = getPrefetchedUrls(); - if (prefetched.has(rscUrl)) return; - prefetched.add(rscUrl); + if (prefetched.has(cacheKey)) return; + prefetched.add(cacheKey); const mountedSlotsHeader = getMountedSlotsHeader(); - const headers: Record = { Accept: "text/x-component" }; + const headers = new Headers({ Accept: "text/x-component" }); if (mountedSlotsHeader) { - headers["X-Vinext-Mounted-Slots"] = mountedSlotsHeader; + headers.set("X-Vinext-Mounted-Slots", mountedSlotsHeader); + } + if (interceptionContext !== null) { + headers.set("X-Vinext-Interception-Context", interceptionContext); } prefetchRscResponse( rscUrl, @@ -1174,6 +1219,7 @@ const _appRouter = { credentials: "include", priority: "low" as RequestInit["priority"], }), + interceptionContext, mountedSlotsHeader, ); }, diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts index 1a1d6e4ac..3f72d386b 100644 --- a/packages/vinext/src/shims/next-shims.d.ts +++ b/packages/vinext/src/shims/next-shims.d.ts @@ -121,7 +121,11 @@ declare module "next/navigation" { export function toRscUrl(href: string): string; export function getPrefetchCache(): Map; export function getPrefetchedUrls(): Set; - export function storePrefetchResponse(rscUrl: string, response: Response): void; + export function storePrefetchResponse( + rscUrl: string, + response: Response, + interceptionContext?: string | null, + ): void; } declare module "next/image" { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 2f1884666..7d3a135a2 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -77,6 +77,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, + createAppPayloadRouteId as __createAppPayloadRouteId, +} from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, createAppPageTreePath as __createAppPageTreePath, @@ -687,7 +691,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { // Compute the root layout tree path for this error payload using the @@ -696,6 +701,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); } return { + [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext, __route: _noExportRouteId, __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), @@ -815,6 +821,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -1265,6 +1272,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); // Middleware response headers and custom rewrite status are stored in @@ -1531,8 +1539,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { request, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -1979,6 +1988,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlotKey: intercept.slotKey, interceptPage: intercept.page, interceptParams: intercept.matchedParams, @@ -2250,6 +2260,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, + createAppPayloadRouteId as __createAppPayloadRouteId, +} from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, createAppPageTreePath as __createAppPageTreePath, @@ -2860,7 +2874,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { // Compute the root layout tree path for this error payload using the @@ -2869,6 +2884,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); } return { + [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext, __route: _noExportRouteId, __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), @@ -2988,6 +3004,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -3444,6 +3461,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); // Middleware response headers and custom rewrite status are stored in @@ -3710,8 +3728,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { request, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -4158,6 +4177,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlotKey: intercept.slotKey, interceptPage: intercept.page, interceptParams: intercept.matchedParams, @@ -4429,6 +4449,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, + createAppPayloadRouteId as __createAppPayloadRouteId, +} from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, createAppPageTreePath as __createAppPageTreePath, @@ -5040,7 +5064,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { // Compute the root layout tree path for this error payload using the @@ -5049,6 +5074,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); } return { + [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext, __route: _noExportRouteId, __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), @@ -5168,6 +5194,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -5618,6 +5645,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); // Middleware response headers and custom rewrite status are stored in @@ -5884,8 +5912,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { request, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -6332,6 +6361,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlotKey: intercept.slotKey, interceptPage: intercept.page, interceptParams: intercept.matchedParams, @@ -6603,6 +6633,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, + createAppPayloadRouteId as __createAppPayloadRouteId, +} from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, createAppPageTreePath as __createAppPageTreePath, @@ -7243,7 +7277,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { // Compute the root layout tree path for this error payload using the @@ -7252,6 +7287,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); } return { + [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext, __route: _noExportRouteId, __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), @@ -7371,6 +7407,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -7824,6 +7861,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); // Middleware response headers and custom rewrite status are stored in @@ -8090,8 +8128,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { request, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -8538,6 +8577,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlotKey: intercept.slotKey, interceptPage: intercept.page, interceptParams: intercept.matchedParams, @@ -8809,6 +8849,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, + createAppPayloadRouteId as __createAppPayloadRouteId, +} from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, createAppPageTreePath as __createAppPageTreePath, @@ -9426,7 +9470,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { // Compute the root layout tree path for this error payload using the @@ -9435,6 +9480,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); } return { + [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext, __route: _noExportRouteId, __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), @@ -9554,6 +9600,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -10004,6 +10051,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); // Middleware response headers and custom rewrite status are stored in @@ -10270,8 +10318,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { request, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -10718,6 +10767,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlotKey: intercept.slotKey, interceptPage: intercept.page, interceptParams: intercept.matchedParams, @@ -10989,6 +11039,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, + createAppPayloadRouteId as __createAppPayloadRouteId, +} from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, createAppPageTreePath as __createAppPageTreePath, @@ -11599,7 +11653,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { // Compute the root layout tree path for this error payload using the @@ -11608,6 +11663,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); } return { + [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext, __route: _noExportRouteId, __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), @@ -11727,6 +11783,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -12406,6 +12463,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); // Middleware response headers and custom rewrite status are stored in @@ -12810,8 +12868,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { request, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -13258,6 +13317,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlotKey: intercept.slotKey, interceptPage: intercept.page, interceptParams: intercept.matchedParams, diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 5387cb393..39ff4dabb 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -1,6 +1,7 @@ import React from "react"; import { describe, expect, it } from "vite-plus/test"; import { + APP_INTERCEPTION_CONTEXT_KEY, APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, UNMATCHED_SLOT, @@ -11,8 +12,11 @@ import { } from "../packages/vinext/src/server/app-elements.js"; import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js"; import { + createHistoryStateWithPreviousNextUrl, createPendingNavigationCommit, + readHistoryStatePreviousNextUrl, resolveAndClassifyNavigationCommit, + resolveInterceptionContextFromPreviousNextUrl, routerReducer, resolvePendingNavigationCommitDisposition, shouldHardNavigate, @@ -22,9 +26,11 @@ import { function createResolvedElements( routeId: string, rootLayoutTreePath: string | null, + interceptionContext: string | null = null, extraEntries: Record = {}, ) { return normalizeAppElements({ + [APP_INTERCEPTION_CONTEXT_KEY]: interceptionContext, [APP_ROUTE_KEY]: routeId, [APP_ROOT_LAYOUT_KEY]: rootLayoutTreePath, ...extraEntries, @@ -36,6 +42,8 @@ function createState(overrides: Partial = {}): AppRouterState { elements: createResolvedElements("route:/initial", "/"), navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}), renderId: 0, + interceptionContext: null, + previousNextUrl: null, rootLayoutTreePath: "/", routeId: "route:/initial", ...overrides, @@ -54,10 +62,10 @@ describe("app browser entry state helpers", () => { }); it("merges elements on navigate", async () => { - const previousElements = createResolvedElements("route:/initial", "/", { + const previousElements = createResolvedElements("route:/initial", "/", null, { "layout:/": React.createElement("div", null, "layout"), }); - const nextElements = createResolvedElements("route:/next", "/", { + const nextElements = createResolvedElements("route:/next", "/", null, { "page:/next": React.createElement("main", null, "next"), }); @@ -67,7 +75,9 @@ describe("app browser entry state helpers", () => { }), { elements: nextElements, + interceptionContext: null, navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, renderId: 1, rootLayoutTreePath: "/", routeId: "route:/next", @@ -76,6 +86,8 @@ describe("app browser entry state helpers", () => { ); expect(nextState.routeId).toBe("route:/next"); + expect(nextState.interceptionContext).toBeNull(); + expect(nextState.previousNextUrl).toBeNull(); expect(nextState.rootLayoutTreePath).toBe("/"); expect(nextState.elements).toMatchObject({ "layout:/": expect.anything(), @@ -84,13 +96,15 @@ describe("app browser entry state helpers", () => { }); it("replaces elements on replace", () => { - const nextElements = createResolvedElements("route:/next", "/", { + const nextElements = createResolvedElements("route:/next", "/", null, { "page:/next": React.createElement("main", null, "next"), }); const nextState = routerReducer(createState(), { elements: nextElements, + interceptionContext: null, navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, renderId: 1, rootLayoutTreePath: "/", routeId: "route:/next", @@ -98,11 +112,34 @@ describe("app browser entry state helpers", () => { }); expect(nextState.elements).toBe(nextElements); + expect(nextState.interceptionContext).toBeNull(); + expect(nextState.previousNextUrl).toBeNull(); expect(nextState.elements).toMatchObject({ "page:/next": expect.anything(), }); }); + it("carries interception context through pending navigation commits", async () => { + const pending = await createPendingNavigationCommit({ + currentState: createState(), + nextElements: Promise.resolve( + createResolvedElements("route:/photos/42\0/feed", "/", "/feed", { + "page:/photos/42": React.createElement("main", null, "photo"), + }), + ), + navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: "/feed", + renderId: 1, + type: "navigate", + }); + + expect(pending.routeId).toBe("route:/photos/42\0/feed"); + expect(pending.interceptionContext).toBe("/feed"); + expect(pending.previousNextUrl).toBe("/feed"); + expect(pending.action.interceptionContext).toBe("/feed"); + expect(pending.action.previousNextUrl).toBe("/feed"); + }); + it("hard navigates instead of merging when the root layout changes", async () => { const currentState = createState({ rootLayoutTreePath: "/(marketing)", @@ -187,6 +224,7 @@ describe("app browser entry state helpers", () => { currentState: createState(), nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/")), navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: "/feed", renderId: 1, type: "navigate", }); @@ -194,6 +232,70 @@ describe("app browser entry state helpers", () => { expect(refreshCommit.action.type).toBe("navigate"); expect(refreshCommit.routeId).toBe("route:/dashboard"); expect(refreshCommit.rootLayoutTreePath).toBe("/"); + expect(refreshCommit.previousNextUrl).toBe("/feed"); + }); + + it("stores previousNextUrl on navigate actions", () => { + const nextState = routerReducer(createState(), { + elements: createResolvedElements("route:/photos/42\0/feed", "/", "/feed"), + interceptionContext: "/feed", + navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: "/feed", + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/photos/42\0/feed", + type: "navigate", + }); + + expect(nextState.interceptionContext).toBe("/feed"); + expect(nextState.previousNextUrl).toBe("/feed"); + }); +}); + +describe("app browser entry previousNextUrl helpers", () => { + it("stores previousNextUrl alongside existing history state", () => { + expect( + createHistoryStateWithPreviousNextUrl( + { + __vinext_scrollY: 120, + }, + "/feed?tab=latest", + ), + ).toEqual({ + __vinext_previousNextUrl: "/feed?tab=latest", + __vinext_scrollY: 120, + }); + }); + + it("drops previousNextUrl when cleared", () => { + expect( + createHistoryStateWithPreviousNextUrl( + { + __vinext_previousNextUrl: "/feed", + __vinext_scrollY: 120, + }, + null, + ), + ).toEqual({ + __vinext_scrollY: 120, + }); + }); + + it("reads previousNextUrl from history state", () => { + expect( + readHistoryStatePreviousNextUrl({ + __vinext_previousNextUrl: "/feed?tab=latest", + }), + ).toBe("/feed?tab=latest"); + }); + + it("derives interception context from previousNextUrl pathname", () => { + expect(resolveInterceptionContextFromPreviousNextUrl("/feed?tab=latest")).toBe("/feed"); + }); + + it("returns null when previousNextUrl is missing", () => { + expect(readHistoryStatePreviousNextUrl({})).toBeNull(); + expect(resolveInterceptionContextFromPreviousNextUrl(null)).toBeNull(); }); it("classifies pending commits in one step for same-url payloads", async () => { @@ -225,7 +327,7 @@ describe("app browser entry state helpers", () => { describe("mounted slot helpers", () => { it("collects only mounted slot ids", () => { - const elements: AppElements = createResolvedElements("route:/dashboard", "/", { + const elements: AppElements = createResolvedElements("route:/dashboard", "/", null, { "layout:/": React.createElement("div", null, "layout"), "slot:modal:/": React.createElement("div", null, "modal"), "slot:sidebar:/": React.createElement("div", null, "sidebar"), @@ -237,7 +339,7 @@ describe("mounted slot helpers", () => { }); it("serializes mounted slot ids into a stable header value", () => { - const elements: AppElements = createResolvedElements("route:/dashboard", "/", { + const elements: AppElements = createResolvedElements("route:/dashboard", "/", null, { "slot:z:/": React.createElement("div", null, "z"), "slot:a:/": React.createElement("div", null, "a"), }); @@ -246,7 +348,7 @@ describe("mounted slot helpers", () => { }); it("returns null when there are no mounted slots", () => { - const elements: AppElements = createResolvedElements("route:/dashboard", "/", { + const elements: AppElements = createResolvedElements("route:/dashboard", "/", null, { "slot:ghost:/": null, "slot:missing:/": UNMATCHED_SLOT, }); diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index c1fef4fc3..d0b168956 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -2,11 +2,15 @@ import React from "react"; import { describe, expect, it } from "vite-plus/test"; import { UNMATCHED_SLOT } from "../packages/vinext/src/shims/slot.js"; import { + APP_INTERCEPTION_CONTEXT_KEY, APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, + createAppPayloadCacheKey, + createAppPayloadRouteId, normalizeAppElements, readAppElementsMetadata, + resolveVisitedResponseInterceptionContext, } from "../packages/vinext/src/server/app-elements.js"; describe("app elements payload helpers", () => { @@ -35,6 +39,7 @@ describe("app elements payload helpers", () => { it("reads route metadata from the normalized payload", () => { const metadata = readAppElementsMetadata( normalizeAppElements({ + [APP_INTERCEPTION_CONTEXT_KEY]: "/feed", [APP_ROOT_LAYOUT_KEY]: "/(dashboard)", [APP_ROUTE_KEY]: "route:/dashboard", "route:/dashboard": React.createElement("div", null, "route"), @@ -42,9 +47,36 @@ describe("app elements payload helpers", () => { ); expect(metadata.routeId).toBe("route:/dashboard"); + expect(metadata.interceptionContext).toBe("/feed"); expect(metadata.rootLayoutTreePath).toBe("/(dashboard)"); }); + it("defaults missing interception context metadata to null", () => { + const metadata = readAppElementsMetadata( + normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/dashboard", + "route:/dashboard": React.createElement("div", null, "route"), + }), + ); + + expect(metadata.interceptionContext).toBeNull(); + }); + + it("encodes intercepted route ids and cache keys with a NUL separator", () => { + expect(createAppPayloadRouteId("/photos/42", null)).toBe("route:/photos/42"); + expect(createAppPayloadRouteId("/photos/42", "/feed")).toBe("route:/photos/42\0/feed"); + expect(createAppPayloadCacheKey("/photos/42.rsc", null)).toBe("/photos/42.rsc"); + expect(createAppPayloadCacheKey("/photos/42.rsc", "/feed")).toBe("/photos/42.rsc\0/feed"); + }); + + it("preserves the request cache context when a direct-route payload omits it", () => { + expect(resolveVisitedResponseInterceptionContext("/feed", null)).toBe("/feed"); + expect(resolveVisitedResponseInterceptionContext("/feed", "/feed")).toBe("/feed"); + expect(resolveVisitedResponseInterceptionContext("/feed", "/gallery")).toBe("/gallery"); + expect(resolveVisitedResponseInterceptionContext(null, null)).toBeNull(); + }); + it("rejects payloads with a missing __route key", () => { expect(() => readAppElementsMetadata( @@ -75,4 +107,16 @@ describe("app elements payload helpers", () => { ), ).toThrow("[vinext] Missing __rootLayout key in App Router payload"); }); + + it("rejects payloads with an invalid __interceptionContext value", () => { + expect(() => + readAppElementsMetadata( + normalizeAppElements({ + [APP_INTERCEPTION_CONTEXT_KEY]: 123, + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/dashboard", + }), + ), + ).toThrow("[vinext] Invalid __interceptionContext in App Router payload"); + }); }); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index a5a1db26a..a96027ff9 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -480,7 +480,10 @@ describe("App Router integration", () => { it("renders intercepted photo modal on RSC navigation from feed", async () => { // RSC request simulates client-side navigation const res = await fetch(`${baseUrl}/photos/42.rsc`, { - headers: { Accept: "text/x-component" }, + headers: { + Accept: "text/x-component", + "X-Vinext-Interception-Context": "/feed", + }, }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("text/x-component"); @@ -492,6 +495,13 @@ describe("App Router integration", () => { // It should also contain the feed page content (the source route) expect(rscPayload).toContain("Photo Feed"); expect(rscPayload).toContain("feed-page"); + expect(rscPayload).toContain("__interceptionContext"); + expect(rscPayload).toContain("/feed"); + const nul = String.fromCharCode(0); + expect( + rscPayload.includes("route:/photos/42\\u0000/feed") || + rscPayload.includes(`route:/photos/42${nul}/feed`), + ).toBe(true); }); // --- Intercepting routes with dynamic source route --- diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index 6b5837d38..7b940945b 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -2,6 +2,12 @@ import { test, expect } from "@playwright/test"; const BASE = "http://localhost:4174"; +async function waitForAppRouterHydration(page: import("@playwright/test").Page) { + await page.waitForFunction(() => typeof window.__VINEXT_RSC_NAVIGATE__ === "function", null, { + timeout: 10_000, + }); +} + test.describe("Parallel Routes", () => { test("dashboard renders all parallel slot content", async ({ page }) => { await page.goto(`${BASE}/dashboard`); @@ -55,30 +61,122 @@ test.describe("Intercepting Routes", () => { await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible(); }); - // TODO: This test is temporarily skipped due to a timing issue with embedded - // RSC hydration. The intercepting route feature still works - this is a test - // infrastructure issue that needs investigation. See issue #61 comments. - test.skip("RSC client navigation intercepts to show modal", async ({ page }) => { - // Start on the feed page + test("direct payload cache does not override intercepted navigation", async ({ page }) => { + await page.goto(`${BASE}/photos/42`); + await expect(page.locator('[data-testid="photo-page"]')).toBeVisible(); + await page.goto(`${BASE}/feed`); + await waitForAppRouterHydration(page); - // Wait for hydration - await page.waitForFunction( - () => typeof (window as any).__VINEXT_RSC_NAVIGATE__ === "function", - null, - { timeout: 10000 }, - ); + await page.click("#feed-photo-42-link"); - // Click a photo link — this should be intercepted and show a modal - await page.click('a[href="/photos/1"]'); + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); + }); - // Wait for RSC navigation to complete - await page.waitForTimeout(1000); + test("intercepted payload cache is reused for repeated source-page navigations", async ({ + page, + }) => { + await page.goto(`${BASE}/feed`); + await waitForAppRouterHydration(page); - // The modal version of the photo should appear - await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible({ - timeout: 5000, - }); + await page.click("#feed-photo-42-link"); + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + + await page.goto(`${BASE}/about`); + await page.goto(`${BASE}/feed`); + await waitForAppRouterHydration(page); + + await page.click("#feed-photo-42-link"); + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); + }); + + test("chained intercepted navigations keep the original source context", async ({ page }) => { + await page.goto(`${BASE}/feed`); + await waitForAppRouterHydration(page); + + await page.click("#feed-photo-42-link"); + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-modal"]')).toContainText("Viewing photo 42"); + + await page.click("#modal-photo-43-link"); + + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-modal"]')).toContainText("Viewing photo 43"); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); + }); + + test("refresh on direct photo load preserves the full-page render", async ({ page }) => { + await page.goto(`${BASE}/photos/42`); + await expect(page.locator('[data-testid="photo-page"]')).toBeVisible(); + + await page.reload(); + await waitForAppRouterHydration(page); + + await expect(page.locator('[data-testid="photo-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible(); + }); + + test("hard reload after intercepted navigation renders the full page", async ({ page }) => { + await page.goto(`${BASE}/feed`); + await waitForAppRouterHydration(page); + + await page.click("#feed-photo-42-link"); + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + + await page.reload(); + await waitForAppRouterHydration(page); + + await expect(page.locator('[data-testid="photo-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible(); + await expect(page.locator('[data-testid="feed-page"]')).not.toBeVisible(); + }); + + test("router.refresh preserves intercepted modal view", async ({ page }) => { + await page.goto(`${BASE}/feed`); + await waitForAppRouterHydration(page); + + await page.click("#feed-photo-42-link"); + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + + await page.click('[data-testid="photo-modal-refresh"]'); + + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); + }); + + test("prefetches keep separate cache entries for feed and gallery interception contexts", async ({ + page, + }) => { + await page.goto(`${BASE}/feed`); + await waitForAppRouterHydration(page); + await expect + .poll(async () => + page.evaluate(() => + Array.from(window.__VINEXT_RSC_PREFETCH_CACHE__?.keys() ?? []).filter((key) => + key.includes("/photos/42.rsc"), + ), + ), + ) + .toEqual(["/photos/42.rsc\u0000/feed"]); + + await page.click("#gallery-link"); + await page.waitForURL(`${BASE}/gallery`); + await waitForAppRouterHydration(page); + await expect + .poll(async () => + page.evaluate(() => + Array.from(window.__VINEXT_RSC_PREFETCH_CACHE__?.keys() ?? []) + .filter((key) => key.includes("/photos/42.rsc")) + .sort(), + ), + ) + .toEqual(["/photos/42.rsc\u0000/feed", "/photos/42.rsc\u0000/gallery"]); }); }); diff --git a/tests/e2e/app-router/layout-persistence.spec.ts b/tests/e2e/app-router/layout-persistence.spec.ts index c01aaca0a..8cef25c3e 100644 --- a/tests/e2e/app-router/layout-persistence.spec.ts +++ b/tests/e2e/app-router/layout-persistence.spec.ts @@ -1,18 +1,6 @@ import { test, expect } from "@playwright/test"; import { waitForAppRouterHydration } from "../helpers"; -async function disableViteErrorOverlay(page: import("@playwright/test").Page) { - // Vite's dev error overlay can appear during tests (even for expected errors) - // and intercept pointer events, causing flaky click failures. - await page - .addStyleTag({ - content: "vite-error-overlay{display:none !important; pointer-events:none !important;}", - }) - .catch(() => { - // best effort - }); -} - const BASE = "http://localhost:4174"; type CounterControls = { @@ -198,10 +186,6 @@ test.describe("Error recovery across navigation", () => { await expect(page.locator('[data-testid="error-content"]')).toBeVisible(); await waitForAppRouterHydration(page); - // Hide Vite's dev error overlay before triggering an error — it can appear - // over interactive elements and intercept pointer events, causing flaky failures. - await disableViteErrorOverlay(page); - // Trigger error await page.getByTestId("trigger-error").waitFor({ state: "visible" }); await page.getByTestId("trigger-error").click(); @@ -303,9 +287,8 @@ test.describe("Parallel slot persistence", () => { await expect(page.locator('[data-testid="team-default"]')).toBeVisible(); await expect(page.locator('[data-testid="analytics-default"]')).toBeVisible(); - // The page-specific slot content should not be in the DOM at all — - // the server rendered default.tsx for these slots, not page.tsx. - await expect(page.locator('[data-testid="team-slot"]')).not.toBeAttached(); - await expect(page.locator('[data-testid="analytics-slot"]')).not.toBeAttached(); + // The page-specific slot content should NOT be visible + await expect(page.locator('[data-testid="team-slot"]')).not.toBeVisible(); + await expect(page.locator('[data-testid="analytics-slot"]')).not.toBeVisible(); }); }); diff --git a/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx index 5923f6c28..46fdbf71e 100644 --- a/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx +++ b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx @@ -1,3 +1,6 @@ +import Link from "next/link"; +import { PhotoModalRefreshButton } from "./refresh-button"; + // Intercepting route: renders when navigating from /feed to /photos/[id]. // Shows a modal version of the photo instead of the full page. export default function PhotoModal({ params }: { params: { id: string } }) { @@ -5,6 +8,10 @@ export default function PhotoModal({ params }: { params: { id: string } }) {

Photo Modal

Viewing photo {params.id} in modal

+ + Next Photo + +
); } diff --git a/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/refresh-button.tsx b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/refresh-button.tsx new file mode 100644 index 000000000..e974df5b4 --- /dev/null +++ b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/refresh-button.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +export function PhotoModalRefreshButton() { + const router = useRouter(); + + return ( + + ); +} diff --git a/tests/fixtures/app-basic/app/feed/page.tsx b/tests/fixtures/app-basic/app/feed/page.tsx index bb8bd0f48..d551f1f9c 100644 --- a/tests/fixtures/app-basic/app/feed/page.tsx +++ b/tests/fixtures/app-basic/app/feed/page.tsx @@ -1,16 +1,28 @@ +import Link from "next/link"; + export default function FeedPage() { return (

Photo Feed

diff --git a/tests/fixtures/app-basic/app/gallery/page.tsx b/tests/fixtures/app-basic/app/gallery/page.tsx new file mode 100644 index 000000000..a81bd7838 --- /dev/null +++ b/tests/fixtures/app-basic/app/gallery/page.tsx @@ -0,0 +1,16 @@ +import Link from "next/link"; + +export default function GalleryPage() { + return ( +
+

Photo Gallery

+
    +
  • + + Photo 42 + +
  • +
+
+ ); +} diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index 56e9edf17..c21f08a72 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -10,12 +10,14 @@ * vi.resetModules() + dynamic import(). */ import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test"; +import { createAppPayloadCacheKey } from "../packages/vinext/src/server/app-elements.js"; 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 getCurrentInterceptionContext: Navigation["getCurrentInterceptionContext"]; let MAX_PREFETCH_CACHE_SIZE: Navigation["MAX_PREFETCH_CACHE_SIZE"]; let PREFETCH_CACHE_TTL: Navigation["PREFETCH_CACHE_TTL"]; let snapshotRscResponse: Navigation["snapshotRscResponse"]; @@ -37,6 +39,7 @@ beforeEach(async () => { consumePrefetchResponse = nav.consumePrefetchResponse; getPrefetchCache = nav.getPrefetchCache; getPrefetchedUrls = nav.getPrefetchedUrls; + getCurrentInterceptionContext = nav.getCurrentInterceptionContext; MAX_PREFETCH_CACHE_SIZE = nav.MAX_PREFETCH_CACHE_SIZE; PREFETCH_CACHE_TTL = nav.PREFETCH_CACHE_TTL; snapshotRscResponse = nav.snapshotRscResponse; @@ -85,7 +88,7 @@ describe("prefetch cache eviction", () => { cache.set(rscUrl, { snapshot, timestamp: Date.now() }); prefetched.add(rscUrl); - expect(consumePrefetchResponse(rscUrl, "slot:auth:/")).toEqual(snapshot); + expect(consumePrefetchResponse(rscUrl, null, "slot:auth:/")).toEqual(snapshot); expect(cache.has(rscUrl)).toBe(false); expect(prefetched.has(rscUrl)).toBe(false); }); @@ -107,11 +110,32 @@ describe("prefetch cache eviction", () => { }); prefetched.add(rscUrl); - expect(consumePrefetchResponse(rscUrl, "slot:nav:/")).toBeNull(); + expect(consumePrefetchResponse(rscUrl, null, "slot:nav:/")).toBeNull(); expect(cache.has(rscUrl)).toBe(false); expect(prefetched.has(rscUrl)).toBe(false); }); + it("reuses the committed interception context for soft navigations", () => { + (globalThis as any).window.location.pathname = "/photos/42"; + (globalThis as any).window.history.state = { + __vinext_interceptionContext: "/feed", + }; + + expect(getCurrentInterceptionContext()).toBe("/feed"); + }); + + it("allows separate interception-context entries for the same RSC URL", () => { + const feedKey = createAppPayloadCacheKey("/photos/42.rsc", "/feed"); + const galleryKey = createAppPayloadCacheKey("/photos/42.rsc", "/gallery"); + + storePrefetchResponse(feedKey, new Response("feed")); + storePrefetchResponse(galleryKey, new Response("gallery")); + + expect(feedKey).not.toBe(galleryKey); + expect(getPrefetchCache().has(feedKey)).toBe(true); + expect(getPrefetchCache().has(galleryKey)).toBe(true); + }); + it("preserves X-Vinext-Params when replaying cached RSC responses", async () => { const response = new Response("flight", { headers: {