diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts
index ff360f2cc..9d714e0d7 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, PrefetchCacheEntry } from "./shims/navigation";
// ---------------------------------------------------------------------------
// Window globals — browser-side state shared across module boundaries
@@ -75,8 +76,17 @@ 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) => Promise) | undefined;
+ __VINEXT_RSC_NAVIGATE__:
+ | ((
+ href: string,
+ redirectDepth?: number,
+ navigationKind?: "navigate" | "traverse" | "refresh",
+ historyUpdateMode?: "push" | "replace",
+ ) => Promise)
+ | undefined;
/**
* A Promise that resolves when the current in-flight popstate RSC navigation
@@ -93,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 3713228b1..395021dd5 100644
--- a/packages/vinext/src/server/app-browser-entry.ts
+++ b/packages/vinext/src/server/app-browser-entry.ts
@@ -1,7 +1,15 @@
///
-import type { ReactNode } from "react";
-import type { Root } from "react-dom/client";
+import {
+ createElement,
+ startTransition,
+ use,
+ useLayoutEffect,
+ useState,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction,
+} from "react";
import {
createFromFetch,
createFromReadableStream,
@@ -9,15 +17,25 @@ import {
encodeReply,
setServerCallback,
} from "@vitejs/plugin-rsc/browser";
-import { flushSync } from "react-dom";
import { hydrateRoot } from "react-dom/client";
import {
- PREFETCH_CACHE_TTL,
+ activateNavigationSnapshot,
+ commitClientNavigationState,
+ consumePrefetchResponse,
+ createClientNavigationRenderSnapshot,
+ getClientNavigationRenderContext,
getPrefetchCache,
getPrefetchedUrls,
+ pushHistoryStateWithoutNotify,
+ replaceClientParamsWithoutNotify,
+ replaceHistoryStateWithoutNotify,
+ restoreRscResponse,
setClientParams,
+ snapshotRscResponse,
setNavigationContext,
toRscUrl,
+ type CachedRscResponse,
+ type ClientNavigationRenderSnapshot,
} from "../shims/navigation.js";
import {
chunksToReadableStream,
@@ -35,19 +53,285 @@ 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;
+ navigationSnapshot: ClientNavigationRenderSnapshot;
+};
+type NavigationKind = "navigate" | "traverse" | "refresh";
+type HistoryUpdateMode = "push" | "replace";
+interface VisitedResponseCacheEntry {
+ params: Record;
+ expiresAt: number;
+ response: CachedRscResponse;
}
+const MAX_VISITED_RESPONSE_CACHE_SIZE = 50;
+const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000; // 5 minutes (matches Next.js STATIC_STALETIME_MS)
+
+let nextNavigationRenderId = 0;
+let activeNavigationId = 0;
+const pendingNavigationCommits = new Map void>();
+const pendingNavigationPrePaintEffects = 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 &&
+ typeof value.then === "function"
+ );
+}
+
+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 stageClientParams(params: Record): void {
+ latestClientParams = params;
+ replaceClientParamsWithoutNotify(params);
+}
+
+function clearVisitedResponseCache(): void {
+ visitedResponseCache.clear();
+}
+
+function clearPrefetchState(): void {
+ getPrefetchCache().clear();
+ getPrefetchedUrls().clear();
+}
+
+function clearClientNavigationCaches(): void {
+ clearVisitedResponseCache();
+ clearPrefetchState();
+}
+
+function queuePrePaintNavigationEffect(renderId: number, effect: (() => void) | null): void {
+ if (!effect) {
+ return;
+ }
+ pendingNavigationPrePaintEffects.set(renderId, effect);
+}
+
+/**
+ * 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();
+ }
+ }
+ }
+}
+
+function createNavigationCommitEffect(
+ href: string,
+ historyUpdateMode: HistoryUpdateMode | undefined,
+): () => void {
+ return () => {
+ if (historyUpdateMode === "replace") {
+ replaceHistoryStateWithoutNotify(null, "", href);
+ } else if (historyUpdateMode === "push") {
+ pushHistoryStateWithoutNotify(null, "", href);
+ }
+
+ commitClientNavigationState();
+ };
+}
+
+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") {
+ // 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.expiresAt > Date.now()) {
+ return cached;
+ }
+
+ visitedResponseCache.delete(rscUrl);
+ return null;
+}
+
+function storeVisitedResponseSnapshot(
+ rscUrl: string,
+ snapshot: CachedRscResponse,
+ params: Record,
+): void {
+ visitedResponseCache.delete(rscUrl);
+ evictVisitedResponseCacheIfNeeded();
+ const now = Date.now();
+ visitedResponseCache.set(rscUrl, {
+ params,
+ expiresAt: 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 }) {
+ useLayoutEffect(() => {
+ // 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
+ // useLayoutEffect fire before the following frame's paint.
+ const frame = requestAnimationFrame(() => {
+ resolveCommittedNavigations(renderId);
+ });
+
+ return () => {
+ cancelAnimationFrame(frame);
+ };
+ }, [renderId]);
+
+ return children;
+}
+
+function BrowserRoot({
+ initialNode,
+ initialNavigationSnapshot,
+}: {
+ initialNode: ReactNode;
+ initialNavigationSnapshot: ClientNavigationRenderSnapshot;
+}) {
+ const [treeState, setTreeState] = useState({
+ renderId: 0,
+ node: initialNode,
+ navigationSnapshot: initialNavigationSnapshot,
+ });
+
+ 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;
+ }, []);
+
+ const resolvedNode = isThenable(treeState.node) ? use(treeState.node) : treeState.node;
+
+ 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, navigationSnapshot });
+ };
+
+ if (useTransition) {
+ startTransition(applyUpdate);
+ return;
+ }
+
+ applyUpdate();
+}
+
+function renderNavigationPayload(
+ payload: Promise | ReactNode,
+ navigationSnapshot: ClientNavigationRenderSnapshot,
+ prePaintEffect: (() => void) | null = null,
+ useTransition = true,
+): Promise {
+ const renderId = ++nextNavigationRenderId;
+ queuePrePaintNavigationEffect(renderId, prePaintEffect);
+
+ const committed = new Promise((resolve) => {
+ 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, useTransition);
+
+ return committed;
+}
+
function restoreHydrationNavigationContext(
pathname: string,
searchParams: SearchParamInput,
@@ -60,6 +344,24 @@ 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;
+ }
+
+ const y = Number(state.__vinext_scrollY);
+ const x = "__vinext_scrollX" in state ? Number(state.__vinext_scrollX) : 0;
+
+ requestAnimationFrame(() => {
+ window.scrollTo(x, y);
+ });
+}
+
async function readInitialRscStream(): Promise> {
const vinext = getVinextBrowserGlobal();
@@ -70,7 +372,7 @@ async function readInitialRscStream(): Promise> {
const params = embedData.params ?? {};
if (embedData.params) {
- setClientParams(embedData.params);
+ applyClientParams(embedData.params);
}
if (embedData.nav) {
restoreHydrationNavigationContext(
@@ -85,7 +387,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 +407,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.
}
@@ -157,12 +459,25 @@ function registerServerActionCallback(): void {
return undefined;
}
- const result = await createFromFetch(Promise.resolve(fetchResponse), {
- temporaryReferences,
- });
+ // 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 },
+ );
if (isServerActionResult(result)) {
- getReactRoot().render(result.root);
+ 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;
@@ -170,7 +485,12 @@ function registerServerActionCallback(): void {
return undefined;
}
- getReactRoot().render(result as ReactNode);
+ updateBrowserTree(
+ result,
+ createClientNavigationRenderSnapshot(window.location.href, latestClientParams),
+ ++nextNavigationRenderId,
+ false,
+ );
return result;
});
}
@@ -179,19 +499,26 @@ 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(
+ window.__VINEXT_RSC_ROOT__ = hydrateRoot(
document,
- root as ReactNode,
+ createElement(BrowserRoot, {
+ initialNode: root,
+ initialNavigationSnapshot,
+ }),
import.meta.env.DEV ? { onCaughtError() {} } : undefined,
);
- window.__VINEXT_RSC_ROOT__ = reactRoot;
-
window.__VINEXT_RSC_NAVIGATE__ = async function navigateRsc(
href: string,
redirectDepth = 0,
+ navigationKind: NavigationKind = "navigate",
+ historyUpdateMode?: HistoryUpdateMode,
): Promise {
if (redirectDepth > 10) {
console.error(
@@ -204,18 +531,50 @@ 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
+ // 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);
+
+ if (cachedRoute) {
+ stageClientParams(cachedRoute.params);
+ const cachedNavigationSnapshot = createClientNavigationRenderSnapshot(
+ href,
+ cachedRoute.params,
+ );
+ const cachedPayload = createFromFetch(
+ Promise.resolve(restoreRscResponse(cachedRoute.response)),
+ );
+ await renderNavigationPayload(
+ cachedPayload,
+ cachedNavigationSnapshot,
+ navigationCommitEffect,
+ isSameRoute,
+ );
+ 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 prefetchedResponse = consumePrefetchResponse(rscUrl);
+ if (prefetchedResponse) {
+ navResponse = restoreRscResponse(prefetchedResponse);
+ navResponseUrl = prefetchedResponse.url;
+ }
}
if (!navResponse) {
@@ -225,11 +584,14 @@ async function main(): Promise {
});
}
- const finalUrl = new URL(navResponse.url);
+ // 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);
+
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) {
@@ -237,35 +599,72 @@ async function main(): Promise {
return;
}
- return navigate(destinationPath, redirectDepth + 1);
+ return navigate(destinationPath, redirectDepth + 1, navigationKind, historyUpdateMode);
}
+ 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<
+ string,
+ string | string[]
+ >;
+ stageClientParams(navParams);
} catch {
- setClientParams({});
+ stageClientParams({});
}
} else {
- setClientParams({});
+ stageClientParams({});
}
+ const navigationSnapshot = createClientNavigationRenderSnapshot(href, latestClientParams);
+
+ // 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);
- const rscPayload = await createFromFetch(Promise.resolve(navResponse));
- flushSync(() => {
- getReactRoot().render(rscPayload as ReactNode);
- });
+ // 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)),
+ );
+
+ await renderNavigationPayload(
+ rscPayload,
+ navigationSnapshot,
+ navigationCommitEffect,
+ 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;
}
};
- window.addEventListener("popstate", () => {
+ // 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) ?? 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 +674,16 @@ async function main(): Promise {
if (import.meta.hot) {
import.meta.hot.on("rsc:update", async () => {
try {
- const rscPayload = await createFromFetch(
+ clearClientNavigationCaches();
+ const rscPayload = await createFromFetch(
fetch(toRscUrl(window.location.pathname + window.location.search)),
);
- getReactRoot().render(rscPayload as ReactNode);
+ 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((response) => {
- if (response.ok) {
- 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 2e23b5b8c..da2919e1d 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).
@@ -189,11 +219,49 @@ 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;
+ pendingResponse?: Promise;
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 {
+ // Uint8Array creates a view over the cached ArrayBuffer without copying.
+ // ReadableStream consumption does not mutate or detach the underlying
+ // buffer, so repeated restores from the same snapshot are safe. If a
+ // future consumer ever mutates, transfers, or detaches the buffer, this
+ // assumption breaks — add a defensive .slice(0) in that case.
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array(snapshot.body));
+ controller.close();
+ },
+ });
+ return new Response(body, {
+ 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
@@ -219,6 +287,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.
@@ -236,64 +335,192 @@ 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();
// 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();
+ }
+
+ // The `pendingResponse` variable is assigned and then referenced inside its
+ // own .then()/.catch() chain. This is an intentional identity check pattern:
+ // when the promise settles, `cache.get(rscUrl)?.pendingResponse === pendingResponse`
+ // verifies this is still the *current* pending request for that URL (not a
+ // newer one that superseded it).
+ 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 function consumePrefetchResponse(rscUrl: string): CachedRscResponse | null {
+ const cache = getPrefetchCache();
+ const entry = cache.get(rscUrl);
+ if (!entry) {
+ 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) {
+ return null;
+ }
+
+ if (Date.now() - entry.timestamp >= PREFETCH_CACHE_TTL) {
+ deletePrefetchEntry(rscUrl);
+ return null;
}
- cache.set(rscUrl, { response, timestamp: now });
+ 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;
+ navigationSnapshotActiveCount: 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,
+ navigationSnapshotActiveCount: 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 _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;
}
- return _cachedReadonlySearchParams;
+
+ const search = window.location.search;
+ if (search !== state.cachedSearch) {
+ state.cachedSearch = search;
+ state.cachedReadonlySearchParams = new ReadonlyURLSearchParams(search);
+ changed = true;
+ }
+
+ return changed;
}
function getServerSearchParamsSnapshot(): ReadonlyURLSearchParams {
@@ -312,30 +539,95 @@ 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.
+// ---------------------------------------------------------------------------
+
+/**
+ * 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 {
+ const state = getClientNavigationState();
+ if (state) state.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).
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 {
@@ -343,9 +635,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);
};
}
@@ -363,12 +658,27 @@ 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 ?? "/",
);
+ // 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;
+ }
+ return pathname;
}
/**
@@ -380,11 +690,16 @@ 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,
);
+ if (renderSnapshot && (getClientNavigationState()?.navigationSnapshotActiveCount ?? 0) > 0) {
+ return renderSnapshot.searchParams;
+ }
+ return searchParams;
}
/**
@@ -397,11 +712,16 @@ 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,
);
+ if (renderSnapshot && (getClientNavigationState()?.navigationSnapshotActiveCount ?? 0) > 0) {
+ return renderSnapshot.params as T;
+ }
+ return params;
}
/**
@@ -447,22 +767,78 @@ 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;
+
+ // 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.
+ state.navigationSnapshotActiveCount = Math.max(0, state.navigationSnapshotActiveCount - 1);
+
+ 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.
* 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 {
- if (!_nativeReplaceState) return;
const state = window.history.state ?? {};
- _nativeReplaceState.call(
- window.history,
+ replaceHistoryStateWithoutNotify(
{ ...state, __vinext_scrollX: window.scrollX, __vinext_scrollY: window.scrollY },
"",
);
@@ -515,7 +891,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,
@@ -547,11 +923,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);
}
@@ -562,18 +938,18 @@ async function navigateImpl(
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();
-
// 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();
}
if (scroll) {
@@ -588,7 +964,7 @@ async function navigateImpl(
// ---------------------------------------------------------------------------
// 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 /
@@ -598,11 +974,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;
@@ -616,7 +992,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 {
@@ -627,23 +1003,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((response) => {
- if (response.ok) {
- 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"],
+ }),
+ );
},
};
@@ -866,43 +1233,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
- 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/e2e/app-router/navigation-regressions.spec.ts b/tests/e2e/app-router/navigation-regressions.spec.ts
new file mode 100644
index 000000000..817757844
--- /dev/null
+++ b/tests/e2e/app-router/navigation-regressions.spec.ts
@@ -0,0 +1,686 @@
+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 });
+}
+
+/** 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 });
+}
+
+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("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 waitForPaintFlush(page);
+
+ 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 waitForPaintFlush(page);
+
+ 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 waitForPaintFlush(page);
+
+ 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 renders destination content on the first 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 waitForPaintFlush(page);
+
+ 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();
+ });
+
+ 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 waitForPaintFlush(page);
+
+ 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();
+ });
+
+ 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);
+
+ await page.locator("#link-filter-beta").hover();
+ await waitForPaintFlush(page);
+
+ 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");
+ });
+
+ 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/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 (
+
+
Client filter: {filter}
+
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 (
+ <>
+
+
+ {rows.map((row, index) => (
+ -
+ {row}
+
+ ))}
+
+ >
+ );
+}
+
+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/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/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/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/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...}>
+
+
+
+ );
+}
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
+
);
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", () => {
'