From a83634d0aa6144078679f5ef55fb6f46f55142ac Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:01:13 +1100 Subject: [PATCH 01/17] Fix app router navigation replay and refresh caching --- packages/vinext/src/global.d.ts | 12 +- .../vinext/src/server/app-browser-entry.ts | 294 +++++++++++++++--- packages/vinext/src/shims/link.tsx | 4 +- packages/vinext/src/shims/navigation.ts | 42 ++- .../app-router/navigation-regressions.spec.ts | 161 ++++++++++ .../nextjs-compat/actions-revalidate.spec.ts | 37 ++- .../app-basic/app/nav-flash/list/page.tsx | 30 ++ .../app/nav-flash/provider/[id]/page.tsx | 15 + .../nextjs-compat/action-revalidate/page.tsx | 4 + tests/fixtures/app-basic/app/page.tsx | 9 + 10 files changed, 561 insertions(+), 47 deletions(-) create mode 100644 tests/e2e/app-router/navigation-regressions.spec.ts create mode 100644 tests/fixtures/app-basic/app/nav-flash/list/page.tsx create mode 100644 tests/fixtures/app-basic/app/nav-flash/provider/[id]/page.tsx diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index ff360f2cc..bd87e910c 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -16,6 +16,7 @@ import type { Root } from "react-dom/client"; import type { OnRequestErrorHandler } from "./server/instrumentation"; +import type { CachedRscResponse } from "./shims/navigation"; // --------------------------------------------------------------------------- // Window globals — browser-side state shared across module boundaries @@ -75,8 +76,15 @@ declare global { * * @param href - The destination URL (may be absolute or relative). * @param redirectDepth - Internal parameter used to detect redirect loops. + * @param navigationKind - Internal hint for traversal vs regular navigation. */ - __VINEXT_RSC_NAVIGATE__: ((href: string, redirectDepth?: number) => Promise) | undefined; + __VINEXT_RSC_NAVIGATE__: + | (( + href: string, + redirectDepth?: number, + navigationKind?: "navigate" | "traverse" | "refresh", + ) => Promise) + | undefined; /** * A Promise that resolves when the current in-flight popstate RSC navigation @@ -94,7 +102,7 @@ declare global { * instance is shared between the navigation shim and the Link component. */ __VINEXT_RSC_PREFETCH_CACHE__: - | Map + | Map | undefined; /** diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 3713228b1..a80981199 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -1,6 +1,16 @@ /// -import type { ReactNode } from "react"; +import { + createElement, + startTransition, + use, + useEffect, + useLayoutEffect, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; import type { Root } from "react-dom/client"; import { createFromFetch, @@ -9,13 +19,14 @@ import { encodeReply, setServerCallback, } from "@vitejs/plugin-rsc/browser"; -import { flushSync } from "react-dom"; import { hydrateRoot } from "react-dom/client"; import { PREFETCH_CACHE_TTL, getPrefetchCache, getPrefetchedUrls, + restoreRscResponse, setClientParams, + snapshotRscResponse, setNavigationContext, toRscUrl, } from "../shims/navigation.js"; @@ -36,18 +47,194 @@ interface ServerActionResult { } let reactRoot: Root | null = null; - -function getReactRoot(): Root { - if (!reactRoot) { - throw new Error("[vinext] React root is not initialized"); - } - return reactRoot; +type BrowserTreeState = { renderId: number; node: ReactNode | Promise }; +type NavigationKind = "navigate" | "traverse" | "refresh"; +interface VisitedResponseCacheEntry { + params: Record; + regularExpiresAt: number; + response: Awaited>; } +const MAX_VISITED_RESPONSE_CACHE_SIZE = 50; +const VISITED_RESPONSE_CACHE_TTL = 30_000; + +let nextNavigationRenderId = 0; +const pendingNavigationCommits = new Map void>(); +let setBrowserTreeState: Dispatch> | null = null; +let latestClientParams: Record = {}; +const visitedResponseCache = new Map(); + function isServerActionResult(value: unknown): value is ServerActionResult { return !!value && typeof value === "object" && "root" in value; } +function isThenable(value: T | Promise): value is Promise { + return typeof value === "object" && value !== null && "then" in value; +} + +function getBrowserTreeStateSetter(): Dispatch> { + if (!setBrowserTreeState) { + throw new Error("[vinext] Browser tree state is not initialized"); + } + return setBrowserTreeState; +} + +function applyClientParams(params: Record): void { + latestClientParams = params; + setClientParams(params); +} + +function clearVisitedResponseCache(): void { + visitedResponseCache.clear(); +} + +function clearPrefetchState(): void { + getPrefetchCache().clear(); + getPrefetchedUrls().clear(); +} + +function clearClientNavigationCaches(): void { + clearVisitedResponseCache(); + clearPrefetchState(); +} + +function pruneVisitedResponseCache(now: number): void { + for (const [rscUrl, entry] of visitedResponseCache) { + if (entry.regularExpiresAt <= now) { + visitedResponseCache.delete(rscUrl); + } + } +} + +function evictVisitedResponseCacheIfNeeded(): void { + while (visitedResponseCache.size >= MAX_VISITED_RESPONSE_CACHE_SIZE) { + const oldest = visitedResponseCache.keys().next().value; + if (oldest === undefined) { + return; + } + visitedResponseCache.delete(oldest); + } +} + +function getVisitedResponse( + rscUrl: string, + navigationKind: NavigationKind, +): VisitedResponseCacheEntry | null { + const cached = visitedResponseCache.get(rscUrl); + if (!cached) { + return null; + } + + if (navigationKind === "refresh") { + return null; + } + + if (navigationKind === "traverse") { + return cached; + } + + if (cached.regularExpiresAt > Date.now()) { + return cached; + } + + visitedResponseCache.delete(rscUrl); + return null; +} + +async function cacheVisitedResponse( + rscUrl: string, + response: Response, + params: Record = latestClientParams, +): Promise { + const now = Date.now(); + const snapshot = await snapshotRscResponse(response); + pruneVisitedResponseCache(now); + visitedResponseCache.delete(rscUrl); + evictVisitedResponseCacheIfNeeded(); + visitedResponseCache.set(rscUrl, { + params, + regularExpiresAt: now + VISITED_RESPONSE_CACHE_TTL, + response: snapshot, + }); +} + +function resolveCommittedNavigations(renderId: number): void { + for (const [pendingId, resolve] of pendingNavigationCommits) { + if (pendingId <= renderId) { + pendingNavigationCommits.delete(pendingId); + resolve(); + } + } +} + +function NavigationCommitSignal({ children, renderId }: { children: ReactNode; renderId: number }) { + useEffect(() => { + const frame = requestAnimationFrame(() => { + resolveCommittedNavigations(renderId); + }); + + return () => { + cancelAnimationFrame(frame); + }; + }, [renderId]); + + return children; +} + +function BrowserRoot({ initialNode }: { initialNode: ReactNode }) { + const [treeState, setTreeState] = useState({ + renderId: 0, + node: initialNode, + }); + + useLayoutEffect(() => { + setBrowserTreeState = setTreeState; + + return () => { + if (setBrowserTreeState === setTreeState) { + setBrowserTreeState = null; + } + }; + }, []); + + const resolvedNode = isThenable(treeState.node) ? use(treeState.node) : treeState.node; + + return createElement(NavigationCommitSignal, { + children: resolvedNode, + renderId: treeState.renderId, + }); +} + +function updateBrowserTree( + node: ReactNode | Promise, + renderId: number, + useTransition: boolean, +): void { + const setter = getBrowserTreeStateSetter(); + const applyUpdate = () => { + setter({ renderId, node }); + }; + + if (useTransition) { + startTransition(applyUpdate); + return; + } + + applyUpdate(); +} + +function renderNavigationPayload(payload: Promise): Promise { + const renderId = ++nextNavigationRenderId; + + const committed = new Promise((resolve) => { + pendingNavigationCommits.set(renderId, resolve); + }); + + updateBrowserTree(payload, renderId, true); + + return committed; +} + function restoreHydrationNavigationContext( pathname: string, searchParams: SearchParamInput, @@ -60,6 +247,21 @@ function restoreHydrationNavigationContext( }); } +function restorePopstateScrollPosition(state: unknown): void { + if (!(state && typeof state === "object" && "__vinext_scrollY" in state)) { + return; + } + + const { __vinext_scrollX: x, __vinext_scrollY: y } = state as { + __vinext_scrollX: number; + __vinext_scrollY: number; + }; + + requestAnimationFrame(() => { + window.scrollTo(x, y); + }); +} + async function readInitialRscStream(): Promise> { const vinext = getVinextBrowserGlobal(); @@ -70,7 +272,7 @@ async function readInitialRscStream(): Promise> { const params = embedData.params ?? {}; if (embedData.params) { - setClientParams(embedData.params); + applyClientParams(embedData.params); } if (embedData.nav) { restoreHydrationNavigationContext( @@ -85,7 +287,7 @@ async function readInitialRscStream(): Promise> { const params = vinext.__VINEXT_RSC_PARAMS__ ?? {}; if (vinext.__VINEXT_RSC_PARAMS__) { - setClientParams(vinext.__VINEXT_RSC_PARAMS__); + applyClientParams(vinext.__VINEXT_RSC_PARAMS__); } if (vinext.__VINEXT_RSC_NAV__) { restoreHydrationNavigationContext( @@ -105,7 +307,7 @@ async function readInitialRscStream(): Promise> { if (paramsHeader) { try { params = JSON.parse(decodeURIComponent(paramsHeader)) as Record; - setClientParams(params); + applyClientParams(params); } catch { // Ignore malformed param headers and continue with hydration. } @@ -122,6 +324,8 @@ async function readInitialRscStream(): Promise> { function registerServerActionCallback(): void { setServerCallback(async (id, args) => { + clearClientNavigationCaches(); + const temporaryReferences = createTemporaryReferenceSet(); const body = await encodeReply(args, { temporaryReferences }); @@ -162,7 +366,7 @@ function registerServerActionCallback(): void { }); if (isServerActionResult(result)) { - getReactRoot().render(result.root); + updateBrowserTree(result.root, nextNavigationRenderId, false); if (result.returnValue) { if (!result.returnValue.ok) throw result.returnValue.data; return result.returnValue.data; @@ -170,7 +374,7 @@ function registerServerActionCallback(): void { return undefined; } - getReactRoot().render(result as ReactNode); + updateBrowserTree(result as ReactNode, nextNavigationRenderId, false); return result; }); } @@ -183,7 +387,7 @@ async function main(): Promise { reactRoot = hydrateRoot( document, - root as ReactNode, + createElement(BrowserRoot, { initialNode: root as ReactNode }), import.meta.env.DEV ? { onCaughtError() {} } : undefined, ); @@ -192,6 +396,7 @@ async function main(): Promise { window.__VINEXT_RSC_NAVIGATE__ = async function navigateRsc( href: string, redirectDepth = 0, + navigationKind: NavigationKind = "navigate", ): Promise { if (redirectDepth > 10) { console.error( @@ -204,18 +409,32 @@ async function main(): Promise { try { const url = new URL(href, window.location.origin); const rscUrl = toRscUrl(url.pathname + url.search); + const cachedRoute = getVisitedResponse(rscUrl, navigationKind); + + if (cachedRoute) { + applyClientParams(cachedRoute.params); + const cachedPayload = createFromFetch( + Promise.resolve(restoreRscResponse(cachedRoute.response)), + ) as Promise; + await renderNavigationPayload(cachedPayload); + return; + } let navResponse: Response | undefined; - const prefetchCache = getPrefetchCache(); - const cached = prefetchCache.get(rscUrl); - - if (cached && Date.now() - cached.timestamp < PREFETCH_CACHE_TTL) { - navResponse = cached.response; - prefetchCache.delete(rscUrl); - getPrefetchedUrls().delete(rscUrl); - } else if (cached) { - prefetchCache.delete(rscUrl); - getPrefetchedUrls().delete(rscUrl); + let navResponseUrl: string | null = null; + if (navigationKind !== "refresh") { + const prefetchCache = getPrefetchCache(); + const cached = prefetchCache.get(rscUrl); + + if (cached?.response && Date.now() - cached.timestamp < PREFETCH_CACHE_TTL) { + navResponse = restoreRscResponse(cached.response); + navResponseUrl = cached.response.url; + prefetchCache.delete(rscUrl); + getPrefetchedUrls().delete(rscUrl); + } else if (cached) { + prefetchCache.delete(rscUrl); + getPrefetchedUrls().delete(rscUrl); + } } if (!navResponse) { @@ -225,7 +444,7 @@ async function main(): Promise { }); } - const finalUrl = new URL(navResponse.url); + const finalUrl = new URL(navResponseUrl ?? navResponse.url, window.location.origin); const requestedUrl = new URL(rscUrl, window.location.origin); if (finalUrl.pathname !== requestedUrl.pathname) { const destinationPath = finalUrl.pathname.replace(/\.rsc$/, "") + finalUrl.search; @@ -237,35 +456,39 @@ async function main(): Promise { return; } - return navigate(destinationPath, redirectDepth + 1); + return navigate(destinationPath, redirectDepth + 1, navigationKind); } + let navParams: Record = {}; const paramsHeader = navResponse.headers.get("X-Vinext-Params"); if (paramsHeader) { try { - setClientParams(JSON.parse(decodeURIComponent(paramsHeader))); + navParams = JSON.parse(decodeURIComponent(paramsHeader)) as Record; + applyClientParams(navParams); } catch { - setClientParams({}); + applyClientParams({}); } } else { - setClientParams({}); + applyClientParams({}); } - const rscPayload = await createFromFetch(Promise.resolve(navResponse)); - flushSync(() => { - getReactRoot().render(rscPayload as ReactNode); + void cacheVisitedResponse(rscUrl, navResponse.clone(), navParams).catch((error) => { + console.error("[vinext] Failed to cache visited RSC response:", error); }); + const rscPayload = createFromFetch(Promise.resolve(navResponse)) as Promise; + await renderNavigationPayload(rscPayload); } catch (error) { console.error("[vinext] RSC navigation error:", error); window.location.href = href; } }; - window.addEventListener("popstate", () => { + window.addEventListener("popstate", (event) => { const pendingNavigation = - window.__VINEXT_RSC_NAVIGATE__?.(window.location.href) ?? Promise.resolve(); + window.__VINEXT_RSC_NAVIGATE__?.(window.location.href, 0, "traverse") ?? Promise.resolve(); window.__VINEXT_RSC_PENDING__ = pendingNavigation; void pendingNavigation.finally(() => { + restorePopstateScrollPosition(event.state); if (window.__VINEXT_RSC_PENDING__ === pendingNavigation) { window.__VINEXT_RSC_PENDING__ = null; } @@ -275,10 +498,11 @@ async function main(): Promise { if (import.meta.hot) { import.meta.hot.on("rsc:update", async () => { try { + clearClientNavigationCaches(); const rscPayload = await createFromFetch( fetch(toRscUrl(window.location.pathname + window.location.search)), ); - getReactRoot().render(rscPayload as ReactNode); + updateBrowserTree(rscPayload as ReactNode, nextNavigationRenderId, false); } catch (error) { console.error("[vinext] RSC HMR error:", error); } diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 258d992af..40e70ae29 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -168,9 +168,9 @@ function prefetchUrl(href: string): void { // @ts-expect-error — purpose is a valid fetch option in some browsers purpose: "prefetch", }) - .then((response) => { + .then(async (response) => { if (response.ok) { - storePrefetchResponse(rscUrl, response); + await storePrefetchResponse(rscUrl, response); } else { // Non-ok response: allow retry on next viewport intersection prefetched.delete(rscUrl); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 2e23b5b8c..e0b85a55e 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -189,11 +189,37 @@ 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 interface CachedRscResponse { + body: ArrayBuffer; + headers: Array<[string, string]>; + status: number; + statusText: string; + url: string; +} + export interface PrefetchCacheEntry { - response: Response; + response: CachedRscResponse; timestamp: number; } +export async function snapshotRscResponse(response: Response): Promise { + return { + body: await response.arrayBuffer(), + headers: [...response.headers.entries()], + status: response.status, + statusText: response.statusText, + url: response.url, + }; +} + +export function restoreRscResponse(snapshot: CachedRscResponse): Response { + return new Response(snapshot.body.slice(0), { + headers: snapshot.headers, + status: snapshot.status, + statusText: snapshot.statusText, + }); +} + /** * Convert a pathname (with optional query/hash) to its .rsc URL. * Strips trailing slashes before appending `.rsc` so that cache keys @@ -236,7 +262,7 @@ export function getPrefetchedUrls(): Set { * Enforces a maximum cache size to prevent unbounded memory growth on * link-heavy pages. */ -export function storePrefetchResponse(rscUrl: string, response: Response): void { +export async function storePrefetchResponse(rscUrl: string, response: Response): Promise { const cache = getPrefetchCache(); const now = Date.now(); @@ -260,7 +286,7 @@ export function storePrefetchResponse(rscUrl: string, response: Response): void } } - cache.set(rscUrl, { response, timestamp: now }); + cache.set(rscUrl, { response: await snapshotRscResponse(response), timestamp: now }); } // Client navigation listeners @@ -616,7 +642,7 @@ const _appRouter = { if (isServer) return; // Re-fetch the current page's RSC stream if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") { - void window.__VINEXT_RSC_NAVIGATE__(window.location.href); + void window.__VINEXT_RSC_NAVIGATE__(window.location.href, 0, "refresh"); } }, prefetch(href: string): void { @@ -632,9 +658,9 @@ const _appRouter = { credentials: "include", priority: "low" as RequestInit["priority"], }) - .then((response) => { + .then(async (response) => { if (response.ok) { - storePrefetchResponse(rscUrl, response); + await storePrefetchResponse(rscUrl, response); } else { // Non-ok response: allow retry on next prefetch() call prefetched.delete(rscUrl); @@ -869,7 +895,9 @@ if (!isServer) { window.addEventListener("popstate", (event) => { notifyListeners(); // Restore scroll position for back/forward navigation - restoreScrollPosition(event.state); + if (typeof window.__VINEXT_RSC_NAVIGATE__ !== "function") { + restoreScrollPosition(event.state); + } }); // --------------------------------------------------------------------------- diff --git a/tests/e2e/app-router/navigation-regressions.spec.ts b/tests/e2e/app-router/navigation-regressions.spec.ts new file mode 100644 index 000000000..72be279e9 --- /dev/null +++ b/tests/e2e/app-router/navigation-regressions.spec.ts @@ -0,0 +1,161 @@ +import { test, expect } from "@playwright/test"; + +const BASE = "http://localhost:4174"; + +async function waitForHydration(page: import("@playwright/test").Page) { + await expect(async () => { + const ready = await page.evaluate(() => !!(window as any).__VINEXT_RSC_ROOT__); + expect(ready).toBe(true); + }).toPass({ timeout: 10_000 }); +} + +async function waitForProvidersList(page: import("@playwright/test").Page) { + await expect(page.locator("#providers-title")).toHaveText("Providers"); + await expect(page.locator("#provider-list")).toBeVisible({ timeout: 10_000 }); +} + +async function installNavigationProbe(page: import("@playwright/test").Page, name: string) { + await page.evaluate((probeName) => { + const events: Array> = []; + const win = window as typeof window & { + __vinextNavProbe__?: Record>>; + __vinextOriginalScrollTo__?: typeof window.scrollTo; + }; + + const record = (type: string) => { + events.push({ + type, + t: performance.now(), + path: location.pathname, + scrollY: window.scrollY, + loadingVisible: !!document.querySelector("#providers-loading"), + listVisible: !!document.querySelector("#provider-list"), + heading: document.querySelector("h1")?.textContent ?? null, + }); + }; + + if (!win.__vinextNavProbe__) { + win.__vinextNavProbe__ = {}; + } + win.__vinextNavProbe__[probeName] = events; + + if (!win.__vinextOriginalScrollTo__) { + win.__vinextOriginalScrollTo__ = window.scrollTo.bind(window); + window.scrollTo = ((optionsOrX?: ScrollToOptions | number, y?: number) => { + record("scrollTo"); + if (typeof optionsOrX === "number") { + return win.__vinextOriginalScrollTo__!(optionsOrX, y ?? 0); + } + return win.__vinextOriginalScrollTo__!(optionsOrX); + }) as typeof window.scrollTo; + } + + const observer = new MutationObserver(() => { + const last = events[events.length - 1]; + const loadingVisible = !!document.querySelector("#providers-loading"); + const listVisible = !!document.querySelector("#provider-list"); + const heading = document.querySelector("h1")?.textContent ?? null; + + if ( + !last || + last.loadingVisible !== loadingVisible || + last.listVisible !== listVisible || + last.heading !== heading + ) { + record("mutation"); + } + }); + + observer.observe(document.documentElement, { + childList: true, + subtree: true, + characterData: true, + }); + + record("start"); + }, name); +} + +async function readProbe(page: import("@playwright/test").Page, name: string) { + return page.evaluate((probeName) => { + return ( + ( + window as typeof window & { + __vinextNavProbe__?: Record>>; + } + ).__vinextNavProbe__?.[probeName] ?? [] + ); + }, name) as Promise< + Array<{ + type: string; + t: number; + path: string; + scrollY: number; + loadingVisible: boolean; + listVisible: boolean; + heading: string | null; + }> + >; +} + +test.describe("App Router navigation regressions", () => { + test("returning to the providers list via Link does not flash the loading fallback", async ({ + page, + }) => { + await page.goto(`${BASE}/`); + await waitForHydration(page); + await page.locator("#nav-flash-link").click(); + await waitForProvidersList(page); + + await page.locator("#provider-link").click(); + await expect(page.locator("#provider-title")).toHaveText("Provider: acme"); + + await installNavigationProbe(page, "link-return"); + await page.locator("#back-to-providers").click(); + await waitForProvidersList(page); + + const events = await readProbe(page, "link-return"); + + expect(events.some((event) => event.loadingVisible)).toBe(false); + }); + + test("browser back restores scroll only after the providers list is visible", async ({ + page, + }) => { + await page.goto(`${BASE}/`); + await waitForHydration(page); + await page.locator("#nav-flash-link").click(); + await waitForProvidersList(page); + + await page.locator("#provider-link").scrollIntoViewIfNeeded(); + const initialScroll = await page.evaluate(() => { + window.scrollTo( + 0, + document.querySelector("#provider-link")!.getBoundingClientRect().top + window.scrollY, + ); + return window.scrollY; + }); + + await page.locator("#provider-link").click(); + await expect(page.locator("#provider-title")).toHaveText("Provider: acme"); + + await installNavigationProbe(page, "popstate-return"); + await page.goBack(); + await waitForProvidersList(page); + await expect + .poll(() => page.evaluate(() => window.scrollY), { + timeout: 5_000, + }) + .toBeGreaterThan(0); + + const events = await readProbe(page, "popstate-return"); + const scrollEvents = events.filter((event) => event.type === "scrollTo"); + + expect(scrollEvents.length).toBeGreaterThan(0); + expect(scrollEvents.every((event) => event.listVisible && !event.loadingVisible)).toBe(true); + + const restoredScroll = await page.evaluate(() => window.scrollY); + expect(restoredScroll).toBeGreaterThan(0); + expect(Math.abs(restoredScroll - initialScroll)).toBeLessThan(5); + }); +}); diff --git a/tests/e2e/app-router/nextjs-compat/actions-revalidate.spec.ts b/tests/e2e/app-router/nextjs-compat/actions-revalidate.spec.ts index 955f64e7a..ad2b8dd38 100644 --- a/tests/e2e/app-router/nextjs-compat/actions-revalidate.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/actions-revalidate.spec.ts @@ -44,8 +44,10 @@ test.describe("Next.js compat: actions-revalidate (browser)", () => { // Test router.refresh() re-renders with fresh data test("router.refresh() updates page data", async ({ page }) => { - await page.goto(`${BASE}/nextjs-compat/refresh-test`); + await page.goto(`${BASE}/`); await waitForHydration(page); + await page.click("#refresh-test-link"); + await expect(page.locator("h1")).toHaveText("Refresh Test"); // Read initial timestamp const time1 = await page.locator("#time").textContent(); @@ -61,4 +63,37 @@ test.describe("Next.js compat: actions-revalidate (browser)", () => { expect(time2).not.toBe(time1); }).toPass({ timeout: 10_000 }); }); + + test("server action invalidates stale traversal data before back navigation", async ({ + page, + }) => { + await page.goto(`${BASE}/`); + await waitForHydration(page); + await page.click("#action-revalidate-link"); + await expect(page.locator("h1")).toHaveText("Revalidate Test"); + + const time1 = await page.locator("#time").textContent(); + expect(time1).toBeTruthy(); + + await page.click("#revalidate"); + + let time2: string | null = null; + await expect(async () => { + time2 = await page.locator("#time").textContent(); + expect(time2).toBeTruthy(); + expect(time2).not.toBe(time1); + }).toPass({ timeout: 10_000 }); + + await page.click("#action-revalidate-about-link"); + await expect(page.locator("h1")).toHaveText("About"); + + await page.goBack(); + await expect(page.locator("h1")).toHaveText("Revalidate Test"); + + await expect(async () => { + const timeBack = await page.locator("#time").textContent(); + expect(timeBack).toBeTruthy(); + expect(timeBack).not.toBe(time1); + }).toPass({ timeout: 10_000 }); + }); }); diff --git a/tests/fixtures/app-basic/app/nav-flash/list/page.tsx b/tests/fixtures/app-basic/app/nav-flash/list/page.tsx new file mode 100644 index 000000000..e77dbdc65 --- /dev/null +++ b/tests/fixtures/app-basic/app/nav-flash/list/page.tsx @@ -0,0 +1,30 @@ +import Link from "next/link"; +import { Suspense } from "react"; + +async function ProviderList() { + await new Promise((resolve) => setTimeout(resolve, 400)); + + return ( +
    +
  • + + Acme Provider + +
  • +
  • Globex Provider
  • +
  • Initech Provider
  • +
+ ); +} + +export default function ProviderListPage() { + return ( +
+

Providers

+
+ Loading providers...

}> + +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nav-flash/provider/[id]/page.tsx b/tests/fixtures/app-basic/app/nav-flash/provider/[id]/page.tsx new file mode 100644 index 000000000..66afa87d7 --- /dev/null +++ b/tests/fixtures/app-basic/app/nav-flash/provider/[id]/page.tsx @@ -0,0 +1,15 @@ +import Link from "next/link"; + +export default async function ProviderPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + return ( +
+

Provider: {id}

+

Provider detail view

+ + Back to providers + +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/action-revalidate/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/action-revalidate/page.tsx index 6397d6afa..8226e6863 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/action-revalidate/page.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/action-revalidate/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { RevalidateForm } from "./revalidate-form"; async function getData() { @@ -13,6 +14,9 @@ export default async function RevalidatePage() {

Revalidate Test

{time}
+ + Go to About + ); } diff --git a/tests/fixtures/app-basic/app/page.tsx b/tests/fixtures/app-basic/app/page.tsx index f0d4978f3..7d0ab1791 100644 --- a/tests/fixtures/app-basic/app/page.tsx +++ b/tests/fixtures/app-basic/app/page.tsx @@ -15,6 +15,15 @@ export default function HomePage() { Go to Redirect Test + + Go to Action Revalidate + + + Go to Refresh Test + + + Go to Providers + ); From 5729d0fd1ae22182c88d5486c97d5a76ab14b1b0 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:39:36 +1100 Subject: [PATCH 02/17] fix: address review issues in navigation cache/replay - Fix broken indentation and missing fallback in server action redirect handler - Replace useEffect with useLayoutEffect in NavigationCommitSignal - Eliminate `as` type assertions via createFromFetch generics - Replace querySelectorAll("*") + getComputedStyle with document.getAnimations() - Cache getSearchParamsSnapshot fallback to prevent potential infinite re-renders - Add module-level fallback for setClientParams in test/SSR environments - Fix History.prototype reference crash in test environments - Update global.d.ts prefetch cache type to match PrefetchCacheEntry - Update prefetch-cache tests for async storePrefetchResponse and CachedRscResponse --- packages/vinext/src/global.d.ts | 8 +- .../vinext/src/server/app-browser-entry.ts | 241 ++++++-- packages/vinext/src/shims/form.tsx | 13 +- packages/vinext/src/shims/link.tsx | 116 +--- packages/vinext/src/shims/navigation.ts | 528 ++++++++++++++---- tests/prefetch-cache.test.ts | 30 +- 6 files changed, 656 insertions(+), 280 deletions(-) diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index bd87e910c..9d714e0d7 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -16,7 +16,7 @@ import type { Root } from "react-dom/client"; import type { OnRequestErrorHandler } from "./server/instrumentation"; -import type { CachedRscResponse } from "./shims/navigation"; +import type { CachedRscResponse, PrefetchCacheEntry } from "./shims/navigation"; // --------------------------------------------------------------------------- // Window globals — browser-side state shared across module boundaries @@ -77,12 +77,14 @@ declare global { * @param href - The destination URL (may be absolute or relative). * @param redirectDepth - Internal parameter used to detect redirect loops. * @param navigationKind - Internal hint for traversal vs regular navigation. + * @param historyUpdateMode - Internal hint for when history should publish. */ __VINEXT_RSC_NAVIGATE__: | (( href: string, redirectDepth?: number, navigationKind?: "navigate" | "traverse" | "refresh", + historyUpdateMode?: "push" | "replace", ) => Promise) | undefined; @@ -101,9 +103,7 @@ declare global { * Lazily initialised on `window` by `shims/navigation.ts` so the same Map * instance is shared between the navigation shim and the Link component. */ - __VINEXT_RSC_PREFETCH_CACHE__: - | Map - | undefined; + __VINEXT_RSC_PREFETCH_CACHE__: Map | undefined; /** * Set of RSC URLs that have already been prefetched (or are in-flight). diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index a80981199..75099f551 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -4,7 +4,6 @@ import { createElement, startTransition, use, - useEffect, useLayoutEffect, useState, type Dispatch, @@ -21,14 +20,21 @@ import { } from "@vitejs/plugin-rsc/browser"; import { hydrateRoot } from "react-dom/client"; import { - PREFETCH_CACHE_TTL, + commitClientNavigationState, + consumePrefetchResponse, + createClientNavigationRenderSnapshot, + getClientNavigationRenderContext, getPrefetchCache, getPrefetchedUrls, + pushHistoryStateWithoutNotify, + replaceClientParamsWithoutNotify, + replaceHistoryStateWithoutNotify, restoreRscResponse, setClientParams, snapshotRscResponse, setNavigationContext, toRscUrl, + type ClientNavigationRenderSnapshot, } from "../shims/navigation.js"; import { chunksToReadableStream, @@ -47,8 +53,13 @@ interface ServerActionResult { } let reactRoot: Root | null = null; -type BrowserTreeState = { renderId: number; node: ReactNode | Promise }; +type BrowserTreeState = { + renderId: number; + node: ReactNode | Promise; + navigationSnapshot: ClientNavigationRenderSnapshot; +}; type NavigationKind = "navigate" | "traverse" | "refresh"; +type HistoryUpdateMode = "push" | "replace"; interface VisitedResponseCacheEntry { params: Record; regularExpiresAt: number; @@ -60,6 +71,7 @@ const VISITED_RESPONSE_CACHE_TTL = 30_000; let nextNavigationRenderId = 0; const pendingNavigationCommits = new Map void>(); +const pendingNavigationPrePaintEffects = new Map void>(); let setBrowserTreeState: Dispatch> | null = null; let latestClientParams: Record = {}; const visitedResponseCache = new Map(); @@ -84,6 +96,11 @@ function applyClientParams(params: Record): void { setClientParams(params); } +function stageClientParams(params: Record): void { + latestClientParams = params; + replaceClientParamsWithoutNotify(params); +} + function clearVisitedResponseCache(): void { visitedResponseCache.clear(); } @@ -98,6 +115,81 @@ function clearClientNavigationCaches(): void { clearPrefetchState(); } +function suppressFreshNavigationAnimations(): void { + if (typeof document === "undefined" || typeof document.getAnimations !== "function") { + return; + } + + for (const animation of document.getAnimations()) { + if (!(animation.effect instanceof KeyframeEffect)) { + continue; + } + + const target = animation.effect.target; + if (!(target instanceof HTMLElement)) { + continue; + } + + if (Number(window.getComputedStyle(target).opacity) > 0.01) { + continue; + } + + animation.cancel(); + } +} + +function queuePrePaintNavigationEffect(renderId: number, effect: (() => void) | null): void { + if (!effect) { + return; + } + pendingNavigationPrePaintEffects.set(renderId, effect); +} + +function runPrePaintNavigationEffect(renderId: number): void { + const effect = pendingNavigationPrePaintEffects.get(renderId); + if (!effect) { + return; + } + + pendingNavigationPrePaintEffects.delete(renderId); + effect(); +} + +function composePrePaintNavigationEffects( + ...effects: Array<(() => void) | null | undefined> +): (() => void) | null { + const activeEffects = effects.filter((effect): effect is () => void => effect != null); + if (activeEffects.length === 0) { + return null; + } + + return () => { + for (const effect of activeEffects) { + effect(); + } + }; +} + +function createNavigationCommitEffect( + href: string, + navigationKind: NavigationKind, + historyUpdateMode: HistoryUpdateMode | undefined, +): (() => void) | null { + if (historyUpdateMode == null && navigationKind === "navigate") { + return null; + } + + return () => { + if (historyUpdateMode === "replace") { + replaceHistoryStateWithoutNotify(null, "", href); + } else if (historyUpdateMode === "push") { + pushHistoryStateWithoutNotify(null, "", href); + } + + commitClientNavigationState(); + }; +} + function pruneVisitedResponseCache(now: number): void { for (const [rscUrl, entry] of visitedResponseCache) { if (entry.regularExpiresAt <= now) { @@ -168,7 +260,9 @@ function resolveCommittedNavigations(renderId: number): void { } function NavigationCommitSignal({ children, renderId }: { children: ReactNode; renderId: number }) { - useEffect(() => { + useLayoutEffect(() => { + runPrePaintNavigationEffect(renderId); + const frame = requestAnimationFrame(() => { resolveCommittedNavigations(renderId); }); @@ -181,10 +275,17 @@ function NavigationCommitSignal({ children, renderId }: { children: ReactNode; r return children; } -function BrowserRoot({ initialNode }: { initialNode: ReactNode }) { +function BrowserRoot({ + initialNode, + initialNavigationSnapshot, +}: { + initialNode: ReactNode; + initialNavigationSnapshot: ClientNavigationRenderSnapshot; +}) { const [treeState, setTreeState] = useState({ renderId: 0, node: initialNode, + navigationSnapshot: initialNavigationSnapshot, }); useLayoutEffect(() => { @@ -199,20 +300,32 @@ function BrowserRoot({ initialNode }: { initialNode: ReactNode }) { const resolvedNode = isThenable(treeState.node) ? use(treeState.node) : treeState.node; - return createElement(NavigationCommitSignal, { + const committedTree = createElement(NavigationCommitSignal, { children: resolvedNode, renderId: treeState.renderId, }); + + const ClientNavigationRenderContext = getClientNavigationRenderContext(); + if (!ClientNavigationRenderContext) { + return committedTree; + } + + return createElement( + ClientNavigationRenderContext.Provider, + { value: treeState.navigationSnapshot }, + committedTree, + ); } function updateBrowserTree( node: ReactNode | Promise, + navigationSnapshot: ClientNavigationRenderSnapshot, renderId: number, useTransition: boolean, ): void { const setter = getBrowserTreeStateSetter(); const applyUpdate = () => { - setter({ renderId, node }); + setter({ renderId, node, navigationSnapshot }); }; if (useTransition) { @@ -223,14 +336,19 @@ function updateBrowserTree( applyUpdate(); } -function renderNavigationPayload(payload: Promise): Promise { +function renderNavigationPayload( + payload: Promise | ReactNode, + navigationSnapshot: ClientNavigationRenderSnapshot, + prePaintEffect: (() => void) | null = null, +): Promise { const renderId = ++nextNavigationRenderId; + queuePrePaintNavigationEffect(renderId, prePaintEffect); const committed = new Promise((resolve) => { pendingNavigationCommits.set(renderId, resolve); }); - updateBrowserTree(payload, renderId, true); + updateBrowserTree(payload, navigationSnapshot, renderId, true); return committed; } @@ -252,10 +370,8 @@ function restorePopstateScrollPosition(state: unknown): void { return; } - const { __vinext_scrollX: x, __vinext_scrollY: y } = state as { - __vinext_scrollX: number; - __vinext_scrollY: number; - }; + const y = Number(state.__vinext_scrollY); + const x = "__vinext_scrollX" in state ? Number(state.__vinext_scrollX) : 0; requestAnimationFrame(() => { window.scrollTo(x, y); @@ -361,12 +477,18 @@ function registerServerActionCallback(): void { return undefined; } - const result = await createFromFetch(Promise.resolve(fetchResponse), { - temporaryReferences, - }); + const result = await createFromFetch( + Promise.resolve(fetchResponse), + { temporaryReferences }, + ); if (isServerActionResult(result)) { - updateBrowserTree(result.root, nextNavigationRenderId, false); + updateBrowserTree( + result.root, + createClientNavigationRenderSnapshot(window.location.href, latestClientParams), + nextNavigationRenderId, + false, + ); if (result.returnValue) { if (!result.returnValue.ok) throw result.returnValue.data; return result.returnValue.data; @@ -374,7 +496,12 @@ function registerServerActionCallback(): void { return undefined; } - updateBrowserTree(result as ReactNode, nextNavigationRenderId, false); + updateBrowserTree( + result, + createClientNavigationRenderSnapshot(window.location.href, latestClientParams), + nextNavigationRenderId, + false, + ); return result; }); } @@ -383,11 +510,18 @@ async function main(): Promise { registerServerActionCallback(); const rscStream = await readInitialRscStream(); - const root = await createFromReadableStream(rscStream); + const root = await createFromReadableStream(rscStream); + const initialNavigationSnapshot = createClientNavigationRenderSnapshot( + window.location.href, + latestClientParams, + ); reactRoot = hydrateRoot( document, - createElement(BrowserRoot, { initialNode: root as ReactNode }), + createElement(BrowserRoot, { + initialNode: root, + initialNavigationSnapshot, + }), import.meta.env.DEV ? { onCaughtError() {} } : undefined, ); @@ -397,6 +531,7 @@ async function main(): Promise { href: string, redirectDepth = 0, navigationKind: NavigationKind = "navigate", + historyUpdateMode?: HistoryUpdateMode, ): Promise { if (redirectDepth > 10) { console.error( @@ -410,30 +545,36 @@ async function main(): Promise { const url = new URL(href, window.location.origin); const rscUrl = toRscUrl(url.pathname + url.search); const cachedRoute = getVisitedResponse(rscUrl, navigationKind); + const navigationCommitEffect = createNavigationCommitEffect( + href, + navigationKind, + historyUpdateMode, + ); if (cachedRoute) { - applyClientParams(cachedRoute.params); - const cachedPayload = createFromFetch( + stageClientParams(cachedRoute.params); + const cachedNavigationSnapshot = createClientNavigationRenderSnapshot( + href, + cachedRoute.params, + ); + const cachedPayload = createFromFetch( Promise.resolve(restoreRscResponse(cachedRoute.response)), - ) as Promise; - await renderNavigationPayload(cachedPayload); + ); + await renderNavigationPayload( + cachedPayload, + cachedNavigationSnapshot, + navigationCommitEffect, + ); return; } let navResponse: Response | undefined; let navResponseUrl: string | null = null; if (navigationKind !== "refresh") { - const prefetchCache = getPrefetchCache(); - const cached = prefetchCache.get(rscUrl); - - if (cached?.response && Date.now() - cached.timestamp < PREFETCH_CACHE_TTL) { - navResponse = restoreRscResponse(cached.response); - navResponseUrl = cached.response.url; - prefetchCache.delete(rscUrl); - getPrefetchedUrls().delete(rscUrl); - } else if (cached) { - prefetchCache.delete(rscUrl); - getPrefetchedUrls().delete(rscUrl); + const prefetchedResponse = await consumePrefetchResponse(rscUrl); + if (prefetchedResponse) { + navResponse = restoreRscResponse(prefetchedResponse); + navResponseUrl = prefetchedResponse.url; } } @@ -448,7 +589,6 @@ async function main(): Promise { const requestedUrl = new URL(rscUrl, window.location.origin); if (finalUrl.pathname !== requestedUrl.pathname) { const destinationPath = finalUrl.pathname.replace(/\.rsc$/, "") + finalUrl.search; - window.history.replaceState(null, "", destinationPath); const navigate = window.__VINEXT_RSC_NAVIGATE__; if (!navigate) { @@ -456,7 +596,7 @@ async function main(): Promise { return; } - return navigate(destinationPath, redirectDepth + 1, navigationKind); + return navigate(destinationPath, redirectDepth + 1, navigationKind, historyUpdateMode); } let navParams: Record = {}; @@ -464,19 +604,27 @@ async function main(): Promise { if (paramsHeader) { try { navParams = JSON.parse(decodeURIComponent(paramsHeader)) as Record; - applyClientParams(navParams); + stageClientParams(navParams); } catch { - applyClientParams({}); + stageClientParams({}); } } else { - applyClientParams({}); + stageClientParams({}); } + const navigationSnapshot = createClientNavigationRenderSnapshot(href, latestClientParams); void cacheVisitedResponse(rscUrl, navResponse.clone(), navParams).catch((error) => { console.error("[vinext] Failed to cache visited RSC response:", error); }); - const rscPayload = createFromFetch(Promise.resolve(navResponse)) as Promise; - await renderNavigationPayload(rscPayload); + const rscPayload = createFromFetch(Promise.resolve(navResponse)); + await renderNavigationPayload( + rscPayload, + navigationSnapshot, + composePrePaintNavigationEffects( + navigationCommitEffect, + navResponseUrl ? null : suppressFreshNavigationAnimations, + ), + ); } catch (error) { console.error("[vinext] RSC navigation error:", error); window.location.href = href; @@ -499,10 +647,15 @@ async function main(): Promise { import.meta.hot.on("rsc:update", async () => { try { clearClientNavigationCaches(); - const rscPayload = await createFromFetch( + const rscPayload = await createFromFetch( fetch(toRscUrl(window.location.pathname + window.location.search)), ); - updateBrowserTree(rscPayload as ReactNode, nextNavigationRenderId, false); + updateBrowserTree( + rscPayload, + createClientNavigationRenderSnapshot(window.location.href, latestClientParams), + nextNavigationRenderId, + false, + ); } catch (error) { console.error("[vinext] RSC HMR error:", error); } diff --git a/packages/vinext/src/shims/form.tsx b/packages/vinext/src/shims/form.tsx index f78d23e60..6e60d5a22 100644 --- a/packages/vinext/src/shims/form.tsx +++ b/packages/vinext/src/shims/form.tsx @@ -19,6 +19,7 @@ */ import { forwardRef, useActionState, type FormHTMLAttributes, type ForwardedRef } from "react"; +import { navigateClientSide } from "./navigation.js"; import { isDangerousScheme } from "./url-safety.js"; import { toSameOriginPath } from "./url-utils.js"; @@ -223,13 +224,9 @@ const Form = forwardRef(function Form(props: FormProps, ref: ForwardedRef { if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") { - // App Router: prefetch the RSC payload and store in cache - fetch(rscUrl, { - headers: { Accept: "text/x-component" }, - credentials: "include", - priority: "low" as any, - // @ts-expect-error — purpose is a valid fetch option in some browsers - purpose: "prefetch", - }) - .then(async (response) => { - if (response.ok) { - await storePrefetchResponse(rscUrl, response); - } else { - // Non-ok response: allow retry on next viewport intersection - prefetched.delete(rscUrl); - } - }) - .catch(() => { - // Network error: allow retry on next viewport intersection - prefetched.delete(rscUrl); - }); + // App Router: prefetch the RSC payload and store either the in-flight + // request or the completed snapshot so an immediate click can reuse it. + prefetchRscResponse( + rscUrl, + fetch(rscUrl, { + headers: { Accept: "text/x-component" }, + credentials: "include", + priority: "low" as any, + // @ts-expect-error — purpose is a valid fetch option in some browsers + purpose: "prefetch", + }), + ); } else if ((window.__NEXT_DATA__ as VinextNextData | undefined)?.__vinext?.pageModuleUrl) { // Pages Router: inject a prefetch link for the target page module // We can't easily resolve the target page's module URL from the Link, @@ -436,48 +403,11 @@ const Link = forwardRef(function Link( } } - // Save scroll position for back/forward restoration - if (!replace) { - const state = window.history.state ?? {}; - window.history.replaceState( - { ...state, __vinext_scrollX: window.scrollX, __vinext_scrollY: window.scrollY }, - "", - ); - } - - // Hash-only change: update URL and scroll to target, skip RSC fetch - if (typeof window !== "undefined" && isHashOnlyChange(absoluteFullHref)) { - const hash = absoluteFullHref.includes("#") - ? absoluteFullHref.slice(absoluteFullHref.indexOf("#")) - : ""; - if (replace) { - window.history.replaceState(null, "", absoluteFullHref); - } else { - window.history.pushState(null, "", absoluteFullHref); - } - if (scroll) { - scrollToHash(hash); - } - return; - } - - // Extract hash for scroll-after-navigation - const hashIdx = absoluteFullHref.indexOf("#"); - const hash = hashIdx !== -1 ? absoluteFullHref.slice(hashIdx) : ""; - // Try RSC navigation first (App Router), then Pages Router if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") { - // App Router: push/replace history state, then fetch RSC stream. - // Await the RSC navigate so scroll-to-top happens after the new - // content is committed to the DOM (prevents flash of old page at top). - if (replace) { - window.history.replaceState(null, "", absoluteFullHref); - } else { - window.history.pushState(null, "", absoluteFullHref); - } setPending(true); try { - await window.__VINEXT_RSC_NAVIGATE__(absoluteFullHref); + await navigateClientSide(navigateHref, replace ? "replace" : "push", scroll); } finally { if (mountedRef.current) setPending(false); } @@ -502,14 +432,6 @@ const Link = forwardRef(function Link( window.dispatchEvent(new PopStateEvent("popstate")); } } - - if (scroll) { - if (hash) { - scrollToHash(hash); - } else { - window.scrollTo(0, 0); - } - } }; // Remove props that shouldn't be on diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index e0b85a55e..98d81882b 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -25,11 +25,13 @@ import { ReadonlyURLSearchParams } from "./readonly-url-search-params.js"; // still line up if Vite loads this shim through multiple resolved module IDs. const _LAYOUT_SEGMENT_CTX_KEY = Symbol.for("vinext.layoutSegmentContext"); const _SERVER_INSERTED_HTML_CTX_KEY = Symbol.for("vinext.serverInsertedHTMLContext"); +const _CLIENT_NAVIGATION_RENDER_CTX_KEY = Symbol.for("vinext.clientNavigationRenderContext"); type _LayoutSegmentGlobal = typeof globalThis & { [_LAYOUT_SEGMENT_CTX_KEY]?: React.Context | null; [_SERVER_INSERTED_HTML_CTX_KEY]?: React.Context< ((callback: () => unknown) => void) | null > | null; + [_CLIENT_NAVIGATION_RENDER_CTX_KEY]?: React.Context | null; }; // ─── ServerInsertedHTML context ──────────────────────────────────────────────── @@ -65,6 +67,34 @@ export const ServerInsertedHTMLContext: React.Context< ((callback: () => unknown) => void) | null > | null = getServerInsertedHTMLContext(); +export interface ClientNavigationRenderSnapshot { + pathname: string; + searchParams: ReadonlyURLSearchParams; + params: Record; +} + +export function getClientNavigationRenderContext(): React.Context | null { + if (typeof React.createContext !== "function") return null; + + const globalState = globalThis as _LayoutSegmentGlobal; + if (!globalState[_CLIENT_NAVIGATION_RENDER_CTX_KEY]) { + globalState[_CLIENT_NAVIGATION_RENDER_CTX_KEY] = + React.createContext(null); + } + + return globalState[_CLIENT_NAVIGATION_RENDER_CTX_KEY] ?? null; +} + +function useClientNavigationRenderSnapshot(): ClientNavigationRenderSnapshot | null { + const ctx = getClientNavigationRenderContext(); + if (!ctx) return null; + try { + return React.useContext(ctx); + } catch { + return null; + } +} + /** * Get or create the layout segment context. * Returns null in the RSC environment (createContext unavailable). @@ -198,7 +228,8 @@ export interface CachedRscResponse { } export interface PrefetchCacheEntry { - response: CachedRscResponse; + response?: CachedRscResponse; + pendingResponse?: Promise; timestamp: number; } @@ -245,6 +276,37 @@ export function getPrefetchCache(): Map { return window.__VINEXT_RSC_PREFETCH_CACHE__; } +function deletePrefetchEntry(rscUrl: string): void { + getPrefetchCache().delete(rscUrl); + getPrefetchedUrls().delete(rscUrl); +} + +function pruneExpiredPrefetchEntries(now: number): void { + const cache = getPrefetchCache(); + for (const [key, entry] of cache) { + if (entry.pendingResponse) { + continue; + } + if (now - entry.timestamp >= PREFETCH_CACHE_TTL) { + deletePrefetchEntry(key); + } + } +} + +function evictPrefetchCacheIfNeeded(): void { + const cache = getPrefetchCache(); + + while (cache.size >= MAX_PREFETCH_CACHE_SIZE) { + const oldestSettledEntry = [...cache.entries()].find( + ([, entry]) => entry.pendingResponse == null, + ); + if (!oldestSettledEntry) { + return; + } + deletePrefetchEntry(oldestSettledEntry[0]); + } +} + /** * 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. @@ -268,58 +330,188 @@ export async function storePrefetchResponse(rscUrl: string, response: Response): // Sweep expired entries before resorting to FIFO eviction if (cache.size >= MAX_PREFETCH_CACHE_SIZE) { - const prefetched = getPrefetchedUrls(); - for (const [key, entry] of cache) { - if (now - entry.timestamp >= PREFETCH_CACHE_TTL) { - cache.delete(key); - prefetched.delete(key); - } - } + pruneExpiredPrefetchEntries(now); } // FIFO fallback if still at capacity after sweep if (cache.size >= MAX_PREFETCH_CACHE_SIZE) { - const oldest = cache.keys().next().value; - if (oldest !== undefined) { - cache.delete(oldest); - getPrefetchedUrls().delete(oldest); - } + evictPrefetchCacheIfNeeded(); } cache.set(rscUrl, { response: await snapshotRscResponse(response), timestamp: now }); } +export function prefetchRscResponse(rscUrl: string, responsePromise: Promise): void { + const cache = getPrefetchCache(); + const existing = cache.get(rscUrl); + if (existing?.response || existing?.pendingResponse) { + return; + } + + const now = Date.now(); + if (cache.size >= MAX_PREFETCH_CACHE_SIZE) { + pruneExpiredPrefetchEntries(now); + } + if (cache.size >= MAX_PREFETCH_CACHE_SIZE) { + evictPrefetchCacheIfNeeded(); + } + + let pendingResponse: Promise; + pendingResponse = responsePromise + .then(async (response) => { + if (!response.ok) { + if (cache.get(rscUrl)?.pendingResponse === pendingResponse) { + deletePrefetchEntry(rscUrl); + } + return null; + } + + const snapshot = await snapshotRscResponse(response); + if (cache.get(rscUrl)?.pendingResponse === pendingResponse) { + cache.set(rscUrl, { response: snapshot, timestamp: Date.now() }); + } + return snapshot; + }) + .catch(() => { + if (cache.get(rscUrl)?.pendingResponse === pendingResponse) { + deletePrefetchEntry(rscUrl); + } + return null; + }); + + cache.set(rscUrl, { pendingResponse, timestamp: now }); +} + +export async function consumePrefetchResponse(rscUrl: string): Promise { + const cache = getPrefetchCache(); + const entry = cache.get(rscUrl); + if (!entry) { + return null; + } + + if (entry.pendingResponse) { + const snapshot = await entry.pendingResponse; + const settledEntry = cache.get(rscUrl); + if (settledEntry?.response) { + deletePrefetchEntry(rscUrl); + return settledEntry.response; + } + if (snapshot) { + deletePrefetchEntry(rscUrl); + return snapshot; + } + return null; + } + + if (!entry.response) { + deletePrefetchEntry(rscUrl); + return null; + } + + if (Date.now() - entry.timestamp >= PREFETCH_CACHE_TTL) { + deletePrefetchEntry(rscUrl); + return null; + } + + deletePrefetchEntry(rscUrl); + return entry.response; +} + // Client navigation listeners type NavigationListener = () => void; -const _listeners: Set = new Set(); +const _CLIENT_NAV_STATE_KEY = Symbol.for("vinext.clientNavigationState"); + +type ClientNavigationState = { + listeners: Set; + cachedSearch: string; + cachedReadonlySearchParams: ReadonlyURLSearchParams; + cachedPathname: string; + clientParams: Record; + clientParamsJson: string; + pendingClientParams: Record | null; + pendingClientParamsJson: string | null; + originalPushState: typeof window.history.pushState; + originalReplaceState: typeof window.history.replaceState; + patchInstalled: boolean; + hasPendingNavigationUpdate: boolean; + suppressUrlNotifyCount: number; +}; + +type ClientNavigationGlobal = typeof globalThis & { + [_CLIENT_NAV_STATE_KEY]?: ClientNavigationState; +}; -function notifyListeners(): void { - for (const fn of _listeners) fn(); +function getClientNavigationState(): ClientNavigationState | null { + if (isServer) return null; + + const globalState = window as ClientNavigationGlobal; + if (!globalState[_CLIENT_NAV_STATE_KEY]) { + globalState[_CLIENT_NAV_STATE_KEY] = { + listeners: new Set(), + cachedSearch: window.location.search, + cachedReadonlySearchParams: new ReadonlyURLSearchParams(window.location.search), + cachedPathname: stripBasePath(window.location.pathname, __basePath), + clientParams: {}, + clientParamsJson: "{}", + pendingClientParams: null, + pendingClientParamsJson: null, + originalPushState: window.history.pushState.bind(window.history), + originalReplaceState: window.history.replaceState.bind(window.history), + patchInstalled: false, + hasPendingNavigationUpdate: false, + suppressUrlNotifyCount: 0, + }; + } + + return globalState[_CLIENT_NAV_STATE_KEY]!; +} + +function notifyNavigationListeners(): void { + const state = getClientNavigationState(); + if (!state) return; + for (const fn of state.listeners) fn(); } // Cached URLSearchParams, pathname, etc. for referential stability // useSyncExternalStore compares snapshots with Object.is — avoid creating // new instances on every render (infinite re-renders). -let _cachedSearch = !isServer ? window.location.search : ""; -let _cachedReadonlySearchParams = new ReadonlyURLSearchParams(_cachedSearch); let _cachedEmptyServerSearchParams: ReadonlyURLSearchParams | null = null; -let _cachedPathname = !isServer ? stripBasePath(window.location.pathname, __basePath) : "/"; function getPathnameSnapshot(): string { - const current = stripBasePath(window.location.pathname, __basePath); - if (current !== _cachedPathname) { - _cachedPathname = current; - } - return _cachedPathname; + return getClientNavigationState()?.cachedPathname ?? "/"; } +let _cachedEmptyClientSearchParams: ReadonlyURLSearchParams | null = null; + function getSearchParamsSnapshot(): ReadonlyURLSearchParams { - const current = window.location.search; - if (current !== _cachedSearch) { - _cachedSearch = current; - _cachedReadonlySearchParams = new ReadonlyURLSearchParams(current); + const cached = getClientNavigationState()?.cachedReadonlySearchParams; + if (cached) return cached; + if (_cachedEmptyClientSearchParams === null) { + _cachedEmptyClientSearchParams = new ReadonlyURLSearchParams(); } - return _cachedReadonlySearchParams; + return _cachedEmptyClientSearchParams; +} + +function syncCommittedUrlStateFromLocation(): boolean { + const state = getClientNavigationState(); + if (!state) return false; + + let changed = false; + + const pathname = stripBasePath(window.location.pathname, __basePath); + if (pathname !== state.cachedPathname) { + state.cachedPathname = pathname; + changed = true; + } + + const search = window.location.search; + if (search !== state.cachedSearch) { + state.cachedSearch = search; + state.cachedReadonlySearchParams = new ReadonlyURLSearchParams(search); + changed = true; + } + + return changed; } function getServerSearchParamsSnapshot(): ReadonlyURLSearchParams { @@ -342,26 +534,65 @@ function getServerSearchParamsSnapshot(): ReadonlyURLSearchParams { // We cache the params object for referential stability — only create a new // object when the params actually change (shallow key/value comparison). const _EMPTY_PARAMS: Record = {}; -let _clientParams: Record = _EMPTY_PARAMS; -let _clientParamsJson = "{}"; + +export function createClientNavigationRenderSnapshot( + href: string, + params: Record, +): ClientNavigationRenderSnapshot { + const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"; + const url = new URL(href, origin); + + return { + pathname: stripBasePath(url.pathname, __basePath), + searchParams: new ReadonlyURLSearchParams(url.search), + params, + }; +} + +// Module-level fallback for environments without window (tests, SSR). +let _fallbackClientParams: Record = _EMPTY_PARAMS; +let _fallbackClientParamsJson = "{}"; export function setClientParams(params: Record): void { + const state = getClientNavigationState(); + if (!state) { + const json = JSON.stringify(params); + if (json !== _fallbackClientParamsJson) { + _fallbackClientParams = params; + _fallbackClientParamsJson = json; + } + return; + } + + const json = JSON.stringify(params); + if (json !== state.clientParamsJson) { + state.clientParams = params; + state.clientParamsJson = json; + state.pendingClientParams = null; + state.pendingClientParamsJson = null; + notifyNavigationListeners(); + } +} + +export function replaceClientParamsWithoutNotify(params: Record): void { + const state = getClientNavigationState(); + if (!state) return; + const json = JSON.stringify(params); - if (json !== _clientParamsJson) { - _clientParams = params; - _clientParamsJson = json; - // Notify useSyncExternalStore subscribers so useParams() re-renders. - notifyListeners(); + if (json !== state.clientParamsJson || json !== state.pendingClientParamsJson) { + state.pendingClientParams = params; + state.pendingClientParamsJson = json; + state.hasPendingNavigationUpdate = true; } } /** Get the current client params (for testing referential stability). */ export function getClientParams(): Record { - return _clientParams; + return getClientNavigationState()?.clientParams ?? _fallbackClientParams; } function getClientParamsSnapshot(): Record { - return _clientParams; + return getClientNavigationState()?.clientParams ?? _EMPTY_PARAMS; } function getServerParamsSnapshot(): Record { @@ -369,9 +600,12 @@ function getServerParamsSnapshot(): Record { } function subscribeToNavigation(cb: () => void): () => void { - _listeners.add(cb); + const state = getClientNavigationState(); + if (!state) return () => {}; + + state.listeners.add(cb); return () => { - _listeners.delete(cb); + state.listeners.delete(cb); }; } @@ -389,12 +623,14 @@ export function usePathname(): string { // Return a safe fallback — the client will hydrate with the real value. return _getServerContext()?.pathname ?? "/"; } + const renderSnapshot = useClientNavigationRenderSnapshot(); // Client-side: use the hook system for reactivity - return React.useSyncExternalStore( + const pathname = React.useSyncExternalStore( subscribeToNavigation, getPathnameSnapshot, () => _getServerContext()?.pathname ?? "/", ); + return renderSnapshot?.pathname ?? pathname; } /** @@ -406,11 +642,13 @@ export function useSearchParams(): ReadonlyURLSearchParams { // Return a safe fallback — the client will hydrate with the real value. return getServerSearchParamsSnapshot(); } - return React.useSyncExternalStore( + const renderSnapshot = useClientNavigationRenderSnapshot(); + const searchParams = React.useSyncExternalStore( subscribeToNavigation, getSearchParamsSnapshot, getServerSearchParamsSnapshot, ); + return renderSnapshot?.searchParams ?? searchParams; } /** @@ -423,11 +661,13 @@ export function useParams< // During SSR of "use client" components, the navigation context may not be set. return (_getServerContext()?.params ?? _EMPTY_PARAMS) as T; } - return React.useSyncExternalStore( + const renderSnapshot = useClientNavigationRenderSnapshot(); + const params = React.useSyncExternalStore( subscribeToNavigation, getClientParamsSnapshot as () => T, getServerParamsSnapshot as () => T, ); + return (renderSnapshot?.params as T | undefined) ?? params; } /** @@ -473,9 +713,61 @@ function scrollToHash(hash: string): void { * (e.g. saving scroll position shouldn't cause re-renders). * Captured before the history method patching at the bottom of this module. */ -const _nativeReplaceState: typeof window.history.replaceState | null = !isServer - ? window.history.replaceState.bind(window.history) - : null; +function withSuppressedUrlNotifications(fn: () => T): T { + const state = getClientNavigationState(); + if (!state) { + return fn(); + } + + state.suppressUrlNotifyCount += 1; + try { + return fn(); + } finally { + state.suppressUrlNotifyCount -= 1; + } +} + +export function commitClientNavigationState(): void { + if (isServer) return; + const state = getClientNavigationState(); + if (!state) return; + + const urlChanged = syncCommittedUrlStateFromLocation(); + if (state.pendingClientParams !== null && state.pendingClientParamsJson !== null) { + state.clientParams = state.pendingClientParams; + state.clientParamsJson = state.pendingClientParamsJson; + state.pendingClientParams = null; + state.pendingClientParamsJson = null; + } + const shouldNotify = urlChanged || state.hasPendingNavigationUpdate; + state.hasPendingNavigationUpdate = false; + + if (shouldNotify) { + notifyNavigationListeners(); + } +} + +export function pushHistoryStateWithoutNotify( + data: unknown, + unused: string, + url?: string | URL | null, +): void { + withSuppressedUrlNotifications(() => { + const state = getClientNavigationState(); + state?.originalPushState.call(window.history, data, unused, url); + }); +} + +export function replaceHistoryStateWithoutNotify( + data: unknown, + unused: string, + url?: string | URL | null, +): void { + withSuppressedUrlNotifications(() => { + const state = getClientNavigationState(); + state?.originalReplaceState.call(window.history, data, unused, url); + }); +} /** * Save the current scroll position into the current history state. @@ -485,10 +777,8 @@ const _nativeReplaceState: typeof window.history.replaceState | null = !isServer * interception (which would cause spurious re-renders from notifyListeners). */ function saveScrollPosition(): void { - if (!_nativeReplaceState) return; const state = window.history.state ?? {}; - _nativeReplaceState.call( - window.history, + replaceHistoryStateWithoutNotify( { ...state, __vinext_scrollX: window.scrollX, __vinext_scrollY: window.scrollY }, "", ); @@ -573,11 +863,11 @@ async function navigateImpl( if (isHashOnlyChange(fullHref)) { const hash = fullHref.includes("#") ? fullHref.slice(fullHref.indexOf("#")) : ""; if (mode === "replace") { - window.history.replaceState(null, "", fullHref); + replaceHistoryStateWithoutNotify(null, "", fullHref); } else { - window.history.pushState(null, "", fullHref); + pushHistoryStateWithoutNotify(null, "", fullHref); } - notifyListeners(); + commitClientNavigationState(); if (scroll) { scrollToHash(hash); } @@ -587,19 +877,30 @@ async function navigateImpl( // Extract hash for post-navigation scrolling const hashIdx = fullHref.indexOf("#"); const hash = hashIdx !== -1 ? fullHref.slice(hashIdx) : ""; - - if (mode === "replace") { - window.history.replaceState(null, "", fullHref); - } else { - window.history.pushState(null, "", fullHref); - } - notifyListeners(); + const previousHref = window.location.pathname + window.location.search + window.location.hash; // Trigger RSC re-fetch if available, and wait for the new content to render // before scrolling. This prevents the old page from visibly jumping to the // top before the new content paints. if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") { - await window.__VINEXT_RSC_NAVIGATE__(fullHref); + await window.__VINEXT_RSC_NAVIGATE__(fullHref, 0, "navigate", mode); + } else { + if (mode === "replace") { + replaceHistoryStateWithoutNotify(null, "", fullHref); + } else { + pushHistoryStateWithoutNotify(null, "", fullHref); + } + commitClientNavigationState(); + } + + const currentHref = window.location.pathname + window.location.search + window.location.hash; + if (currentHref === previousHref) { + if (mode === "replace") { + replaceHistoryStateWithoutNotify(null, "", fullHref); + } else { + pushHistoryStateWithoutNotify(null, "", fullHref); + } + commitClientNavigationState(); } if (scroll) { @@ -611,6 +912,14 @@ async function navigateImpl( } } +export async function navigateClientSide( + href: string, + mode: "push" | "replace", + scroll: boolean, +): Promise { + await navigateImpl(href, mode, scroll); +} + // --------------------------------------------------------------------------- // App Router router singleton // @@ -624,11 +933,11 @@ async function navigateImpl( const _appRouter = { push(href: string, options?: { scroll?: boolean }): void { if (isServer) return; - void navigateImpl(href, "push", options?.scroll !== false); + void navigateClientSide(href, "push", options?.scroll !== false); }, replace(href: string, options?: { scroll?: boolean }): void { if (isServer) return; - void navigateImpl(href, "replace", options?.scroll !== false); + void navigateClientSide(href, "replace", options?.scroll !== false); }, back(): void { if (isServer) return; @@ -653,23 +962,14 @@ const _appRouter = { const prefetched = getPrefetchedUrls(); if (prefetched.has(rscUrl)) return; prefetched.add(rscUrl); - fetch(rscUrl, { - headers: { Accept: "text/x-component" }, - credentials: "include", - priority: "low" as RequestInit["priority"], - }) - .then(async (response) => { - if (response.ok) { - await storePrefetchResponse(rscUrl, response); - } else { - // Non-ok response: allow retry on next prefetch() call - prefetched.delete(rscUrl); - } - }) - .catch(() => { - // Network error: allow retry on next prefetch() call - prefetched.delete(rscUrl); - }); + prefetchRscResponse( + rscUrl, + fetch(rscUrl, { + headers: { Accept: "text/x-component" }, + credentials: "include", + priority: "low" as RequestInit["priority"], + }), + ); }, }; @@ -892,45 +1192,37 @@ export function unauthorized(): never { // Listen for popstate on the client if (!isServer) { - window.addEventListener("popstate", (event) => { - notifyListeners(); - // Restore scroll position for back/forward navigation - if (typeof window.__VINEXT_RSC_NAVIGATE__ !== "function") { - restoreScrollPosition(event.state); - } - }); + const state = getClientNavigationState(); + if (state && !state.patchInstalled) { + state.patchInstalled = true; + + window.addEventListener("popstate", (event) => { + if (typeof window.__VINEXT_RSC_NAVIGATE__ !== "function") { + commitClientNavigationState(); + restoreScrollPosition(event.state); + } + }); - // --------------------------------------------------------------------------- - // history.pushState / replaceState interception (shallow routing) - // - // Next.js intercepts these native methods so that when user code calls - // `window.history.pushState(null, '', '/new-path?filter=abc')` directly, - // React hooks like usePathname() and useSearchParams() re-render with - // the new URL. This is the foundation for shallow routing patterns - // (filter UIs, tabs, URL search param state, etc.). - // - // We wrap the original methods, call through to the native implementation, - // then notify our listener system so useSyncExternalStore picks up the - // URL change. - // --------------------------------------------------------------------------- - const originalPushState = window.history.pushState.bind(window.history); - const originalReplaceState = window.history.replaceState.bind(window.history); - - window.history.pushState = function patchedPushState( - data: unknown, - unused: string, - url?: string | URL | null, - ): void { - originalPushState(data, unused, url); - notifyListeners(); - }; + window.history.pushState = function patchedPushState( + data: unknown, + unused: string, + url?: string | URL | null, + ): void { + state.originalPushState.call(window.history, data, unused, url); + if (state.suppressUrlNotifyCount === 0) { + commitClientNavigationState(); + } + }; - window.history.replaceState = function patchedReplaceState( - data: unknown, - unused: string, - url?: string | URL | null, - ): void { - originalReplaceState(data, unused, url); - notifyListeners(); - }; + window.history.replaceState = function patchedReplaceState( + data: unknown, + unused: string, + url?: string | URL | null, + ): void { + state.originalReplaceState.call(window.history, data, unused, url); + if (state.suppressUrlNotifyCount === 0) { + commitClientNavigationState(); + } + }; + } } diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index 1c91a9c8f..fac593a01 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -12,6 +12,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test"; type Navigation = typeof import("../packages/vinext/src/shims/navigation.js"); +type CachedRscResponse = import("../packages/vinext/src/shims/navigation.js").CachedRscResponse; let storePrefetchResponse: Navigation["storePrefetchResponse"]; let getPrefetchCache: Navigation["getPrefetchCache"]; let getPrefetchedUrls: Navigation["getPrefetchedUrls"]; @@ -42,19 +43,30 @@ afterEach(() => { delete (globalThis as any).window; }); +/** Helper: create a CachedRscResponse snapshot from a string body. */ +function createSnapshot(body: string): CachedRscResponse { + return { + body: new TextEncoder().encode(body).buffer, + headers: [], + status: 200, + statusText: "OK", + url: "", + }; +} + /** Helper: fill cache with `count` entries at a given timestamp. */ function fillCache(count: number, timestamp: number, keyPrefix = "/page-"): void { const cache = getPrefetchCache(); const prefetched = getPrefetchedUrls(); for (let i = 0; i < count; i++) { const key = `${keyPrefix}${i}.rsc`; - cache.set(key, { response: new Response(`body-${i}`), timestamp }); + cache.set(key, { response: createSnapshot(`body-${i}`), timestamp }); prefetched.add(key); } } describe("prefetch cache eviction", () => { - it("sweeps all expired entries before FIFO", () => { + it("sweeps all expired entries before FIFO", async () => { // Use fixed arbitrary values to avoid any dependency on the real wall clock const now = 1_000_000; const expired = now - PREFETCH_CACHE_TTL - 1_000; // 31s before `now` @@ -64,7 +76,7 @@ describe("prefetch cache eviction", () => { expect(getPrefetchedUrls().size).toBe(MAX_PREFETCH_CACHE_SIZE); vi.spyOn(Date, "now").mockReturnValue(now); - storePrefetchResponse("/new.rsc", new Response("new")); + await storePrefetchResponse("/new.rsc", new Response("new")); const cache = getPrefetchCache(); expect(cache.size).toBe(1); @@ -73,7 +85,7 @@ describe("prefetch cache eviction", () => { expect(getPrefetchedUrls().size).toBe(0); }); - it("falls back to FIFO when all entries are fresh", () => { + it("falls back to FIFO when all entries are fresh", async () => { // Use fixed arbitrary values to avoid any dependency on the real wall clock const now = 1_000_000; @@ -82,7 +94,7 @@ describe("prefetch cache eviction", () => { expect(getPrefetchedUrls().size).toBe(MAX_PREFETCH_CACHE_SIZE); vi.spyOn(Date, "now").mockReturnValue(now); - storePrefetchResponse("/new.rsc", new Response("new")); + await storePrefetchResponse("/new.rsc", new Response("new")); const cache = getPrefetchCache(); // FIFO evicted one, new one added → still at capacity @@ -97,7 +109,7 @@ describe("prefetch cache eviction", () => { expect(getPrefetchedUrls().has("/page-0.rsc")).toBe(false); }); - it("sweeps only expired entries when cache has a mix", () => { + it("sweeps only expired entries when cache has a mix", async () => { // Use fixed arbitrary values to avoid any dependency on the real wall clock const now = 1_000_000; const expired = now - PREFETCH_CACHE_TTL - 1_000; @@ -111,7 +123,7 @@ describe("prefetch cache eviction", () => { expect(getPrefetchedUrls().size).toBe(MAX_PREFETCH_CACHE_SIZE); vi.spyOn(Date, "now").mockReturnValue(now); - storePrefetchResponse("/new.rsc", new Response("new")); + await storePrefetchResponse("/new.rsc", new Response("new")); const cache = getPrefetchCache(); // expired swept, fresh kept, 1 new added @@ -130,7 +142,7 @@ describe("prefetch cache eviction", () => { expect(getPrefetchedUrls().size).toBe(rest); }); - it("does not sweep when cache is below capacity", () => { + it("does not sweep when cache is below capacity", async () => { // Use fixed arbitrary values to avoid any dependency on the real wall clock const now = 1_000_000; const expired = now - PREFETCH_CACHE_TTL - 1_000; @@ -139,7 +151,7 @@ describe("prefetch cache eviction", () => { fillCache(belowCapacity, expired); vi.spyOn(Date, "now").mockReturnValue(now); - storePrefetchResponse("/new.rsc", new Response("new")); + await storePrefetchResponse("/new.rsc", new Response("new")); const cache = getPrefetchCache(); // Below capacity — no eviction, all entries kept + 1 new From 3303a3fa2a7621d43e7740f6c1baba09b8494121 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:39:48 +1100 Subject: [PATCH 03/17] test: add navigation regression e2e tests and fixtures Add Playwright tests for navigation flash regressions covering link-sync, param-sync, and query-sync scenarios with corresponding test fixture pages. --- .../app-router/navigation-regressions.spec.ts | 529 ++++++++++++++++++ .../app/nav-flash/link-sync/FilterLinks.tsx | 36 ++ .../app/nav-flash/link-sync/page.tsx | 56 ++ .../param-sync/[filter]/FilterControls.tsx | 28 + .../nav-flash/param-sync/[filter]/page.tsx | 39 ++ .../nav-flash/query-sync/FilterControls.tsx | 28 + .../app/nav-flash/query-sync/page.tsx | 56 ++ 7 files changed, 772 insertions(+) create mode 100644 tests/fixtures/app-basic/app/nav-flash/link-sync/FilterLinks.tsx create mode 100644 tests/fixtures/app-basic/app/nav-flash/link-sync/page.tsx create mode 100644 tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/FilterControls.tsx create mode 100644 tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/page.tsx create mode 100644 tests/fixtures/app-basic/app/nav-flash/query-sync/FilterControls.tsx create mode 100644 tests/fixtures/app-basic/app/nav-flash/query-sync/page.tsx diff --git a/tests/e2e/app-router/navigation-regressions.spec.ts b/tests/e2e/app-router/navigation-regressions.spec.ts index 72be279e9..8a54cc18c 100644 --- a/tests/e2e/app-router/navigation-regressions.spec.ts +++ b/tests/e2e/app-router/navigation-regressions.spec.ts @@ -99,6 +99,535 @@ async function readProbe(page: import("@playwright/test").Page, name: string) { } test.describe("App Router navigation regressions", () => { + test("client route params stay in sync with the committed tree during cold dynamic navigation", async ({ + page, + }) => { + await page.goto(`${BASE}/nav-flash/param-sync/alpha`); + await waitForHydration(page); + await expect(page.locator("#client-param-label")).toHaveText("Client param: alpha"); + await expect(page.locator("#param-filter-title")).toHaveText("Server param: alpha"); + await expect(page.locator("#param-filter-first-row")).toHaveText("Alpha 1"); + + await page.evaluate(() => { + let active = true; + const events: Array<{ + clientParam: string | null; + serverParam: string | null; + firstRow: string | null; + pathname: string; + }> = []; + + const capture = () => { + events.push({ + clientParam: document.querySelector("#client-param-label")?.textContent ?? null, + serverParam: document.querySelector("#param-filter-title")?.textContent ?? null, + firstRow: document.querySelector("#param-filter-first-row")?.textContent ?? null, + pathname: window.location.pathname, + }); + }; + + capture(); + const sample = () => { + if (!active) return; + capture(); + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + + ( + window as typeof window & { + __vinextParamSyncProbe__?: typeof events; + __stopVinextParamSyncProbe__?: () => void; + } + ).__vinextParamSyncProbe__ = events; + ( + window as typeof window & { + __vinextParamSyncProbe__?: typeof events; + __stopVinextParamSyncProbe__?: () => void; + } + ).__stopVinextParamSyncProbe__ = () => { + active = false; + }; + }); + + await page.locator("#param-filter-beta").click(); + await expect(page.locator("#client-param-label")).toHaveText("Client param: beta"); + await expect(page.locator("#param-filter-title")).toHaveText("Server param: beta"); + await expect(page.locator("#param-filter-first-row")).toHaveText("Beta 1"); + await page.waitForTimeout(100); + + const events = await page.evaluate(() => { + ( + window as typeof window & { + __stopVinextParamSyncProbe__?: () => void; + __vinextParamSyncProbe__?: Array<{ + clientParam: string | null; + serverParam: string | null; + firstRow: string | null; + pathname: string; + }>; + } + ).__stopVinextParamSyncProbe__?.(); + + return ( + ( + window as typeof window & { + __vinextParamSyncProbe__?: Array<{ + clientParam: string | null; + serverParam: string | null; + firstRow: string | null; + pathname: string; + }>; + } + ).__vinextParamSyncProbe__ ?? [] + ); + }); + + expect( + events.some( + (event) => + event.clientParam === "Client param: beta" && + (event.serverParam !== "Server param: beta" || event.firstRow !== "Beta 1"), + ), + ).toBe(false); + }); + + test("client URL hooks stay in sync with the committed tree during cold filter navigation", async ({ + page, + }) => { + await page.goto(`${BASE}/nav-flash/query-sync`); + await waitForHydration(page); + await expect(page.locator("#client-filter-label")).toHaveText("Client filter: alpha"); + await expect(page.locator("#query-filter-title")).toHaveText("Server filter: alpha"); + await expect(page.locator("#query-filter-first-row")).toHaveText("Alpha 1"); + + await page.evaluate(() => { + let active = true; + const events: Array<{ + clientFilter: string | null; + serverFilter: string | null; + firstRow: string | null; + search: string; + }> = []; + + const capture = () => { + events.push({ + clientFilter: document.querySelector("#client-filter-label")?.textContent ?? null, + serverFilter: document.querySelector("#query-filter-title")?.textContent ?? null, + firstRow: document.querySelector("#query-filter-first-row")?.textContent ?? null, + search: window.location.search, + }); + }; + + capture(); + const sample = () => { + if (!active) return; + capture(); + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + + ( + window as typeof window & { + __vinextQuerySyncProbe__?: typeof events; + __stopVinextQuerySyncProbe__?: () => void; + } + ).__vinextQuerySyncProbe__ = events; + ( + window as typeof window & { + __vinextQuerySyncProbe__?: typeof events; + __stopVinextQuerySyncProbe__?: () => void; + } + ).__stopVinextQuerySyncProbe__ = () => { + active = false; + }; + }); + + await page.locator("#query-filter-beta").click(); + const paintedFrames: Array<{ + clientFilter: string | null; + serverFilter: string | null; + firstRow: string | null; + search: string; + }> = []; + for (let i = 0; i < 90; i++) { + await page.waitForTimeout(16); + paintedFrames.push( + await page.evaluate(() => ({ + clientFilter: document.querySelector("#client-filter-label")?.textContent ?? null, + serverFilter: document.querySelector("#query-filter-title")?.textContent ?? null, + firstRow: document.querySelector("#query-filter-first-row")?.textContent ?? null, + search: window.location.search, + })), + ); + } + await expect(page.locator("#client-filter-label")).toHaveText("Client filter: beta"); + await expect(page.locator("#query-filter-title")).toHaveText("Server filter: beta"); + await expect(page.locator("#query-filter-first-row")).toHaveText("Beta 1"); + await page.waitForTimeout(100); + + const events = await page.evaluate(() => { + ( + window as typeof window & { + __stopVinextQuerySyncProbe__?: () => void; + __vinextQuerySyncProbe__?: Array<{ + clientFilter: string | null; + serverFilter: string | null; + firstRow: string | null; + search: string; + }>; + } + ).__stopVinextQuerySyncProbe__?.(); + + return ( + ( + window as typeof window & { + __vinextQuerySyncProbe__?: Array<{ + clientFilter: string | null; + serverFilter: string | null; + firstRow: string | null; + search: string; + }>; + } + ).__vinextQuerySyncProbe__ ?? [] + ); + }); + + expect( + events.some( + (event) => + event.clientFilter === "Client filter: beta" && + (event.serverFilter !== "Server filter: beta" || event.firstRow !== "Beta 1"), + ), + ).toBe(false); + expect( + paintedFrames.some( + (event) => + event.serverFilter === "Server filter: beta" && + (event.clientFilter !== "Client filter: beta" || + event.firstRow !== "Beta 1" || + event.search !== "?filter=beta"), + ), + ).toBe(false); + }); + + test("cold filter navigation does not reveal the destination shell before its list is ready", async ({ + page, + }) => { + await page.goto(`${BASE}/nav-flash/query-sync`); + await waitForHydration(page); + await expect(page.locator("#query-filter-title")).toHaveText("Server filter: alpha"); + await expect(page.locator("#query-filter-first-row")).toHaveText("Alpha 1"); + + await page.evaluate(() => { + let active = true; + const events: Array<{ + heading: string | null; + loadingVisible: boolean; + firstRow: string | null; + }> = []; + + const capture = () => { + events.push({ + heading: document.querySelector("#query-filter-title")?.textContent ?? null, + loadingVisible: !!document.querySelector("#query-filter-loading"), + firstRow: document.querySelector("#query-filter-first-row")?.textContent ?? null, + }); + }; + + capture(); + const sample = () => { + if (!active) return; + capture(); + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + + ( + window as typeof window & { + __vinextColdShellProbe__?: typeof events; + __stopVinextColdShellProbe__?: () => void; + } + ).__vinextColdShellProbe__ = events; + ( + window as typeof window & { + __vinextColdShellProbe__?: typeof events; + __stopVinextColdShellProbe__?: () => void; + } + ).__stopVinextColdShellProbe__ = () => { + active = false; + }; + }); + + await page.locator("#query-filter-beta").click(); + await expect(page.locator("#query-filter-title")).toHaveText("Server filter: beta"); + await expect(page.locator("#query-filter-first-row")).toHaveText("Beta 1"); + await page.waitForTimeout(100); + + const events = await page.evaluate(() => { + ( + window as typeof window & { + __stopVinextColdShellProbe__?: () => void; + __vinextColdShellProbe__?: Array<{ + heading: string | null; + loadingVisible: boolean; + firstRow: string | null; + }>; + } + ).__stopVinextColdShellProbe__?.(); + + return ( + ( + window as typeof window & { + __vinextColdShellProbe__?: Array<{ + heading: string | null; + loadingVisible: boolean; + firstRow: string | null; + }>; + } + ).__vinextColdShellProbe__ ?? [] + ); + }); + + expect( + events.some( + (event) => + event.heading === "Server filter: beta" && + (event.loadingVisible || event.firstRow !== "Beta 1"), + ), + ).toBe(false); + }); + + test("fresh filter navigation suppresses mount-time row animations on the first visible frame", async ({ + page, + }) => { + await page.goto(`${BASE}/nav-flash/query-sync`); + await waitForHydration(page); + await expect(page.locator("#query-filter-title")).toHaveText("Server filter: alpha"); + await expect(page.locator("#query-filter-first-row")).toHaveText("Alpha 1"); + + await page.evaluate(() => { + let active = true; + const events: Array<{ + heading: string | null; + firstRow: string | null; + firstRowOpacity: string | null; + firstRowAnimation: string | null; + }> = []; + + const capture = () => { + const firstRow = document.querySelector("#query-filter-first-row"); + const style = firstRow ? getComputedStyle(firstRow) : null; + events.push({ + heading: document.querySelector("#query-filter-title")?.textContent ?? null, + firstRow: firstRow?.textContent ?? null, + firstRowOpacity: style?.opacity ?? null, + firstRowAnimation: style?.animationName ?? null, + }); + }; + + capture(); + const sample = () => { + if (!active) return; + capture(); + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + + ( + window as typeof window & { + __vinextAnimationProbe__?: typeof events; + __stopVinextAnimationProbe__?: () => void; + } + ).__vinextAnimationProbe__ = events; + ( + window as typeof window & { + __vinextAnimationProbe__?: typeof events; + __stopVinextAnimationProbe__?: () => void; + } + ).__stopVinextAnimationProbe__ = () => { + active = false; + }; + }); + + await page.locator("#query-filter-beta").click(); + await expect(page.locator("#query-filter-title")).toHaveText("Server filter: beta"); + await expect(page.locator("#query-filter-first-row")).toHaveText("Beta 1"); + await page.waitForTimeout(100); + + const events = await page.evaluate(() => { + ( + window as typeof window & { + __stopVinextAnimationProbe__?: () => void; + __vinextAnimationProbe__?: Array<{ + heading: string | null; + firstRow: string | null; + firstRowOpacity: string | null; + firstRowAnimation: string | null; + }>; + } + ).__stopVinextAnimationProbe__?.(); + + return ( + ( + window as typeof window & { + __vinextAnimationProbe__?: Array<{ + heading: string | null; + firstRow: string | null; + firstRowOpacity: string | null; + firstRowAnimation: string | null; + }>; + } + ).__vinextAnimationProbe__ ?? [] + ); + }); + + const firstDestinationFrame = events.find( + (event) => event.heading === "Server filter: beta" && event.firstRow === "Beta 1", + ); + + expect(firstDestinationFrame).toBeTruthy(); + expect(firstDestinationFrame?.firstRowOpacity).toBe("1"); + expect(firstDestinationFrame?.firstRowAnimation).toBe("none"); + }); + + test("cold Link navigation keeps client URL state aligned with the committed tree", async ({ + page, + }) => { + await page.goto(`${BASE}/nav-flash/link-sync`); + await waitForHydration(page); + await expect(page.locator("#link-client-filter-label")).toHaveText("Client filter: alpha"); + await expect(page.locator("#link-filter-title")).toHaveText("Server filter: alpha"); + await expect(page.locator("#link-filter-first-row")).toHaveText("Alpha 1"); + + await page.evaluate(() => { + let active = true; + const events: Array<{ + clientFilter: string | null; + serverFilter: string | null; + firstRow: string | null; + firstRowOpacity: string | null; + firstRowAnimation: string | null; + search: string; + }> = []; + + const capture = () => { + const firstRow = document.querySelector("#link-filter-first-row"); + const style = firstRow ? getComputedStyle(firstRow) : null; + events.push({ + clientFilter: document.querySelector("#link-client-filter-label")?.textContent ?? null, + serverFilter: document.querySelector("#link-filter-title")?.textContent ?? null, + firstRow: firstRow?.textContent ?? null, + firstRowOpacity: style?.opacity ?? null, + firstRowAnimation: style?.animationName ?? null, + search: window.location.search, + }); + }; + + capture(); + const sample = () => { + if (!active) return; + capture(); + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + + ( + window as typeof window & { + __vinextLinkSyncProbe__?: typeof events; + __stopVinextLinkSyncProbe__?: () => void; + } + ).__vinextLinkSyncProbe__ = events; + ( + window as typeof window & { + __vinextLinkSyncProbe__?: typeof events; + __stopVinextLinkSyncProbe__?: () => void; + } + ).__stopVinextLinkSyncProbe__ = () => { + active = false; + }; + }); + + await page.locator("#link-filter-beta").click(); + await expect(page.locator("#link-client-filter-label")).toHaveText("Client filter: beta"); + await expect(page.locator("#link-filter-title")).toHaveText("Server filter: beta"); + await expect(page.locator("#link-filter-first-row")).toHaveText("Beta 1"); + await page.waitForTimeout(100); + + const events = await page.evaluate(() => { + ( + window as typeof window & { + __stopVinextLinkSyncProbe__?: () => void; + __vinextLinkSyncProbe__?: Array<{ + clientFilter: string | null; + serverFilter: string | null; + firstRow: string | null; + firstRowOpacity: string | null; + firstRowAnimation: string | null; + search: string; + }>; + } + ).__stopVinextLinkSyncProbe__?.(); + + return ( + ( + window as typeof window & { + __vinextLinkSyncProbe__?: Array<{ + clientFilter: string | null; + serverFilter: string | null; + firstRow: string | null; + firstRowOpacity: string | null; + firstRowAnimation: string | null; + search: string; + }>; + } + ).__vinextLinkSyncProbe__ ?? [] + ); + }); + + expect( + events.some( + (event) => + event.clientFilter === "Client filter: beta" && + (event.serverFilter !== "Server filter: beta" || event.firstRow !== "Beta 1"), + ), + ).toBe(false); + + const firstDestinationFrame = events.find( + (event) => event.serverFilter === "Server filter: beta" && event.firstRow === "Beta 1", + ); + + expect(firstDestinationFrame).toBeTruthy(); + expect(firstDestinationFrame?.firstRowOpacity).toBe("1"); + expect(firstDestinationFrame?.firstRowAnimation).toBe("none"); + }); + + test("hover prefetch reuses the in-flight RSC request on immediate Link click", async ({ + page, + }) => { + await page.goto(`${BASE}/nav-flash/link-sync`); + await waitForHydration(page); + + const betaRscRequests: string[] = []; + page.on("request", (request) => { + if (request.url().includes("/nav-flash/link-sync.rsc?filter=beta")) { + betaRscRequests.push(request.url()); + } + }); + + await page.locator("#link-filter-beta").hover(); + await page.waitForTimeout(50); + + expect(betaRscRequests.length).toBeGreaterThan(0); + + await page.locator("#link-filter-beta").click(); + await expect(page.locator("#link-client-filter-label")).toHaveText("Client filter: beta"); + await expect(page.locator("#link-filter-title")).toHaveText("Server filter: beta"); + await expect(page.locator("#link-filter-first-row")).toHaveText("Beta 1"); + + expect(betaRscRequests).toHaveLength(1); + }); + test("returning to the providers list via Link does not flash the loading fallback", async ({ page, }) => { diff --git a/tests/fixtures/app-basic/app/nav-flash/link-sync/FilterLinks.tsx b/tests/fixtures/app-basic/app/nav-flash/link-sync/FilterLinks.tsx new file mode 100644 index 000000000..67e4866aa --- /dev/null +++ b/tests/fixtures/app-basic/app/nav-flash/link-sync/FilterLinks.tsx @@ -0,0 +1,36 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; + +export function FilterLinks() { + const router = useRouter(); + const searchParams = useSearchParams(); + const filter = searchParams.get("filter") ?? "alpha"; + + return ( +
+ + router.prefetch("/nav-flash/link-sync?filter=alpha")} + onFocus={() => router.prefetch("/nav-flash/link-sync?filter=alpha")} + > + Alpha + + router.prefetch("/nav-flash/link-sync?filter=beta")} + onFocus={() => router.prefetch("/nav-flash/link-sync?filter=beta")} + > + Beta + +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nav-flash/link-sync/page.tsx b/tests/fixtures/app-basic/app/nav-flash/link-sync/page.tsx new file mode 100644 index 000000000..1ddb9b378 --- /dev/null +++ b/tests/fixtures/app-basic/app/nav-flash/link-sync/page.tsx @@ -0,0 +1,56 @@ +import { Suspense } from "react"; +import { unstable_noStore as noStore } from "next/cache"; +import { FilterLinks } from "./FilterLinks"; + +type SearchParams = Promise<{ + filter?: string; +}>; + +async function SlowList({ filter }: { filter: string }) { + noStore(); + await new Promise((resolve) => setTimeout(resolve, 700)); + + const rows = + filter === "beta" ? ["Beta 1", "Beta 2", "Beta 3"] : ["Alpha 1", "Alpha 2", "Alpha 3"]; + + return ( + <> + + + + ); +} + +export default async function LinkSyncPage({ searchParams }: { searchParams: SearchParams }) { + const filter = (await searchParams).filter ?? "alpha"; + + return ( +
+

Server filter: {filter}

+ + Loading...

}> + +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/FilterControls.tsx b/tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/FilterControls.tsx new file mode 100644 index 000000000..4b075951b --- /dev/null +++ b/tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/FilterControls.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { startTransition } from "react"; +import { useParams, useRouter } from "next/navigation"; + +export function FilterControls() { + const router = useRouter(); + const params = useParams<{ filter: string }>(); + const filter = params.filter ?? "alpha"; + + function navigate(nextFilter: string) { + startTransition(() => { + router.push(`/nav-flash/param-sync/${nextFilter}`, { scroll: false }); + }); + } + + return ( +
+

Client param: {filter}

+ + +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/page.tsx b/tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/page.tsx new file mode 100644 index 000000000..41e04badd --- /dev/null +++ b/tests/fixtures/app-basic/app/nav-flash/param-sync/[filter]/page.tsx @@ -0,0 +1,39 @@ +import { Suspense } from "react"; +import { unstable_noStore as noStore } from "next/cache"; +import { FilterControls } from "./FilterControls"; + +type Params = Promise<{ + filter: string; +}>; + +async function SlowList({ filter }: { filter: string }) { + noStore(); + await new Promise((resolve) => setTimeout(resolve, 700)); + + const rows = + filter === "beta" ? ["Beta 1", "Beta 2", "Beta 3"] : ["Alpha 1", "Alpha 2", "Alpha 3"]; + + return ( +
    + {rows.map((row, index) => ( +
  • + {row} +
  • + ))} +
+ ); +} + +export default async function ParamSyncPage({ params }: { params: Params }) { + const filter = (await params).filter ?? "alpha"; + + return ( +
+

Server param: {filter}

+ + Loading...

}> + +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/nav-flash/query-sync/FilterControls.tsx b/tests/fixtures/app-basic/app/nav-flash/query-sync/FilterControls.tsx new file mode 100644 index 000000000..915ebe776 --- /dev/null +++ b/tests/fixtures/app-basic/app/nav-flash/query-sync/FilterControls.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { startTransition } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +export function FilterControls() { + const router = useRouter(); + const searchParams = useSearchParams(); + const filter = searchParams.get("filter") ?? "alpha"; + + function navigate(nextFilter: string) { + startTransition(() => { + router.push(`/nav-flash/query-sync?filter=${nextFilter}`, { scroll: false }); + }); + } + + return ( +
+

Client filter: {filter}

+ + +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nav-flash/query-sync/page.tsx b/tests/fixtures/app-basic/app/nav-flash/query-sync/page.tsx new file mode 100644 index 000000000..09932da0f --- /dev/null +++ b/tests/fixtures/app-basic/app/nav-flash/query-sync/page.tsx @@ -0,0 +1,56 @@ +import { Suspense } from "react"; +import { unstable_noStore as noStore } from "next/cache"; +import { FilterControls } from "./FilterControls"; + +type SearchParams = Promise<{ + filter?: string; +}>; + +async function SlowList({ filter }: { filter: string }) { + noStore(); + await new Promise((resolve) => setTimeout(resolve, 700)); + + const rows = + filter === "beta" ? ["Beta 1", "Beta 2", "Beta 3"] : ["Alpha 1", "Alpha 2", "Alpha 3"]; + + return ( + <> + +
    + {rows.map((row, index) => ( +
  • + {row} +
  • + ))} +
+ + ); +} + +export default async function QuerySyncPage({ searchParams }: { searchParams: SearchParams }) { + const filter = (await searchParams).filter ?? "alpha"; + + return ( +
+

Server filter: {filter}

+ + Loading...

}> + +
+
+ ); +} From 4f313fc1f73db95cc8a0baf8559be9aa0ebbcd98 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:44:30 +1100 Subject: [PATCH 04/17] fix: update form tests for navigateClientSide calling convention Form component now delegates to navigateClientSide instead of calling pushState + navigate separately. Update test assertions to expect the new 4-arg __VINEXT_RSC_NAVIGATE__ signature and add missing window mock properties (pathname, search, hash, scrollX/Y). --- tests/form.test.ts | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/tests/form.test.ts b/tests/form.test.ts index 034feebe2..8538f4992 100644 --- a/tests/form.test.ts +++ b/tests/form.test.ts @@ -109,12 +109,20 @@ function createWindowStub() { history: { pushState, replaceState, + state: null, }, location: { origin: "http://localhost:3000", href: "http://localhost:3000/current", + pathname: "/current", + search: "", + hash: "", }, + scrollX: 0, + scrollY: 0, scrollTo, + addEventListener: () => {}, + dispatchEvent: () => {}, }, }; } @@ -240,7 +248,7 @@ describe("Form useActionState", () => { describe("Form client GET interception", () => { it("strips existing query params from the action URL and warns in development", async () => { - const { navigate, pushState, scrollTo } = installClientGlobals({ supportsSubmitter: true }); + const { navigate } = installClientGlobals({ supportsSubmitter: true }); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const { onSubmit } = renderClientForm({ action: "/search?lang=en" }); const event = createSubmitEvent({ @@ -253,13 +261,11 @@ describe("Form client GET interception", () => { '
received an `action` that contains search params: "/search?lang=en". This is not supported, and they will be ignored. If you need to pass in additional search params, use an `` instead.', ); expect(event.preventDefault).toHaveBeenCalledOnce(); - expect(pushState).toHaveBeenCalledWith(null, "", "/search?q=react"); - expect(navigate).toHaveBeenCalledWith("/search?q=react"); - expect(scrollTo).toHaveBeenCalledWith(0, 0); + expect(navigate).toHaveBeenCalledWith("/search?q=react", 0, "navigate", "push"); }); it("honors submitter formAction, formMethod, and submitter name/value", async () => { - const { navigate, pushState } = installClientGlobals({ supportsSubmitter: true }); + const { navigate } = installClientGlobals({ supportsSubmitter: true }); const { onSubmit } = renderClientForm({ action: "/search", method: "POST" }); const submitter = new FakeButtonElement({ attributes: { @@ -280,12 +286,12 @@ describe("Form client GET interception", () => { await onSubmit(event); expect(event.preventDefault).toHaveBeenCalledOnce(); - expect(pushState).toHaveBeenCalledWith( - null, - "", + expect(navigate).toHaveBeenCalledWith( "/search-alt?q=button&lang=fr&source=submitter-action", + 0, + "navigate", + "push", ); - expect(navigate).toHaveBeenCalledWith("/search-alt?q=button&lang=fr&source=submitter-action"); }); it("falls back to appending submitter name/value when FormData submitter overload is unavailable", async () => { @@ -310,6 +316,9 @@ describe("Form client GET interception", () => { expect(navigate).toHaveBeenCalledWith( "/search-alt?q=fallback&lang=de&source=fallback-submitter", + 0, + "navigate", + "push", ); }); @@ -328,7 +337,7 @@ describe("Form client GET interception", () => { }); it("strips submitter formAction query params and warns in development", async () => { - const { navigate, pushState } = installClientGlobals({ supportsSubmitter: true }); + const { navigate } = installClientGlobals({ supportsSubmitter: true }); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const { onSubmit } = renderClientForm({ action: "/search" }); const submitter = new FakeButtonElement({ @@ -348,12 +357,12 @@ describe("Form client GET interception", () => { expect(warn).toHaveBeenCalledWith( ' received a `formAction` that contains search params: "/search-alt?lang=fr". This is not supported, and they will be ignored. If you need to pass in additional search params, use an `` instead.', ); - expect(pushState).toHaveBeenCalledWith( - null, - "", + expect(navigate).toHaveBeenCalledWith( "/search-alt?q=button&source=submitter-action", + 0, + "navigate", + "push", ); - expect(navigate).toHaveBeenCalledWith("/search-alt?q=button&source=submitter-action"); }); it("does not intercept submitters with unsupported formTarget overrides", async () => { From 85df5b789814fcb46f7fbd52f27e0ac6f4c3d0c6 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:00:24 +1100 Subject: [PATCH 05/17] fix: revert behavioral changes to match Codex's original intent Restore useEffect in NavigationCommitSignal, restore querySelectorAll-based animation suppression, and restore separate effect hooks. --- .../vinext/src/server/app-browser-entry.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 75099f551..f15d926d1 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -4,6 +4,7 @@ import { createElement, startTransition, use, + useEffect, useLayoutEffect, useState, type Dispatch, @@ -116,25 +117,21 @@ function clearClientNavigationCaches(): void { } function suppressFreshNavigationAnimations(): void { - if (typeof document === "undefined" || typeof document.getAnimations !== "function") { + if (typeof document === "undefined") { return; } - for (const animation of document.getAnimations()) { - if (!(animation.effect instanceof KeyframeEffect)) { + for (const element of document.body.querySelectorAll("*")) { + const style = window.getComputedStyle(element); + if (style.animationName === "none") { continue; } - const target = animation.effect.target; - if (!(target instanceof HTMLElement)) { + if (Number(style.opacity) > 0.01) { continue; } - if (Number(window.getComputedStyle(target).opacity) > 0.01) { - continue; - } - - animation.cancel(); + element.style.animation = "none"; } } @@ -262,7 +259,9 @@ function resolveCommittedNavigations(renderId: number): void { function NavigationCommitSignal({ children, renderId }: { children: ReactNode; renderId: number }) { useLayoutEffect(() => { runPrePaintNavigationEffect(renderId); + }, [renderId]); + useEffect(() => { const frame = requestAnimationFrame(() => { resolveCommittedNavigations(renderId); }); From c86494514aa77ea51696a792b4792ea21e8af0c3 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:47:12 +1100 Subject: [PATCH 06/17] fix: buffer RSC responses, fix shallow routing, server action redirects, and Firefox nav hang - Buffer full RSC response before createFromFetch to prevent flight parser microtask interleaving that causes partial tree commits on cold navigations - Await createFromFetch to fully resolve the tree before rendering - Add navigation snapshot activation counter so hooks only prefer the render snapshot context during active transitions, fixing pushState/ replaceState reactivity (shallow routing) - Restore immediate history.pushState for server action redirects and use fire-and-forget navigate to avoid useTransition/startTransition deadlock - Always call commitClientNavigationState after navigation commit - Don't block navigation on pending prefetch responses, matching Next.js segment cache behavior (fixes Firefox nav bar hang) - Always run animation suppression for all navigations, not just cold fetches Ref #639 --- .../vinext/src/server/app-browser-entry.ts | 53 +++++++------- packages/vinext/src/shims/navigation.ts | 70 ++++++++++++++----- .../app-router/navigation-regressions.spec.ts | 17 ++--- 3 files changed, 85 insertions(+), 55 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index f15d926d1..bac296451 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -21,6 +21,7 @@ import { } from "@vitejs/plugin-rsc/browser"; import { hydrateRoot } from "react-dom/client"; import { + activateNavigationSnapshot, commitClientNavigationState, consumePrefetchResponse, createClientNavigationRenderSnapshot, @@ -35,6 +36,7 @@ import { snapshotRscResponse, setNavigationContext, toRscUrl, + type CachedRscResponse, type ClientNavigationRenderSnapshot, } from "../shims/navigation.js"; import { @@ -169,13 +171,8 @@ function composePrePaintNavigationEffects( function createNavigationCommitEffect( href: string, - navigationKind: NavigationKind, historyUpdateMode: HistoryUpdateMode | undefined, -): (() => void) | null { - if (historyUpdateMode == null && navigationKind === "navigate") { - return null; - } - +): () => void { return () => { if (historyUpdateMode === "replace") { replaceHistoryStateWithoutNotify(null, "", href); @@ -230,13 +227,12 @@ function getVisitedResponse( return null; } -async function cacheVisitedResponse( +function storeVisitedResponseSnapshot( rscUrl: string, - response: Response, + snapshot: CachedRscResponse, params: Record = latestClientParams, -): Promise { +): void { const now = Date.now(); - const snapshot = await snapshotRscResponse(response); pruneVisitedResponseCache(now); visitedResponseCache.delete(rscUrl); evictVisitedResponseCacheIfNeeded(); @@ -347,6 +343,10 @@ function renderNavigationPayload( pendingNavigationCommits.set(renderId, resolve); }); + // Activate the snapshot so hooks prefer the context value during the + // transition render. Deactivated by commitClientNavigationState() in + // the pre-paint effect after the transition commits. + activateNavigationSnapshot(); updateBrowserTree(payload, navigationSnapshot, renderId, true); return committed; @@ -544,11 +544,7 @@ async function main(): Promise { const url = new URL(href, window.location.origin); const rscUrl = toRscUrl(url.pathname + url.search); const cachedRoute = getVisitedResponse(rscUrl, navigationKind); - const navigationCommitEffect = createNavigationCommitEffect( - href, - navigationKind, - historyUpdateMode, - ); + const navigationCommitEffect = createNavigationCommitEffect(href, historyUpdateMode); if (cachedRoute) { stageClientParams(cachedRoute.params); @@ -570,7 +566,7 @@ async function main(): Promise { let navResponse: Response | undefined; let navResponseUrl: string | null = null; if (navigationKind !== "refresh") { - const prefetchedResponse = await consumePrefetchResponse(rscUrl); + const prefetchedResponse = consumePrefetchResponse(rscUrl); if (prefetchedResponse) { navResponse = restoreRscResponse(prefetchedResponse); navResponseUrl = prefetchedResponse.url; @@ -612,17 +608,26 @@ async function main(): Promise { } const navigationSnapshot = createClientNavigationRenderSnapshot(href, latestClientParams); - void cacheVisitedResponse(rscUrl, navResponse.clone(), navParams).catch((error) => { - console.error("[vinext] Failed to cache visited RSC response:", error); - }); - const rscPayload = createFromFetch(Promise.resolve(navResponse)); + // Buffer the full RSC response before rendering. Without this, the flight + // parser processes the stream progressively — chunks interleave across + // microtask boundaries, causing React to commit a partially-resolved tree + // (e.g. list content updates before heading hooks catch up). Buffering + // ensures processBinaryChunk handles all flight rows in one synchronous + // pass, matching how cached/prefetched responses already work. + const responseSnapshot = await snapshotRscResponse(navResponse); + storeVisitedResponseSnapshot(rscUrl, responseSnapshot, navParams); + // Fully resolve the RSC tree before rendering. Even with a buffered + // response, the flight parser uses internal Promises that schedule + // microtasks between row processing. Awaiting the resolved tree ensures + // React receives a complete ReactNode with no pending lazy references, + // preventing intermediate Suspense fallback reveals during the transition. + const rscPayload = await createFromFetch( + Promise.resolve(restoreRscResponse(responseSnapshot)), + ); await renderNavigationPayload( rscPayload, navigationSnapshot, - composePrePaintNavigationEffects( - navigationCommitEffect, - navResponseUrl ? null : suppressFreshNavigationAnimations, - ), + composePrePaintNavigationEffects(navigationCommitEffect, suppressFreshNavigationAnimations), ); } catch (error) { console.error("[vinext] RSC navigation error:", error); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 98d81882b..5633aba10 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -382,29 +382,20 @@ export function prefetchRscResponse(rscUrl: string, responsePromise: Promise { +export function consumePrefetchResponse(rscUrl: string): CachedRscResponse | null { const cache = getPrefetchCache(); const entry = cache.get(rscUrl); if (!entry) { return null; } - if (entry.pendingResponse) { - const snapshot = await entry.pendingResponse; - const settledEntry = cache.get(rscUrl); - if (settledEntry?.response) { - deletePrefetchEntry(rscUrl); - return settledEntry.response; - } - if (snapshot) { - deletePrefetchEntry(rscUrl); - return snapshot; - } - return null; - } - + // Only consume settled (fully cached) responses — never block on pending + // ones. Next.js's segment cache does the same: if no settled prefetch + // exists, it fires a fresh dynamic request immediately rather than waiting + // for an in-flight prefetch that may hang or be slow (e.g. Firefox with + // non-standard fetch options). The pending prefetch will still complete in + // the background and cache itself for future navigations. if (!entry.response) { - deletePrefetchEntry(rscUrl); return null; } @@ -530,6 +521,29 @@ function getServerSearchParamsSnapshot(): ReadonlyURLSearchParams { return _cachedEmptyServerSearchParams; } +// --------------------------------------------------------------------------- +// Navigation snapshot activation flag +// +// The render snapshot context provides pending URL values during transitions. +// After the transition commits, the snapshot becomes stale and must NOT shadow +// subsequent external URL changes (user pushState/replaceState). This flag +// tracks whether a navigation transition is in progress — hooks only prefer +// the snapshot while it's active. +// --------------------------------------------------------------------------- + +let _navigationSnapshotActiveCount = 0; + +/** + * Mark a navigation snapshot as active. Called before startTransition + * in renderNavigationPayload. While active, hooks prefer the snapshot + * context value over useSyncExternalStore. Uses a counter (not boolean) + * to handle overlapping navigations — rapid clicks can interleave + * activate/deactivate if multiple transitions are in flight. + */ +export function activateNavigationSnapshot(): void { + _navigationSnapshotActiveCount++; +} + // Track client-side params (set during RSC hydration/navigation) // We cache the params object for referential stability — only create a new // object when the params actually change (shallow key/value comparison). @@ -630,7 +644,13 @@ export function usePathname(): string { getPathnameSnapshot, () => _getServerContext()?.pathname ?? "/", ); - return renderSnapshot?.pathname ?? pathname; + // Only use the render snapshot during an active navigation transition. + // After commit, fall through to useSyncExternalStore so user + // pushState/replaceState calls are immediately reflected. + if (renderSnapshot && _navigationSnapshotActiveCount > 0) { + return renderSnapshot.pathname; + } + return pathname; } /** @@ -648,7 +668,10 @@ export function useSearchParams(): ReadonlyURLSearchParams { getSearchParamsSnapshot, getServerSearchParamsSnapshot, ); - return renderSnapshot?.searchParams ?? searchParams; + if (renderSnapshot && _navigationSnapshotActiveCount > 0) { + return renderSnapshot.searchParams; + } + return searchParams; } /** @@ -667,7 +690,10 @@ export function useParams< getClientParamsSnapshot as () => T, getServerParamsSnapshot as () => T, ); - return (renderSnapshot?.params as T | undefined) ?? params; + if (renderSnapshot && _navigationSnapshotActiveCount > 0) { + return (renderSnapshot.params ?? params) as T; + } + return params; } /** @@ -732,6 +758,12 @@ export function commitClientNavigationState(): void { const state = getClientNavigationState(); if (!state) return; + // Deactivate the navigation snapshot so hooks fall through to the + // reactive useSyncExternalStore values. The committed URL/params are + // now up-to-date, and any subsequent pushState/replaceState calls + // must be immediately reflected in hooks. + _navigationSnapshotActiveCount = Math.max(0, _navigationSnapshotActiveCount - 1); + const urlChanged = syncCommittedUrlStateFromLocation(); if (state.pendingClientParams !== null && state.pendingClientParamsJson !== null) { state.clientParams = state.pendingClientParams; diff --git a/tests/e2e/app-router/navigation-regressions.spec.ts b/tests/e2e/app-router/navigation-regressions.spec.ts index 8a54cc18c..11ba3b588 100644 --- a/tests/e2e/app-router/navigation-regressions.spec.ts +++ b/tests/e2e/app-router/navigation-regressions.spec.ts @@ -602,30 +602,23 @@ test.describe("App Router navigation regressions", () => { expect(firstDestinationFrame?.firstRowAnimation).toBe("none"); }); - test("hover prefetch reuses the in-flight RSC request on immediate Link click", async ({ + test("hover prefetch followed by immediate click navigates without blocking on the prefetch", async ({ page, }) => { + // Next.js's segment cache does not block navigation on pending prefetches. + // If the prefetch hasn't settled by click time, a fresh dynamic request + // fires. This prevents navigation from hanging when prefetch is slow + // (e.g. Firefox with non-standard fetch options on Cloudflare Workers). await page.goto(`${BASE}/nav-flash/link-sync`); await waitForHydration(page); - const betaRscRequests: string[] = []; - page.on("request", (request) => { - if (request.url().includes("/nav-flash/link-sync.rsc?filter=beta")) { - betaRscRequests.push(request.url()); - } - }); - await page.locator("#link-filter-beta").hover(); await page.waitForTimeout(50); - expect(betaRscRequests.length).toBeGreaterThan(0); - await page.locator("#link-filter-beta").click(); await expect(page.locator("#link-client-filter-label")).toHaveText("Client filter: beta"); await expect(page.locator("#link-filter-title")).toHaveText("Server filter: beta"); await expect(page.locator("#link-filter-first-row")).toHaveText("Beta 1"); - - expect(betaRscRequests).toHaveLength(1); }); test("returning to the providers list via Link does not flash the loading fallback", async ({ From 4b57ce20c31b29c1df004ac1d6968723ef55c460 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:53:16 +1100 Subject: [PATCH 07/17] =?UTF-8?q?refactor:=20remove=20animation=20suppress?= =?UTF-8?q?ion=20=E2=80=94=20it=20needs=20an=20architectural=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit suppressFreshNavigationAnimations only caught animations with fill-mode: both/backwards (opacity-based check). Real-world CSS uses the default fill-mode (none), making the function a no-op. Next.js has zero animation suppression code — they avoid the problem via segment-level caching that only re-mounts changed segments. Remove the function, its composition helper, and E2E assertions that tested suppression behavior. The animation replay issue is tracked as a separate architectural concern (segment-level caching). --- .../vinext/src/server/app-browser-entry.ts | 40 +------------------ .../app-router/navigation-regressions.spec.ts | 4 -- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index bac296451..2b1806d72 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -118,25 +118,6 @@ function clearClientNavigationCaches(): void { clearPrefetchState(); } -function suppressFreshNavigationAnimations(): void { - if (typeof document === "undefined") { - return; - } - - for (const element of document.body.querySelectorAll("*")) { - const style = window.getComputedStyle(element); - if (style.animationName === "none") { - continue; - } - - if (Number(style.opacity) > 0.01) { - continue; - } - - element.style.animation = "none"; - } -} - function queuePrePaintNavigationEffect(renderId: number, effect: (() => void) | null): void { if (!effect) { return; @@ -154,21 +135,6 @@ function runPrePaintNavigationEffect(renderId: number): void { effect(); } -function composePrePaintNavigationEffects( - ...effects: Array<(() => void) | null | undefined> -): (() => void) | null { - const activeEffects = effects.filter((effect): effect is () => void => effect != null); - if (activeEffects.length === 0) { - return null; - } - - return () => { - for (const effect of activeEffects) { - effect(); - } - }; -} - function createNavigationCommitEffect( href: string, historyUpdateMode: HistoryUpdateMode | undefined, @@ -624,11 +590,7 @@ async function main(): Promise { const rscPayload = await createFromFetch( Promise.resolve(restoreRscResponse(responseSnapshot)), ); - await renderNavigationPayload( - rscPayload, - navigationSnapshot, - composePrePaintNavigationEffects(navigationCommitEffect, suppressFreshNavigationAnimations), - ); + await renderNavigationPayload(rscPayload, navigationSnapshot, navigationCommitEffect); } catch (error) { console.error("[vinext] RSC navigation error:", error); window.location.href = href; diff --git a/tests/e2e/app-router/navigation-regressions.spec.ts b/tests/e2e/app-router/navigation-regressions.spec.ts index 11ba3b588..1f0736cfe 100644 --- a/tests/e2e/app-router/navigation-regressions.spec.ts +++ b/tests/e2e/app-router/navigation-regressions.spec.ts @@ -487,8 +487,6 @@ test.describe("App Router navigation regressions", () => { ); expect(firstDestinationFrame).toBeTruthy(); - expect(firstDestinationFrame?.firstRowOpacity).toBe("1"); - expect(firstDestinationFrame?.firstRowAnimation).toBe("none"); }); test("cold Link navigation keeps client URL state aligned with the committed tree", async ({ @@ -598,8 +596,6 @@ test.describe("App Router navigation regressions", () => { ); expect(firstDestinationFrame).toBeTruthy(); - expect(firstDestinationFrame?.firstRowOpacity).toBe("1"); - expect(firstDestinationFrame?.firstRowAnimation).toBe("none"); }); test("hover prefetch followed by immediate click navigates without blocking on the prefetch", async ({ From 046087cbd5603416d54246b096a6e6b3f898ed74 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:55:32 +1100 Subject: [PATCH 08/17] refactor: replace useEffect with useLayoutEffect in NavigationCommitSignal Consolidate the two layout/effect hooks into one useLayoutEffect. The requestAnimationFrame fires after paint regardless of where it's scheduled from, so useEffect was unnecessary. --- packages/vinext/src/server/app-browser-entry.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 2b1806d72..663f143d4 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -4,7 +4,6 @@ import { createElement, startTransition, use, - useEffect, useLayoutEffect, useState, type Dispatch, @@ -221,9 +220,10 @@ function resolveCommittedNavigations(renderId: number): void { function NavigationCommitSignal({ children, renderId }: { children: ReactNode; renderId: number }) { useLayoutEffect(() => { runPrePaintNavigationEffect(renderId); - }, [renderId]); - useEffect(() => { + // Resolve the navigation commit promise after the browser paints. + // requestAnimationFrame fires after the next paint regardless of + // where it's scheduled from, so this works from useLayoutEffect. const frame = requestAnimationFrame(() => { resolveCommittedNavigations(renderId); }); From 072087e9c984626e744138160f177d36e97ff3c6 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:15:47 +1100 Subject: [PATCH 09/17] fix: skip startTransition for cross-route navigations (Firefox hang) React's startTransition hangs indefinitely in Firefox when replacing the entire component tree (cross-route navigation). The transition never commits, leaving the old page visible with no way to recover. Use startTransition only for same-route navigations (searchParam changes) where it keeps the old UI visible during loading. For cross-route navigations (different pathname), use synchronous state updates so the new page renders immediately. Also removes debug logging from the investigation. --- .../vinext/src/server/app-browser-entry.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 663f143d4..939e7cb9e 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -301,6 +301,7 @@ function renderNavigationPayload( payload: Promise | ReactNode, navigationSnapshot: ClientNavigationRenderSnapshot, prePaintEffect: (() => void) | null = null, + useTransition = true, ): Promise { const renderId = ++nextNavigationRenderId; queuePrePaintNavigationEffect(renderId, prePaintEffect); @@ -313,7 +314,7 @@ function renderNavigationPayload( // transition render. Deactivated by commitClientNavigationState() in // the pre-paint effect after the transition commits. activateNavigationSnapshot(); - updateBrowserTree(payload, navigationSnapshot, renderId, true); + updateBrowserTree(payload, navigationSnapshot, renderId, useTransition); return committed; } @@ -509,6 +510,11 @@ async function main(): Promise { try { const url = new URL(href, window.location.origin); const rscUrl = toRscUrl(url.pathname + url.search); + // 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 + // startTransition hangs in Firefox when replacing the entire tree. + const isSameRoute = url.pathname === window.location.pathname; const cachedRoute = getVisitedResponse(rscUrl, navigationKind); const navigationCommitEffect = createNavigationCommitEffect(href, historyUpdateMode); @@ -525,6 +531,7 @@ async function main(): Promise { cachedPayload, cachedNavigationSnapshot, navigationCommitEffect, + isSameRoute, ); return; } @@ -548,6 +555,7 @@ async function main(): Promise { const finalUrl = new URL(navResponseUrl ?? navResponse.url, window.location.origin); const requestedUrl = new URL(rscUrl, window.location.origin); + if (finalUrl.pathname !== requestedUrl.pathname) { const destinationPath = finalUrl.pathname.replace(/\.rsc$/, "") + finalUrl.search; @@ -580,17 +588,20 @@ async function main(): Promise { // (e.g. list content updates before heading hooks catch up). Buffering // ensures processBinaryChunk handles all flight rows in one synchronous // pass, matching how cached/prefetched responses already work. + const responseSnapshot = await snapshotRscResponse(navResponse); + storeVisitedResponseSnapshot(rscUrl, responseSnapshot, navParams); - // Fully resolve the RSC tree before rendering. Even with a buffered - // response, the flight parser uses internal Promises that schedule - // microtasks between row processing. Awaiting the resolved tree ensures - // React receives a complete ReactNode with no pending lazy references, - // preventing intermediate Suspense fallback reveals during the transition. - const rscPayload = await createFromFetch( + const rscPayload = createFromFetch( Promise.resolve(restoreRscResponse(responseSnapshot)), ); - await renderNavigationPayload(rscPayload, navigationSnapshot, navigationCommitEffect); + + await renderNavigationPayload( + rscPayload, + navigationSnapshot, + navigationCommitEffect, + isSameRoute, + ); } catch (error) { console.error("[vinext] RSC navigation error:", error); window.location.href = href; From 3afababb545dd56df4ec417af66753bd6d2381e4 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 23 Mar 2026 03:17:11 +1100 Subject: [PATCH 10/17] fix: address code review findings from elegance pass - Increment nextNavigationRenderId for server action renders to avoid stale renderId collisions with navigation commits - Deactivate snapshot counter on navigation failure to prevent hooks from permanently returning stale values - Remove navigateClientSide wrapper (was 1:1 pass-through of navigateImpl) - Remove dead fallback URL update block in navigateClientSide that could never fire (createNavigationCommitEffect always pushes history) - Rename animation test to match what it actually asserts --- .../vinext/src/server/app-browser-entry.ts | 7 ++++-- packages/vinext/src/shims/navigation.ts | 23 ++----------------- .../app-router/navigation-regressions.spec.ts | 2 +- 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 939e7cb9e..50e787f58 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -452,7 +452,7 @@ function registerServerActionCallback(): void { updateBrowserTree( result.root, createClientNavigationRenderSnapshot(window.location.href, latestClientParams), - nextNavigationRenderId, + ++nextNavigationRenderId, false, ); if (result.returnValue) { @@ -465,7 +465,7 @@ function registerServerActionCallback(): void { updateBrowserTree( result, createClientNavigationRenderSnapshot(window.location.href, latestClientParams), - nextNavigationRenderId, + ++nextNavigationRenderId, false, ); return result; @@ -603,6 +603,9 @@ async function main(): Promise { isSameRoute, ); } catch (error) { + // Deactivate the snapshot counter in case it was incremented before + // the error — prevents hooks from permanently returning stale values. + commitClientNavigationState(); console.error("[vinext] RSC navigation error:", error); window.location.href = href; } diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 5633aba10..5a0bcb932 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -863,7 +863,7 @@ function restoreScrollPosition(state: unknown): void { /** * Navigate to a URL, handling external URLs, hash-only changes, and RSC navigation. */ -async function navigateImpl( +export async function navigateClientSide( href: string, mode: "push" | "replace", scroll: boolean, @@ -909,7 +909,6 @@ async function navigateImpl( // Extract hash for post-navigation scrolling const hashIdx = fullHref.indexOf("#"); const hash = hashIdx !== -1 ? fullHref.slice(hashIdx) : ""; - const previousHref = window.location.pathname + window.location.search + window.location.hash; // Trigger RSC re-fetch if available, and wait for the new content to render // before scrolling. This prevents the old page from visibly jumping to the @@ -925,16 +924,6 @@ async function navigateImpl( commitClientNavigationState(); } - const currentHref = window.location.pathname + window.location.search + window.location.hash; - if (currentHref === previousHref) { - if (mode === "replace") { - replaceHistoryStateWithoutNotify(null, "", fullHref); - } else { - pushHistoryStateWithoutNotify(null, "", fullHref); - } - commitClientNavigationState(); - } - if (scroll) { if (hash) { scrollToHash(hash); @@ -944,18 +933,10 @@ async function navigateImpl( } } -export async function navigateClientSide( - href: string, - mode: "push" | "replace", - scroll: boolean, -): Promise { - await navigateImpl(href, mode, scroll); -} - // --------------------------------------------------------------------------- // App Router router singleton // -// All methods close over module-level state (navigateImpl, withBasePath, etc.) +// All methods close over module-level state (navigateClientSide, withBasePath, etc.) // and carry no per-render data, so the object can be created once and reused. // Next.js returns the same router reference on every call to useRouter(), which // matters for components that rely on referential equality (e.g. useMemo / diff --git a/tests/e2e/app-router/navigation-regressions.spec.ts b/tests/e2e/app-router/navigation-regressions.spec.ts index 1f0736cfe..17ed272e2 100644 --- a/tests/e2e/app-router/navigation-regressions.spec.ts +++ b/tests/e2e/app-router/navigation-regressions.spec.ts @@ -398,7 +398,7 @@ test.describe("App Router navigation regressions", () => { ).toBe(false); }); - test("fresh filter navigation suppresses mount-time row animations on the first visible frame", async ({ + test("fresh filter navigation renders destination content on the first frame", async ({ page, }) => { await page.goto(`${BASE}/nav-flash/query-sync`); From 825d9c872b0932b88d40acfef73f7ad80afb7663 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:09:55 +1100 Subject: [PATCH 11/17] chore: retrigger CI From 43fd25a584d288d77717f8340688b1c36cdeca4a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 24 Mar 2026 01:15:33 +1100 Subject: [PATCH 12/17] fix: address code review findings from elegance pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move _navigationSnapshotActiveCount to ClientNavigationState global (Symbol.for key) so it survives multiple Vite module instances - Fix isThenable to check typeof then === "function" (not just "then" in value) - Fix replaceClientParamsWithoutNotify: use && not || to avoid setting hasPendingNavigationUpdate when params haven't actually changed - Increment renderId for HMR updates to avoid stale renderId collisions - Zero-copy restoreRscResponse using Uint8Array view instead of ArrayBuffer.slice(0) — avoids allocating a full copy per cache hit - Fix rAF comment: requestAnimationFrame fires before the next paint, not after --- .../vinext/src/server/app-browser-entry.ts | 24 +++++++------ packages/vinext/src/shims/navigation.ts | 34 ++++++++++++++----- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 50e787f58..b097791a0 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -83,7 +83,11 @@ function isServerActionResult(value: unknown): value is ServerActionResult { } function isThenable(value: T | Promise): value is Promise { - return typeof value === "object" && value !== null && "then" in value; + return ( + typeof value === "object" && + value !== null && + typeof (value as Record).then === "function" + ); } function getBrowserTreeStateSetter(): Dispatch> { @@ -221,9 +225,9 @@ function NavigationCommitSignal({ children, renderId }: { children: ReactNode; r useLayoutEffect(() => { runPrePaintNavigationEffect(renderId); - // Resolve the navigation commit promise after the browser paints. - // requestAnimationFrame fires after the next paint regardless of - // where it's scheduled from, so this works from useLayoutEffect. + // Resolve the navigation commit promise after the browser commits + // the next frame. requestAnimationFrame callbacks scheduled from + // useLayoutEffect fire before the following frame's paint. const frame = requestAnimationFrame(() => { resolveCommittedNavigations(renderId); }); @@ -250,13 +254,11 @@ function BrowserRoot({ }); useLayoutEffect(() => { + // setTreeState is stable (React guarantee), and BrowserRoot is a + // singleton that never unmounts. No cleanup needed — omitting it + // avoids a Strict Mode double-invocation window where the setter + // would briefly be null between cleanup and re-mount. setBrowserTreeState = setTreeState; - - return () => { - if (setBrowserTreeState === setTreeState) { - setBrowserTreeState = null; - } - }; }, []); const resolvedNode = isThenable(treeState.node) ? use(treeState.node) : treeState.node; @@ -633,7 +635,7 @@ async function main(): Promise { updateBrowserTree( rscPayload, createClientNavigationRenderSnapshot(window.location.href, latestClientParams), - nextNavigationRenderId, + ++nextNavigationRenderId, false, ); } catch (error) { diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 5a0bcb932..cc69406eb 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -244,7 +244,16 @@ export async function snapshotRscResponse(response: Response): Promise({ + start(controller) { + controller.enqueue(new Uint8Array(snapshot.body)); + controller.close(); + }, + }); + return new Response(body, { headers: snapshot.headers, status: snapshot.status, statusText: snapshot.statusText, @@ -426,6 +435,7 @@ type ClientNavigationState = { patchInstalled: boolean; hasPendingNavigationUpdate: boolean; suppressUrlNotifyCount: number; + navigationSnapshotActiveCount: number; }; type ClientNavigationGlobal = typeof globalThis & { @@ -451,6 +461,7 @@ function getClientNavigationState(): ClientNavigationState | null { patchInstalled: false, hasPendingNavigationUpdate: false, suppressUrlNotifyCount: 0, + navigationSnapshotActiveCount: 0, }; } @@ -531,17 +542,20 @@ function getServerSearchParamsSnapshot(): ReadonlyURLSearchParams { // the snapshot while it's active. // --------------------------------------------------------------------------- -let _navigationSnapshotActiveCount = 0; - /** * Mark a navigation snapshot as active. Called before startTransition * in renderNavigationPayload. While active, hooks prefer the snapshot * context value over useSyncExternalStore. Uses a counter (not boolean) * to handle overlapping navigations — rapid clicks can interleave * activate/deactivate if multiple transitions are in flight. + * + * Stored on ClientNavigationState (Symbol.for global) to survive + * multiple Vite module instances loading this file through different + * resolved IDs. */ export function activateNavigationSnapshot(): void { - _navigationSnapshotActiveCount++; + const state = getClientNavigationState(); + if (state) state.navigationSnapshotActiveCount++; } // Track client-side params (set during RSC hydration/navigation) @@ -593,7 +607,7 @@ export function replaceClientParamsWithoutNotify(params: Record 0) { + if (renderSnapshot && (getClientNavigationState()?.navigationSnapshotActiveCount ?? 0) > 0) { return renderSnapshot.pathname; } return pathname; @@ -668,7 +682,7 @@ export function useSearchParams(): ReadonlyURLSearchParams { getSearchParamsSnapshot, getServerSearchParamsSnapshot, ); - if (renderSnapshot && _navigationSnapshotActiveCount > 0) { + if (renderSnapshot && (getClientNavigationState()?.navigationSnapshotActiveCount ?? 0) > 0) { return renderSnapshot.searchParams; } return searchParams; @@ -690,7 +704,7 @@ export function useParams< getClientParamsSnapshot as () => T, getServerParamsSnapshot as () => T, ); - if (renderSnapshot && _navigationSnapshotActiveCount > 0) { + if (renderSnapshot && (getClientNavigationState()?.navigationSnapshotActiveCount ?? 0) > 0) { return (renderSnapshot.params ?? params) as T; } return params; @@ -762,7 +776,9 @@ export function commitClientNavigationState(): void { // reactive useSyncExternalStore values. The committed URL/params are // now up-to-date, and any subsequent pushState/replaceState calls // must be immediately reflected in hooks. - _navigationSnapshotActiveCount = Math.max(0, _navigationSnapshotActiveCount - 1); + if (state) { + state.navigationSnapshotActiveCount = Math.max(0, state.navigationSnapshotActiveCount - 1); + } const urlChanged = syncCommittedUrlStateFromLocation(); if (state.pendingClientParams !== null && state.pendingClientParamsJson !== null) { From aaadf1c028ab43bed5bb16d02b807e60ac796950 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 24 Mar 2026 01:35:12 +1100 Subject: [PATCH 13/17] fix: address remaining code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 5-minute max staleness for traverse (back/forward) cache entries instead of serving indefinitely stale data - Move cache invalidation after successful server action (not before), so failed actions don't unnecessarily clear valid caches - Add navigation cancellation via activeNavigationId — superseded navigations bail out after fetch to avoid pushing stale URLs - Remove useLayoutEffect cleanup for setBrowserTreeState (BrowserRoot is a singleton, cleanup created a Strict Mode null window) - Document scroll restoration split between browser entry (App Router) and navigation.ts (Pages Router) --- .../vinext/src/server/app-browser-entry.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index b097791a0..6a9ff61ff 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -64,14 +64,17 @@ type NavigationKind = "navigate" | "traverse" | "refresh"; type HistoryUpdateMode = "push" | "replace"; interface VisitedResponseCacheEntry { params: Record; + createdAt: number; regularExpiresAt: number; response: Awaited>; } const MAX_VISITED_RESPONSE_CACHE_SIZE = 50; +const TRAVERSE_MAX_STALENESS = 5 * 60_000; // 5 minutes for back/forward const VISITED_RESPONSE_CACHE_TTL = 30_000; let nextNavigationRenderId = 0; +let activeNavigationId = 0; const pendingNavigationCommits = new Map void>(); const pendingNavigationPrePaintEffects = new Map void>(); let setBrowserTreeState: Dispatch> | null = null; @@ -185,6 +188,12 @@ function getVisitedResponse( } if (navigationKind === "traverse") { + // Back/forward bypasses the regular TTL for instant navigation, + // but still enforces a max staleness to avoid serving very old data. + if (Date.now() - cached.createdAt > TRAVERSE_MAX_STALENESS) { + visitedResponseCache.delete(rscUrl); + return null; + } return cached; } @@ -207,6 +216,7 @@ function storeVisitedResponseSnapshot( evictVisitedResponseCacheIfNeeded(); visitedResponseCache.set(rscUrl, { params, + createdAt: now, regularExpiresAt: now + VISITED_RESPONSE_CACHE_TTL, response: snapshot, }); @@ -333,6 +343,11 @@ function restoreHydrationNavigationContext( }); } +// Simplified scroll restoration for App Router popstate. The full version +// in navigation.ts waits for __VINEXT_RSC_PENDING__ (needed for Pages Router +// where the popstate listener runs before the RSC handler). Here, the +// popstate handler awaits __VINEXT_RSC_NAVIGATE__ first, so scroll +// restoration runs after the new content is rendered. function restorePopstateScrollPosition(state: unknown): void { if (!(state && typeof state === "object" && "__vinext_scrollY" in state)) { return; @@ -408,8 +423,6 @@ async function readInitialRscStream(): Promise> { function registerServerActionCallback(): void { setServerCallback(async (id, args) => { - clearClientNavigationCaches(); - const temporaryReferences = createTemporaryReferenceSet(); const body = await encodeReply(args, { temporaryReferences }); @@ -450,6 +463,12 @@ function registerServerActionCallback(): void { { temporaryReferences }, ); + // Clear navigation caches after the action succeeds — server actions + // may have mutated data, making cached responses stale. We clear here + // (not before the fetch) so failed actions don't unnecessarily + // invalidate valid caches. + clearClientNavigationCaches(); + if (isServerActionResult(result)) { updateBrowserTree( result.root, @@ -512,6 +531,10 @@ async function main(): Promise { try { const url = new URL(href, window.location.origin); const rscUrl = toRscUrl(url.pathname + url.search); + // Assign a unique ID for this navigation. If a newer navigation starts + // before this one finishes (rapid clicks), we bail out after await + // points to avoid pushing stale URLs to history. + const navId = ++activeNavigationId; // 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 @@ -555,6 +578,9 @@ async function main(): Promise { }); } + // Bail out if a newer navigation started while we were fetching. + if (navId !== activeNavigationId) return; + const finalUrl = new URL(navResponseUrl ?? navResponse.url, window.location.origin); const requestedUrl = new URL(rscUrl, window.location.origin); From 0a83ad4f2d2582313127293d4452ae4c0669a078 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:09:28 +1100 Subject: [PATCH 14/17] fix: prevent snapshot counter leak and phantom history entries on rapid navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drain all pre-paint effects up to the committed renderId in NavigationCommitSignal, so superseded startTransition renders still get their counter decremented - Only run history push/replace for the winning navigation — superseded ones just balance the snapshot counter without creating phantom entries - Add second navId staleness check after snapshotRscResponse await - Disable browser native scroll restoration (history.scrollRestoration) to prevent scroll jank on back/forward - Remove dead reactRoot variable and unused Root import - Replace banned 'as' cast in isThenable with 'in' narrowing - Fix stale JSDoc and misleading comments - Remove redundant null check in commitClientNavigationState --- .../vinext/src/server/app-browser-entry.ts | 54 ++++++++++++++----- packages/vinext/src/shims/navigation.ts | 16 +++--- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 6a9ff61ff..77730266d 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -10,7 +10,6 @@ import { type ReactNode, type SetStateAction, } from "react"; -import type { Root } from "react-dom/client"; import { createFromFetch, createFromReadableStream, @@ -54,7 +53,6 @@ interface ServerActionResult { }; } -let reactRoot: Root | null = null; type BrowserTreeState = { renderId: number; node: ReactNode | Promise; @@ -89,7 +87,8 @@ function isThenable(value: T | Promise): value is Promise { return ( typeof value === "object" && value !== null && - typeof (value as Record).then === "function" + "then" in value && + typeof value.then === "function" ); } @@ -131,14 +130,30 @@ function queuePrePaintNavigationEffect(renderId: number, effect: (() => void) | pendingNavigationPrePaintEffects.set(renderId, effect); } -function runPrePaintNavigationEffect(renderId: number): void { - const effect = pendingNavigationPrePaintEffects.get(renderId); - if (!effect) { - return; +/** + * Run all queued pre-paint effects for renderIds up to and including the + * given renderId. When React supersedes a startTransition update (rapid + * clicks on same-route links), the superseded NavigationCommitSignal never + * mounts, so its pre-paint effect — which calls commitClientNavigationState + * to decrement navigationSnapshotActiveCount — never fires. By draining all + * effects ≤ the committed renderId here, the winning transition cleans up + * after any superseded ones, keeping the counter balanced. + */ +function drainPrePaintEffects(upToRenderId: number): void { + for (const [id, effect] of pendingNavigationPrePaintEffects) { + if (id <= upToRenderId) { + pendingNavigationPrePaintEffects.delete(id); + if (id === upToRenderId) { + // Only the winning navigation should mutate history — run its + // full effect (history push/replace + counter decrement). + effect(); + } else { + // Superseded navigations were never rendered. Just balance the + // snapshot counter without pushing phantom history entries. + commitClientNavigationState(); + } + } } - - pendingNavigationPrePaintEffects.delete(renderId); - effect(); } function createNavigationCommitEffect( @@ -233,7 +248,9 @@ function resolveCommittedNavigations(renderId: number): void { function NavigationCommitSignal({ children, renderId }: { children: ReactNode; renderId: number }) { useLayoutEffect(() => { - runPrePaintNavigationEffect(renderId); + // Drain pre-paint effects for this renderId AND any superseded ones + // whose NavigationCommitSignal never mounted (see drainPrePaintEffects). + drainPrePaintEffects(renderId); // Resolve the navigation commit promise after the browser commits // the next frame. requestAnimationFrame callbacks scheduled from @@ -503,7 +520,7 @@ async function main(): Promise { latestClientParams, ); - reactRoot = hydrateRoot( + window.__VINEXT_RSC_ROOT__ = hydrateRoot( document, createElement(BrowserRoot, { initialNode: root, @@ -512,8 +529,6 @@ async function main(): Promise { import.meta.env.DEV ? { onCaughtError() {} } : undefined, ); - window.__VINEXT_RSC_ROOT__ = reactRoot; - window.__VINEXT_RSC_NAVIGATE__ = async function navigateRsc( href: string, redirectDepth = 0, @@ -619,6 +634,9 @@ async function main(): Promise { const responseSnapshot = await snapshotRscResponse(navResponse); + // Bail out if a newer navigation started while buffering the response. + if (navId !== activeNavigationId) return; + storeVisitedResponseSnapshot(rscUrl, responseSnapshot, navParams); const rscPayload = createFromFetch( Promise.resolve(restoreRscResponse(responseSnapshot)), @@ -639,6 +657,14 @@ async function main(): Promise { } }; + // Disable the browser's native scroll restoration so it doesn't fight + // with our manual save/restore via __vinext_scrollY in history.state. + // Without this, back/forward causes a visible scroll jump (browser + // restores instantly) followed by a correction (our rAF-deferred restore). + if ("scrollRestoration" in history) { + history.scrollRestoration = "manual"; + } + window.addEventListener("popstate", (event) => { const pendingNavigation = window.__VINEXT_RSC_NAVIGATE__?.(window.location.href, 0, "traverse") ?? Promise.resolve(); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index cc69406eb..4e73683d5 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -244,9 +244,11 @@ export async function snapshotRscResponse(response: Response): Promise({ start(controller) { controller.enqueue(new Uint8Array(snapshot.body)); @@ -776,9 +778,7 @@ export function commitClientNavigationState(): void { // reactive useSyncExternalStore values. The committed URL/params are // now up-to-date, and any subsequent pushState/replaceState calls // must be immediately reflected in hooks. - if (state) { - state.navigationSnapshotActiveCount = Math.max(0, state.navigationSnapshotActiveCount - 1); - } + state.navigationSnapshotActiveCount = Math.max(0, state.navigationSnapshotActiveCount - 1); const urlChanged = syncCommittedUrlStateFromLocation(); if (state.pendingClientParams !== null && state.pendingClientParamsJson !== null) { @@ -821,8 +821,8 @@ export function replaceHistoryStateWithoutNotify( * Save the current scroll position into the current history state. * Called before every navigation to enable scroll restoration on back/forward. * - * Uses _nativeReplaceState to avoid triggering the history.replaceState - * interception (which would cause spurious re-renders from notifyListeners). + * Uses replaceHistoryStateWithoutNotify to avoid triggering the patched + * history.replaceState interception (which would cause spurious re-renders). */ function saveScrollPosition(): void { const state = window.history.state ?? {}; From efe7f4001988a1310a842fa99d044a7fd250bef5 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:13:23 +1100 Subject: [PATCH 15/17] chore: fix formatting after rebase --- packages/vinext/src/server/app-browser-entry.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 77730266d..38ac39f91 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -615,7 +615,10 @@ async function main(): Promise { const paramsHeader = navResponse.headers.get("X-Vinext-Params"); if (paramsHeader) { try { - navParams = JSON.parse(decodeURIComponent(paramsHeader)) as Record; + navParams = JSON.parse(decodeURIComponent(paramsHeader)) as Record< + string, + string | string[] + >; stageClientParams(navParams); } catch { stageClientParams({}); From e6824545078d53c19ac9f480e0297c62b55c30bd Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:46:00 +1100 Subject: [PATCH 16/17] fix: align visited response cache with Next.js and clean up types - Bump forward navigation TTL from 30s to 5 minutes (matches STATIC_STALETIME_MS) - Remove max staleness check for back/forward traversals. Next.js BFCache serves entries regardless of age, bounded only by LRU eviction (50 entries) and page refresh - Remove TTL-based prune on write. Expiry is checked at read time only, so traverse-eligible entries survive between navigations - Use CachedRscResponse directly instead of Awaited> - Remove dead default parameter and dead nullish coalescing fallback --- .../vinext/src/server/app-browser-entry.ts | 34 +++++-------------- packages/vinext/src/shims/navigation.ts | 2 +- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 38ac39f91..27eb7dacb 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -62,14 +62,12 @@ type NavigationKind = "navigate" | "traverse" | "refresh"; type HistoryUpdateMode = "push" | "replace"; interface VisitedResponseCacheEntry { params: Record; - createdAt: number; - regularExpiresAt: number; - response: Awaited>; + expiresAt: number; + response: CachedRscResponse; } const MAX_VISITED_RESPONSE_CACHE_SIZE = 50; -const TRAVERSE_MAX_STALENESS = 5 * 60_000; // 5 minutes for back/forward -const VISITED_RESPONSE_CACHE_TTL = 30_000; +const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000; // 5 minutes (matches Next.js STATIC_STALETIME_MS) let nextNavigationRenderId = 0; let activeNavigationId = 0; @@ -171,14 +169,6 @@ function createNavigationCommitEffect( }; } -function pruneVisitedResponseCache(now: number): void { - for (const [rscUrl, entry] of visitedResponseCache) { - if (entry.regularExpiresAt <= now) { - visitedResponseCache.delete(rscUrl); - } - } -} - function evictVisitedResponseCacheIfNeeded(): void { while (visitedResponseCache.size >= MAX_VISITED_RESPONSE_CACHE_SIZE) { const oldest = visitedResponseCache.keys().next().value; @@ -203,16 +193,12 @@ function getVisitedResponse( } if (navigationKind === "traverse") { - // Back/forward bypasses the regular TTL for instant navigation, - // but still enforces a max staleness to avoid serving very old data. - if (Date.now() - cached.createdAt > TRAVERSE_MAX_STALENESS) { - visitedResponseCache.delete(rscUrl); - return null; - } + // Back/forward bypasses the TTL entirely for instant navigation. + // Next.js does the same: BFCache entries are served regardless of age. return cached; } - if (cached.regularExpiresAt > Date.now()) { + if (cached.expiresAt > Date.now()) { return cached; } @@ -223,16 +209,14 @@ function getVisitedResponse( function storeVisitedResponseSnapshot( rscUrl: string, snapshot: CachedRscResponse, - params: Record = latestClientParams, + params: Record, ): void { - const now = Date.now(); - pruneVisitedResponseCache(now); visitedResponseCache.delete(rscUrl); evictVisitedResponseCacheIfNeeded(); + const now = Date.now(); visitedResponseCache.set(rscUrl, { params, - createdAt: now, - regularExpiresAt: now + VISITED_RESPONSE_CACHE_TTL, + expiresAt: now + VISITED_RESPONSE_CACHE_TTL, response: snapshot, }); } diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 4e73683d5..368a2ad57 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -707,7 +707,7 @@ export function useParams< getServerParamsSnapshot as () => T, ); if (renderSnapshot && (getClientNavigationState()?.navigationSnapshotActiveCount ?? 0) > 0) { - return (renderSnapshot.params ?? params) as T; + return renderSnapshot.params as T; } return params; } From e9842ff63c3edaac62ed2a71e8ba34f62bdbfccb Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:40:41 +1100 Subject: [PATCH 17/17] fix: address code review findings - Move server action cache clearing before RSC deserialization so invalidation isn't skipped if the payload is malformed - Document isSameRoute staleness during rapid A->B->A navigations - Document why reading navigationSnapshotActiveCount during render is safe despite being a React anti-pattern - Fix restoreRscResponse comment: mutation is the risk, not just transfer/detach - Replace waitForTimeout with double-rAF waitForPaintFlush in E2E tests for deterministic CI behavior - Document the self-referencing promise identity pattern in prefetchRscResponse Co-authored-by: Divanshu Chauhan <23524935+Divkix@users.noreply.github.com> --- .../vinext/src/server/app-browser-entry.ts | 19 +++++++++----- packages/vinext/src/shims/navigation.ts | 26 ++++++++++++++----- .../app-router/navigation-regressions.spec.ts | 19 +++++++++----- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 27eb7dacb..395021dd5 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -459,17 +459,18 @@ function registerServerActionCallback(): void { return undefined; } + // Clear navigation caches after the fetch succeeds but before RSC + // deserialization. Server actions may have mutated data, making cached + // responses stale. We clear immediately after the fetch (not after + // deserialization) so cache invalidation isn't skipped if the RSC + // payload is malformed. + clearClientNavigationCaches(); + const result = await createFromFetch( Promise.resolve(fetchResponse), { temporaryReferences }, ); - // Clear navigation caches after the action succeeds — server actions - // may have mutated data, making cached responses stale. We clear here - // (not before the fetch) so failed actions don't unnecessarily - // invalidate valid caches. - clearClientNavigationCaches(); - if (isServerActionResult(result)) { updateBrowserTree( result.root, @@ -538,6 +539,12 @@ async function main(): Promise { // so React keeps the old UI visible during the transition. For cross-route // navigations (different pathname), use synchronous updates — React's // startTransition hangs in Firefox when replacing the entire tree. + // + // Note: window.location.pathname reflects the *previous* page here because + // the two-phase commit defers history updates to useLayoutEffect. During + // rapid A->B->A, the second navigation may see isSameRoute=true while the + // user perceives a cross-route nav. This is harmless — it only affects + // whether startTransition is used (smoother animation, not correctness). const isSameRoute = url.pathname === window.location.pathname; const cachedRoute = getVisitedResponse(rscUrl, navigationKind); const navigationCommitEffect = createNavigationCommitEffect(href, historyUpdateMode); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 368a2ad57..da2919e1d 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -245,10 +245,10 @@ export async function snapshotRscResponse(response: Response): Promise({ start(controller) { controller.enqueue(new Uint8Array(snapshot.body)); @@ -367,6 +367,11 @@ export function prefetchRscResponse(rscUrl: string, responsePromise: Promise; pendingResponse = responsePromise .then(async (response) => { @@ -660,9 +665,16 @@ export function usePathname(): string { getPathnameSnapshot, () => _getServerContext()?.pathname ?? "/", ); - // Only use the render snapshot during an active navigation transition. - // After commit, fall through to useSyncExternalStore so user - // pushState/replaceState calls are immediately reflected. + // Prefer the render snapshot during an active navigation transition so + // hooks return the pending URL, not the stale committed one. After commit, + // fall through to useSyncExternalStore so user pushState/replaceState + // calls are immediately reflected. + // + // Reading navigationSnapshotActiveCount (mutable external state) during + // render is technically a React anti-pattern, but safe here: the counter + // only changes synchronously before startTransition (activate) and in + // useLayoutEffect after commit (deactivate). React's concurrent renders + // within a single transition see the same external state snapshot. if (renderSnapshot && (getClientNavigationState()?.navigationSnapshotActiveCount ?? 0) > 0) { return renderSnapshot.pathname; } diff --git a/tests/e2e/app-router/navigation-regressions.spec.ts b/tests/e2e/app-router/navigation-regressions.spec.ts index 17ed272e2..817757844 100644 --- a/tests/e2e/app-router/navigation-regressions.spec.ts +++ b/tests/e2e/app-router/navigation-regressions.spec.ts @@ -9,6 +9,13 @@ async function waitForHydration(page: import("@playwright/test").Page) { }).toPass({ timeout: 10_000 }); } +/** Wait for two animation frames to flush all pending paints and microtasks. */ +async function waitForPaintFlush(page: import("@playwright/test").Page) { + await page.evaluate( + () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))), + ); +} + async function waitForProvidersList(page: import("@playwright/test").Page) { await expect(page.locator("#providers-title")).toHaveText("Providers"); await expect(page.locator("#provider-list")).toBeVisible({ timeout: 10_000 }); @@ -154,7 +161,7 @@ test.describe("App Router navigation regressions", () => { await expect(page.locator("#client-param-label")).toHaveText("Client param: beta"); await expect(page.locator("#param-filter-title")).toHaveText("Server param: beta"); await expect(page.locator("#param-filter-first-row")).toHaveText("Beta 1"); - await page.waitForTimeout(100); + await waitForPaintFlush(page); const events = await page.evaluate(() => { ( @@ -264,7 +271,7 @@ test.describe("App Router navigation regressions", () => { await expect(page.locator("#client-filter-label")).toHaveText("Client filter: beta"); await expect(page.locator("#query-filter-title")).toHaveText("Server filter: beta"); await expect(page.locator("#query-filter-first-row")).toHaveText("Beta 1"); - await page.waitForTimeout(100); + await waitForPaintFlush(page); const events = await page.evaluate(() => { ( @@ -362,7 +369,7 @@ test.describe("App Router navigation regressions", () => { await page.locator("#query-filter-beta").click(); await expect(page.locator("#query-filter-title")).toHaveText("Server filter: beta"); await expect(page.locator("#query-filter-first-row")).toHaveText("Beta 1"); - await page.waitForTimeout(100); + await waitForPaintFlush(page); const events = await page.evaluate(() => { ( @@ -453,7 +460,7 @@ test.describe("App Router navigation regressions", () => { await page.locator("#query-filter-beta").click(); await expect(page.locator("#query-filter-title")).toHaveText("Server filter: beta"); await expect(page.locator("#query-filter-first-row")).toHaveText("Beta 1"); - await page.waitForTimeout(100); + await waitForPaintFlush(page); const events = await page.evaluate(() => { ( @@ -550,7 +557,7 @@ test.describe("App Router navigation regressions", () => { await expect(page.locator("#link-client-filter-label")).toHaveText("Client filter: beta"); await expect(page.locator("#link-filter-title")).toHaveText("Server filter: beta"); await expect(page.locator("#link-filter-first-row")).toHaveText("Beta 1"); - await page.waitForTimeout(100); + await waitForPaintFlush(page); const events = await page.evaluate(() => { ( @@ -609,7 +616,7 @@ test.describe("App Router navigation regressions", () => { await waitForHydration(page); await page.locator("#link-filter-beta").hover(); - await page.waitForTimeout(50); + await waitForPaintFlush(page); await page.locator("#link-filter-beta").click(); await expect(page.locator("#link-client-filter-label")).toHaveText("Client filter: beta");