Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
78eefbf
fix(navigation): track pending pathname to fix isSameRoute race condi…
Divkix Apr 2, 2026
76101bb
fix(navigation): address review feedback on pending pathname race con…
Divkix Apr 2, 2026
7056e6a
resolve merge conflict
Divkix Apr 2, 2026
5c9e3d6
Resolve merge conflict: keep pending pathname race condition fix (#744)
Divkix Apr 3, 2026
391a244
Address PR review feedback for pending pathname race condition fix
Divkix Apr 3, 2026
61734a1
Fix unit tests - remove client-side mocks that fail in CI
Divkix Apr 3, 2026
9287f4b
Fix formatting issues in tests/shims.test.ts
Divkix Apr 3, 2026
3cbab5a
chore(tests): clarify smoke test comments for pending pathname helpers
Divkix Apr 3, 2026
b35fc90
fix: clear pending pathname when navigating to different route
Divkix Apr 3, 2026
4f694b6
fix: remove race condition in pending pathname handling
Divkix Apr 4, 2026
6299b3d
fix(navigation): prevent superseded navigations from clearing pending…
Divkix Apr 4, 2026
cbd5049
fix(navigation): prevent superseded navigations from clearing pending…
Divkix Apr 4, 2026
44d466e
fix(e2e): replace waitForURL with toHaveURL for rapid navigation tests
Divkix Apr 4, 2026
ac78e6f
fix(navigation): address PR review feedback on pending pathname race fix
Divkix Apr 8, 2026
f789c6d
fix(e2e): remove full-page-reload marker check from rapid navigation …
Divkix Apr 8, 2026
f97a322
fix: review fixes
Divkix Apr 9, 2026
ce64e4e
fix(e2e): move back-forward test to navigation-flows.spec.ts
Divkix Apr 10, 2026
df72916
merge: resolve conflicts with main
Divkix Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 42 additions & 17 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,20 @@ import { notifyAppRouterTransitionStart } from "../client/instrumentation-client
import {
__basePath,
activateNavigationSnapshot,
clearPendingPathname,
commitClientNavigationState,
consumePrefetchResponse,
createClientNavigationRenderSnapshot,
getClientNavigationRenderContext,
getClientNavigationState,
getPrefetchCache,
getPrefetchedUrls,
pushHistoryStateWithoutNotify,
replaceClientParamsWithoutNotify,
replaceHistoryStateWithoutNotify,
restoreRscResponse,
setClientParams,
setPendingPathname,
snapshotRscResponse,
setMountedSlotsHeader,
setNavigationContext,
Expand Down Expand Up @@ -190,8 +193,10 @@ function drainPrePaintEffects(upToRenderId: number): void {
// Winning navigation: run its actual pre-paint effect
effect();
} else {
// Superseded navigation: balance its activateNavigationSnapshot()
commitClientNavigationState();
// Superseded navigation: balance its activateNavigationSnapshot().
// Pass undefined navId intentionally so this cleanup cannot clear
// pendingPathname owned by the current active navigation.
commitClientNavigationState(undefined);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — the explicit undefined with the documenting comment makes the intent clear. This was a concern in earlier rounds about implicit type mismatches, and this addresses it well.

}
}
}
Expand All @@ -200,9 +205,16 @@ function drainPrePaintEffects(upToRenderId: number): void {
function createNavigationCommitEffect(
href: string,
historyUpdateMode: HistoryUpdateMode | undefined,
navId: number,
params: Record<string, string | string[]>,
): () => void {
return () => {
// Only update URL if this is still the active navigation.
// A newer navigation would have incremented activeNavigationId.
if (navId !== activeNavigationId) {
return;
}
Comment on lines +212 to +216
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we be committing undefined here like we do elsewhere?


const targetHref = new URL(href, window.location.origin).href;
stageClientParams(params);

Expand All @@ -212,7 +224,7 @@ function createNavigationCommitEffect(
pushHistoryStateWithoutNotify(null, "", href);
}

commitClientNavigationState();
commitClientNavigationState(navId);
};
}

Expand Down Expand Up @@ -551,7 +563,7 @@ async function renderNavigationPayload(
const resolve = pendingNavigationCommits.get(renderId);
pendingNavigationCommits.delete(renderId);
if (snapshotActivated) {
commitClientNavigationState();
commitClientNavigationState(navId);
}
resolve?.();
throw error;
Expand Down Expand Up @@ -747,17 +759,24 @@ async function main(): Promise<void> {
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.
// NB: During rapid navigations, window.location.pathname may not reflect
// the previous navigation's URL yet (URL commit is deferred). This could
// cause misclassification (synchronous instead of startTransition or vice
// versa), resulting in slightly less smooth transitions but correct behavior.
const isSameRoute =
stripBasePath(url.pathname, __basePath) ===

// Get navigation state for comparison
const navState = getClientNavigationState();

// Compare against previous pending navigation, then committed, then window.location
// This ensures correct isSameRoute classification during rapid successive navigations
const currentPath =
navState?.pendingPathname ??
navState?.cachedPathname ??
stripBasePath(window.location.pathname, __basePath);

const targetPath = stripBasePath(url.pathname, __basePath);
const isSameRoute = targetPath === currentPath;

// Set this navigation as the pending pathname, overwriting any previous.
// Pass navId so only this navigation (or a newer one) can clear it later.
setPendingPathname(url.pathname, navId);

const elementsAtNavStart = getBrowserRouterState().elements;
const mountedSlotsHeader = getMountedSlotIdsHeader(elementsAtNavStart);
const cachedRoute = getVisitedResponse(rscUrl, mountedSlotsHeader, navigationKind);
Expand Down Expand Up @@ -789,7 +808,7 @@ async function main(): Promise<void> {
cachedNavigationSnapshot,
href,
navId,
createNavigationCommitEffect(href, historyUpdateMode, cachedParams),
createNavigationCommitEffect(href, historyUpdateMode, navId, cachedParams),
isSameRoute,
toActionType(navigationKind),
);
Expand Down Expand Up @@ -878,7 +897,7 @@ async function main(): Promise<void> {
navigationSnapshot,
href,
navId,
createNavigationCommitEffect(href, historyUpdateMode, navParams),
createNavigationCommitEffect(href, historyUpdateMode, navId, navParams),
isSameRoute,
toActionType(navigationKind),
);
Expand All @@ -905,7 +924,13 @@ async function main(): Promise<void> {
// before re-throwing, so this guard correctly skips the double-decrement case.
if (_snapshotPending) {
_snapshotPending = false;
commitClientNavigationState();
commitClientNavigationState(navId);
}
// Clear pending pathname on error so subsequent navigations compare correctly.
// Only clear if this is still the active navigation — a newer navigation
// has already overwritten pendingPathname with its own target.
if (navId === activeNavigationId) {
clearPendingPathname(navId);
}
// Don't hard-navigate to a stale URL if this navigation was superseded by
// a newer one — the newer navigation is already in flight and would be clobbered.
Expand Down
55 changes: 53 additions & 2 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,8 @@ type ClientNavigationState = {
clientParamsJson: string;
pendingClientParams: Record<string, string | string[]> | null;
pendingClientParamsJson: string | null;
pendingPathname: string | null;
pendingPathnameNavId: number | null;
originalPushState: typeof window.history.pushState;
originalReplaceState: typeof window.history.replaceState;
patchInstalled: boolean;
Expand All @@ -525,7 +527,7 @@ export function getMountedSlotsHeader(): string | null {
return globalState[_MOUNTED_SLOTS_HEADER_KEY] ?? null;
}

function getClientNavigationState(): ClientNavigationState | null {
export function getClientNavigationState(): ClientNavigationState | null {
if (isServer) return null;

const globalState = window as ClientNavigationGlobal;
Expand All @@ -538,6 +540,8 @@ function getClientNavigationState(): ClientNavigationState | null {
clientParamsJson: "{}",
pendingClientParams: null,
pendingClientParamsJson: null,
pendingPathname: null,
pendingPathnameNavId: null,
// NB: These capture the currently installed history methods, not guaranteed
// native ones. If a third-party library (analytics, router) has already patched
// history methods before this module loads, we intentionally preserve that
Expand Down Expand Up @@ -768,6 +772,34 @@ export function getClientParams(): Record<string, string | string[]> {
return getClientNavigationState()?.clientParams ?? _fallbackClientParams;
}

/**
* Set the pending pathname for client-side navigation.
* Strips the base path before storing. Associates the pathname with the given navId
* so only that navigation (or a newer one) can clear it.
*/
export function setPendingPathname(pathname: string, navId: number): void {
const state = getClientNavigationState();
if (!state) return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the isServer guard here is redundant. getClientNavigationState() on line 739 already returns null when isServer is true (line 491), so this collapses to just if (!state) return — which is what clearPendingPathname uses at line 749. Consider making them consistent:

Suggested change
if (!state) return;
if (!state) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still unaddressed from both prior reviews: getClientNavigationState() already returns null when isServer is true (line 491), so the isServer check here is redundant. clearPendingPathname at line 749 only checks if (!state) — these should be consistent.

Suggested change
if (!state) return;
if (!state) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagged in all three prior review rounds and still unaddressed. getClientNavigationState() returns null when isServer is true (line 491), so the isServer check is redundant. clearPendingPathname at line 755 only checks if (!state) return — these two functions should be consistent.

Suggested change
if (!state) return;
if (!state) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagged in all four prior review rounds and still unaddressed. getClientNavigationState() returns null when isServer is true (line 492), making the isServer check here redundant. clearPendingPathname at line 755 only checks if (!state) return — these should be consistent.

Suggested change
if (!state) return;
if (!state) return;

state.pendingPathname = stripBasePath(pathname, __basePath);
state.pendingPathnameNavId = navId;
}

/**
* Clear the pending pathname, but only if the given navId matches the one
* that set it, or if pendingPathnameNavId is null (no active owner).
* This prevents superseded navigations from clearing state belonging to newer navigations.
*/
export function clearPendingPathname(navId: number): void {
const state = getClientNavigationState();
if (!state) return;
// Only clear if this navId is the one that set the pendingPathname,
// or if pendingPathnameNavId is null (no owner)
if (state.pendingPathnameNavId === null || state.pendingPathnameNavId === navId) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The pendingPathnameNavId === null branch acts as a safety net for the case where setPendingPathname was never called (or was already cleared). In that case pendingPathname is already null, so the clear is a no-op. Not a bug — it's defensive and fine to keep — but it means the only branch that actually does work here is the === navId check.

state.pendingPathname = null;
state.pendingPathnameNavId = null;
}
}

function getClientParamsSnapshot(): Record<string, string | string[]> {
return getClientNavigationState()?.clientParams ?? _EMPTY_PARAMS;
}
Expand Down Expand Up @@ -927,7 +959,14 @@ function withSuppressedUrlNotifications<T>(fn: () => T): T {
}
}

export function commitClientNavigationState(): void {
/**
* Commit pending client navigation state to committed snapshots.
*
* navId is optional: callers that don't own pendingPathname (for example,
* superseded pre-paint cleanup) may pass undefined to flush snapshot/params
* state without clearing pendingPathname owned by the active navigation.
*/
export function commitClientNavigationState(navId?: number): void {
if (isServer) return;
const state = getClientNavigationState();
if (!state) return;
Expand All @@ -947,6 +986,18 @@ export function commitClientNavigationState(): void {
state.pendingClientParams = null;
state.pendingClientParamsJson = null;
}
// Clear pending pathname when navigation commits, but only if:
// - The navId matches the one that set pendingPathname
// - No newer navigation has overwritten pendingPathname (pendingPathnameNavId === null or matches)
// - navId is undefined only for non-owning callers, which must not clear
// pendingPathname for an active navigation.
const canClearPendingPathname =
state.pendingPathnameNavId === null ||
(navId !== undefined && state.pendingPathnameNavId === navId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The explicit navId !== undefined guard is the right call here — it makes the contract readable without having to reason about type-level number === undefined semantics. The comment above (lines 950-954) documents the intent well.

One minor suggestion: the pendingPathnameNavId === null branch allows clearing when there's no owner, but after the initialization at line 504 (pendingPathnameNavId: null), the only way it stays null is if setPendingPathname was never called. In that case pendingPathname is already null, so the clear is a no-op. Not a bug, just an observation — the null check acts as a safety net for an impossible state, which is fine.

if (canClearPendingPathname) {
state.pendingPathname = null;
state.pendingPathnameNavId = null;
}
const shouldNotify = urlChanged || state.hasPendingNavigationUpdate;
state.hasPendingNavigationUpdate = false;

Expand Down
44 changes: 44 additions & 0 deletions tests/e2e/app-router/navigation-flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,48 @@ test.describe("App Router navigation flows", () => {
await page.goto(`${BASE}/client-nav-test?q=test-param`);
await expect(page.locator('[data-testid="client-search-q"]')).toHaveText("test-param");
});

test("back/forward through rapid-nav pages maintains state", async ({ page }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: This test is a good regression guard for basic sequential history navigation through the rapid-nav fixture pages, but since it's sequential (each goBack()/goForward() is awaited), it doesn't exercise the rapid navigation race condition this PR fixes. It fits naturally in this file (general navigation flows), so no concerns about placement — just noting that the rapid navigation coverage comes from rapid-navigation.spec.ts, not this test.

const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});

// Start at page A
await page.goto(`${BASE}/nav-rapid/page-a`);
await expect(page.locator("h1")).toHaveText("Page A");
await waitForAppRouterHydration(page);

// Navigate A -> B
await page.click('[data-testid="page-a-link-to-b"]');
await expect(page.locator("h1")).toHaveText("Page B");

// Navigate B -> C
await page.click('[data-testid="link-to-c"]');
await expect(page.locator("h1")).toHaveText("Page C");

// Back navigation
await page.goBack();
await page.goBack();

// Should be back at A
await expect(page.locator("h1")).toHaveText("Page A");
await expect(page).toHaveURL(`${BASE}/nav-rapid/page-a`);

// Forward navigation
await page.goForward();
await page.goForward();

// Should be back at C
await expect(page.locator("h1")).toHaveText("Page C");
await expect(page).toHaveURL(`${BASE}/nav-rapid/page-c`);

// Verify no navigation-related errors
const navigationErrors = errors.filter(
(e) => e.includes("navigation") || e.includes("vinext") || e.includes("router"),
);
expect(navigationErrors).toHaveLength(0);
});
});
94 changes: 94 additions & 0 deletions tests/e2e/app-router/rapid-navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { test, expect } from "@playwright/test";

const BASE = "http://localhost:4174";

/**
* Wait for the RSC browser entry to hydrate. The browser entry sets
* window.__VINEXT_RSC_ROOT__ after hydration completes.
*/
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 });
}
Comment on lines +9 to +14
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we reuse the shared helper instead of making a new one?


test.describe("rapid navigation", () => {
test("A→B→C rapid navigation completes smoothly", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});

// Start at page A
await page.goto(`${BASE}/nav-rapid/page-a`);
await expect(page.locator("h1")).toHaveText("Page A");
await waitForHydration(page);

// Click B then immediately click C (before B fully commits)
// Use a single page.evaluate() to click both links atomically.
// This avoids React re-render issues and prevents "execution context destroyed" errors in CI.
await page.evaluate(() => {
const linkB = document.querySelector('[data-testid="page-a-link-to-b"]') as HTMLElement;
const linkC = document.querySelector('[data-testid="page-a-link-to-c"]') as HTMLElement;
if (linkB) linkB.click();
if (linkC) linkC.click();
});

// Use toHaveURL (polling) instead of waitForURL (navigation event) because
// rapid back-to-back client-side navigations abort the first navigation,
// causing waitForURL to fail with ERR_ABORTED.
await expect(page).toHaveURL(`${BASE}/nav-rapid/page-c`, { timeout: 10_000 });
await expect(page.locator("h1")).toHaveText("Page C");

const navigationErrors = errors.filter(
(e) => e.includes("navigation") || e.includes("vinext") || e.includes("router"),
);
expect(navigationErrors).toHaveLength(0);
});

test("same-route query change during cross-route navigation", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});

// Start at page A
await page.goto(`${BASE}/nav-rapid/page-a`);
await expect(page.locator("h1")).toHaveText("Page A");
await waitForHydration(page);

// Navigate to B then immediately change query param (same-route nav)
// Use a single page.evaluate() to click both links atomically.
//
// Why isSameRoute === true for the second navigation:
// Both clicks run synchronously in the same microtask. The first click calls
// setPendingPathname('/nav-rapid/page-b', navId=1), storing the pending target.
// The second click then reads pendingPathname (which is '/nav-rapid/page-b'),
// compares it to its own target pathname (also '/nav-rapid/page-b'), and sees
// they match — so isSameRoute is true. This is correct behavior: the second
// navigation is a same-route query-param change, not a cross-route navigation.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is a great addition — it explains the non-obvious mechanics of why isSameRoute === true for the second navigation. This was requested in round 4 and it reads well.

await page.evaluate(() => {
const linkB = document.querySelector('[data-testid="page-a-link-to-b"]') as HTMLElement;
const linkFilter = document.querySelector(
'[data-testid="page-a-link-to-b-filter"]',
) as HTMLElement;
if (linkB) linkB.click();
if (linkFilter) linkFilter.click();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is improved from the earlier version (the ?filter=test link is now on Page A), but the scenario it exercises is subtle: both clicks happen synchronously in the same microtask, so the second navigateRsc call runs immediately after the first increments activeNavigationId. The first navigation's setPendingPathname('/nav-rapid/page-b', 1) is called, then the second navigation reads pendingPathname (which is /nav-rapid/page-b, base-path-stripped) and compares against its own target (/nav-rapid/page-b). So isSameRoute is true for the second nav, which is correct — it's a same-route query-param change.

The test validates the outcome correctly, but a brief inline comment explaining why the second navigation sees isSameRoute === true (pendingPathname from the first click matches the second click's pathname) would help future readers understand what this is actually testing.

});

// Should settle on B with query param
await expect(page.locator("h1")).toHaveText("Page B");
await expect(page).toHaveURL(`${BASE}/nav-rapid/page-b?filter=test`);

// Verify no navigation-related errors
const navigationErrors = errors.filter(
(e) => e.includes("navigation") || e.includes("vinext") || e.includes("router"),
);
expect(navigationErrors).toHaveLength(0);
});
});
3 changes: 3 additions & 0 deletions tests/fixtures/app-basic/app/nav-rapid/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function NavRapidLayout({ children }: { children: React.ReactNode }) {
return <main data-testid="nav-rapid-container">{children}</main>;
}
Loading
Loading