Skip to content

fix(navigation): track pending pathname to fix isSameRoute race condition during rapid navigation#745

Open
Divkix wants to merge 13 commits intocloudflare:mainfrom
Divkix:fix/744-pending-pathname-race
Open

fix(navigation): track pending pathname to fix isSameRoute race condition during rapid navigation#745
Divkix wants to merge 13 commits intocloudflare:mainfrom
Divkix:fix/744-pending-pathname-race

Conversation

@Divkix
Copy link
Copy Markdown
Contributor

@Divkix Divkix commented Apr 2, 2026

Fixes #744

Problem

During rapid successive navigations (e.g., user clicks A→B→C quickly), the "isSameRoute" check in app-browser-entry.ts compared against stale window.location.pathname because URL commits are deferred via useLayoutEffect.

Solution

Add pendingPathname to ClientNavigationState to track the destination URL of in-flight navigations. This allows isSameRoute to compare against the most recent pending destination instead of stale window.location.pathname.

Changes

  • Add pendingPathname field to ClientNavigationState type in navigation.ts
  • Export setPendingPathname() and clearPendingPathname() helpers
  • Update isSameRoute logic in app-browser-entry.ts to compare against: pendingPathname → cachedPathname → window.location.pathname
  • Clear pendingPathname on navigation commit (commitClientNavigationState) and error
  • Add unit tests for helper functions (server-side no-op behavior)
  • Add E2E tests for rapid navigation scenarios (A→B→C, same-route query change, back/forward, interrupted navigation)

Edge Cases Handled

  • A→B→C rapid navigation: C compares against B (pending) rather than A (stale window.location)
  • Navigation superseded: New navigation overwrites pending, old bails out via navId check
  • Navigation error: clearPendingPathname() resets state
  • Back/forward: Different code path (popstate), not affected by this change

Testing

  • Unit tests: 2 tests for server-side helper function safety
  • E2E tests: 4 scenarios covering rapid navigation patterns
  • Client-side behavior covered by E2E tests in rapid-navigation.spec.ts
  • All existing tests pass

…tion during rapid navigation

Fixes race condition where isSameRoute compared against stale window.location.pathname
during rapid successive navigations (A→B→C). Previously, when navigation C started before
navigation B committed, C would compare against A instead of B, causing janky transitions.

Changes:
- Add pendingPathname field to ClientNavigationState in navigation.ts
- Export setPendingPathname() and clearPendingPathname() helpers
- Update isSameRoute logic in app-browser-entry.ts to compare against:
  1. pendingPathname (previous in-flight navigation)
  2. cachedPathname (last committed navigation)
  3. window.location.pathname (fallback)
- Clear pendingPathname on navigation commit and error
- Add unit tests for helper functions
- Add E2E tests for rapid navigation scenarios

The fix ensures correct same-route vs cross-route classification even when URL
commits are deferred via useLayoutEffect.

Closes cloudflare#744
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 2, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@745

commit: 44d466e

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: pending pathname race condition fix

The approach — tracking the intended destination of in-flight navigations to fix stale isSameRoute comparisons — is the right idea. The core change is small and well-placed. A few issues to address though:

Bug: clearPendingPathname() in catch block can clobber active navigation

This is the main concern. When a superseded navigation errors in the catch block, clearPendingPathname() at line 778 runs unconditionally before the navId !== activeNavigationId stale check at line 781. This means a failing old navigation clears the pendingPathname that was set by the newer active navigation.

In practice, this is likely low-impact because the newer navigation already read pendingPathname before the older one's catch fires (the isSameRoute comparison at line 625-629 happens synchronously at the top of navigateRsc). But it leaves the state incorrect: if a third navigation fires after the catch clears it, it would fall through to cachedPathname instead of seeing the second navigation's pending value. The fix is simple: guard clearPendingPathname() behind the same stale check, or move it after the stale check.

Unit tests provide minimal coverage

The unit tests in navigation-pending-pathname.test.ts only verify that the functions are exported and have the correct .length. They don't test any behavior — setPendingPathname is never called on actual state, clearPendingPathname is never verified to clear anything, and the interaction with commitClientNavigationState is not tested. These are essentially no-ops as regression tests. Given that the helpers early-return in server/test environments (isServer check, no window), the functions literally do nothing when called in these tests.

The E2E tests are well-structured and cover the important scenarios. But the unit test file should either be removed (if you're relying solely on E2E coverage) or replaced with tests that actually exercise the state machine — e.g., using jsdom or mocking getClientNavigationState.

E2E test concern: page.goto() is not a client-side navigation

See inline comment on the "same-route query change" test.

commitClientNavigationState();
}
// Clear pending pathname on error so subsequent navigations compare correctly
clearPendingPathname();
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.

Bug: clearPendingPathname() runs unconditionally, but this catch block also handles superseded navigations (line 781 checks navId !== activeNavigationId). A superseded navigation's error handler will clear the pendingPathname that was set by the active (newer) navigation.

Should be guarded the same way as the _snapshotPending cleanup above it — only clear if this is still the active navigation:

Suggested change
clearPendingPathname();
// 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();
}

Alternatively, move it after the stale check at line 781.

Comment on lines +1 to +6
import { describe, it, expect } from "vite-plus/test";

describe("navigation state - pendingPathname helpers", () => {
describe("type definition", () => {
it("exports setPendingPathname function", async () => {
const nav = await import("../packages/vinext/src/shims/navigation.js");
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.

These tests only verify that the functions are exported and have the expected .length — they don't test any actual behavior. Since setPendingPathname and clearPendingPathname early-return when isServer is true or getClientNavigationState() returns null (which it does in a test environment without window), these functions are literally no-ops when called.

Either:

  1. Remove this file and rely on the E2E tests for coverage, or
  2. Replace with tests that actually exercise the state machine (mock window, verify that getClientNavigationState().pendingPathname changes after setPendingPathname, verify commitClientNavigationState clears it, etc.)

As-is, these tests give a false sense of coverage.

await page.click('a[href="/nav-rapid/page-b"]');

// Immediately change query param (same-route navigation to B with query)
await page.goto(`${BASE}/nav-rapid/page-b?filter=test`);
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.

page.goto() is a full page navigation (hard nav), not a client-side SPA navigation. This doesn't test the "same-route query change during cross-route navigation" scenario described in the test name — it tests "hard navigation interrupts a pending client-side navigation."

To actually test a same-route query change via client-side navigation, you'd need to trigger router.push('/nav-rapid/page-b?filter=test') via page.evaluate after the click, or add a link on page B that navigates to ?filter=test.

The test still has value (verifying hard nav during pending client nav doesn't break), but the name and comments are misleading.

*/
export function setPendingPathname(pathname: string): void {
const state = getClientNavigationState();
if (!state || isServer) 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() already returns null when isServer is true (line 491), so the if (!state || isServer) return collapses to just if (!state) return — which is what clearPendingPathname uses at line 751. Consider making them consistent:

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

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 2, 2026

Review posted successfully. Here's a summary of the feedback:

Key findings

  1. Bug in catch block (app-browser-entry.ts:778): clearPendingPathname() runs unconditionally before the stale-navigation check. When a superseded navigation errors, this clears the pendingPathname set by the active (newer) navigation. Should be guarded with navId === activeNavigationId to only clear when this is still the active navigation.

  2. Unit tests are no-ops (navigation-pending-pathname.test.ts): The 4 tests only check typeof and .length — they never exercise actual state. Since setPendingPathname/clearPendingPathname early-return in server/test environments (no window), these tests verify nothing about behavior. Should either be removed or replaced with real state machine tests.

  3. E2E test uses wrong navigation type (rapid-navigation.spec.ts:63): The "same-route query change" test uses page.goto() which is a hard navigation, not a client-side SPA navigation. It doesn't test the scenario described in the test name.

  4. Minor: redundant isServer guard (navigation.ts:742): setPendingPathname has if (!state || isServer) return but getClientNavigationState() already returns null when isServer is true, making the isServer check redundant and inconsistent with clearPendingPathname which only checks if (!state).

github run

@james-elicx james-elicx marked this pull request as draft April 2, 2026 10:08
…dition fix

This commit addresses all issues raised in PR review cloudflare#745:

1. Fix race condition in catch block (app-browser-entry.ts)
   - Guard clearPendingPathname() with navId === activeNavigationId check
   - Prevents superseded navigations from clobbering pendingPathname of active nav
   - Ensures state consistency when rapid successive navigations occur

2. Remove useless unit tests (navigation-pending-pathname.test.ts)
   - Deleted file that only tested typeof/.length, not actual behavior
   - Functions early-return in test environment (no window), making tests no-ops
   - E2E tests provide sufficient coverage for this functionality

3. Fix E2E test to use client-side navigation (rapid-navigation.spec.ts)
   - Changed page.goto() (hard nav) to Link click (client-side SPA nav)
   - Added query param link to page-b fixture
   - Test now correctly validates same-route query change scenario

4. Remove redundant isServer guard (navigation.ts)
   - getClientNavigationState() already returns null when isServer is true
   - Made consistent with clearPendingPathname() which only checks (!state)

All review feedback items addressed:
- clearPendingPathname() race condition fixed
- Unit tests deleted (no false coverage)
- E2E test uses correct navigation type
- Code consistency improved
Divkix added a commit to Divkix/vinext that referenced this pull request Apr 2, 2026
- Update comment to reference getClientTreeshakeConfigForVite instead of deprecated clientTreeshakeConfig
- Remove redundant not.toHaveProperty('preset') assertions from tests (toEqual already does exact matching)

Addresses review feedback from @ask-bonk[bot] on PR cloudflare#745
james-elicx pushed a commit that referenced this pull request Apr 2, 2026
* fix(build): eliminate Vite 8 treeshake.preset warning

Add version-gated getClientTreeshakeConfigForVite() function that returns
Rollup-compatible config (with preset) for Vite 7 and Rolldown-compatible
config (without preset) for Vite 8+.

The treeshake.preset option is Rollup-specific and causes warnings in Vite 8
which uses Rolldown. The moduleSideEffects: 'no-external' option is valid in
both bundlers and is preserved in all configurations.

Changes:
- Add getClientTreeshakeConfigForVite(viteMajorVersion) function
- Update 4 usage sites to use version-gated function
- Mark clientTreeshakeConfig as @deprecated for backward compatibility
- Export getClientTreeshakeConfigForVite for testing
- Add comprehensive tests for Vite 7/8/9+ compatibility
- Update existing tests to expect Vite 8 format

Fixes #540

* refactor(build): address PR review feedback for treeshake config

Remove dead workaround plugin from playground
- Deleted strip-rolldown-incompatible-rollup-options plugin from
  examples/app-router-playground/vite.config.ts
- This workaround is no longer needed since getClientTreeshakeConfigForVite()
  never emits treeshake.preset for Vite 8+

Add documentation comment about Rolldown defaults
- Enhanced JSDoc for getClientTreeshakeConfigForVite() to explain behavior gap
- Documents how Rolldown defaults compare to Rollup's recommended preset
- Clarifies that moduleSideEffects: no-external is the key optimization

Fix test import style
- Changed from dynamic await import() to static imports
- Matches existing test patterns in build-optimization.test.ts
- Removes async from test cases that don't need it

Addresses review comments from PR #746

* docs(build): clarify Rolldown treeshake divergence in comments

Address PR #746 review feedback from @ask-bonk:

- JSDoc: Explicitly call out unknownGlobalSideEffects as a known acceptable
divergence (Rolldown defaults to true vs Rollup recommended preset's false).
Makes it clear this is intentional, not an oversight.

- Inline comment: Note that Rolldown's built-in defaults already cover what
Rollup's 'recommended' preset provides (annotations, correctContext,
tryCatchDeoptimization). Provides immediate context at the code site.

Both changes are documentation-only; no functional impact.

* chore: address PR review feedback on treeshake config

- Update comment to reference getClientTreeshakeConfigForVite instead of deprecated clientTreeshakeConfig
- Remove redundant not.toHaveProperty('preset') assertions from tests (toEqual already does exact matching)

Addresses review feedback from @ask-bonk[bot] on PR #745
@Divkix Divkix marked this pull request as ready for review April 3, 2026 15:23
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: pending pathname race condition fix (round 2)

The core approach is sound — tracking the intended destination of in-flight navigations via pendingPathname to fix stale isSameRoute comparisons during rapid navigation. The catch block bug from the previous review has been addressed (now guarded with navId === activeNavigationId). A few remaining issues:

PR description claims unit tests that don't exist

The PR body says "Add unit tests for helper functions" and "Unit tests: 4 tests for helper functions," but no unit test file is present in the diff. This appears to reference a navigation-pending-pathname.test.ts file that was either removed or never committed. The PR description should be updated to match what's actually included.

E2E test concern: "same-route query change" test doesn't test rapid navigation

The test on line 46 claims to test "same-route query change during cross-route navigation" but it can't work as described — see inline comment.

Minor: redundant isServer guard

See inline comment on setPendingPathname.

await page.click('a[href="/nav-rapid/page-b"]');

// Immediately change query param via client-side navigation (same-route nav to B with query)
await page.click('a[href="/nav-rapid/page-b?filter=test"]');
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 page.click targets a[href="/nav-rapid/page-b?filter=test"], but that link only exists on Page B (the "Add Filter" link). Page A only has links to /nav-rapid/page-b and /nav-rapid/page-c.

So this click can only succeed after Page B has fully rendered from the click on line 60, meaning both navigations run sequentially — not rapidly. This test doesn't actually exercise the "same-route query change during cross-route navigation" scenario it describes.

To actually test rapid same-route-then-query navigation, the ?filter=test link needs to be accessible before Page B renders. Options:

  1. Add the ?filter=test link to Page A as well
  2. Use page.evaluate(() => window.__VINEXT_RSC_NAVIGATE__('/nav-rapid/page-b?filter=test')) immediately after the click on line 60 to trigger a programmatic client-side navigation without waiting for the DOM

*/
export function setPendingPathname(pathname: string): 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;

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 3, 2026

Review posted successfully. Here's a summary of the feedback:

Core approach is sound. The pendingPathname tracking to fix stale isSameRoute comparisons during rapid navigation is the right idea, and the catch-block bug from the first review has been correctly addressed.

Three remaining issues:

  1. PR description is inaccurate — It claims "Add unit tests for helper functions" and "Unit tests: 4 tests for helper functions," but no unit test file exists in the diff. The description should be updated.

  2. "Same-route query change" E2E test doesn't test what it claims — The ?filter=test link only exists on Page B, so page.click('a[href="/nav-rapid/page-b?filter=test"]') can only succeed after Page B has fully rendered. This means the two navigations run sequentially, not rapidly — it doesn't exercise the race condition scenario.

  3. Minor: redundant isServer guard in setPendingPathnamegetClientNavigationState() already returns null when isServer is true, so the extra check is unnecessary and inconsistent with clearPendingPathname.

github run

Divkix added 3 commits April 3, 2026 13:40
- Add 'Go to B with Filter' link to Page A for proper rapid nav testing
- Add 4 unit tests for setPendingPathname/clearPendingPathname helpers
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: pending pathname race condition fix (round 3)

The core approach is correct and the implementation is clean. The catch-block bug from round 1 has been properly addressed, and the E2E test fixture for the "same-route query change" scenario was fixed by adding the ?filter=test link to Page A (so the second click can fire before Page B renders). Two minor items remain.

Remaining issues

  1. Redundant isServer guard in setPendingPathname — Flagged in both prior reviews and still unaddressed. One-line fix.

  2. Unit tests are still no-ops — The tests in shims.test.ts only verify the functions don't throw on the server. Since both functions early-return when getClientNavigationState() returns null (which it always does on the server), these tests assert that return; doesn't throw. That's trivially true for any function. They add no regression coverage for the actual pending pathname state machine.

    The tests aren't wrong — they're just not useful. If you want to keep them, that's fine, but they shouldn't be counted as behavioral coverage in the PR description. The E2E tests are doing the real work here.

What looks good

  • The pendingPathname → cachedPathname → window.location.pathname fallback chain is correct. cachedPathname is the base-path-stripped last-committed URL, which is the right intermediate source.
  • Reading state before calling setPendingPathname (line 621 before line 632) ensures the current navigation compares against the previous pending target, not its own.
  • The catch-block guard (navId === activeNavigationId) correctly prevents a failing superseded navigation from clobbering the active navigation's pending pathname.
  • commitClientNavigationState() clearing pendingPathname on success is the right place — it's where cachedPathname gets synced from window.location, so the pending value is no longer needed.
  • The superseded-navigation early-return points (lines 646, 655, 696, 734, 740) correctly do not clear pendingPathname, since the newer navigation has already overwritten it.

*/
export function setPendingPathname(pathname: string): 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.

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;


// On server, getClientNavigationState returns null
// This should not throw and should be a no-op
expect(() => setPendingPathname("/some-path")).not.toThrow();
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 verifies that calling a function which does if (!state) return; doesn't throw — that's trivially true for any function with a guard clause. It doesn't exercise the pending pathname state machine at all.

Not blocking, but these shouldn't be described as meaningful unit test coverage in the PR description. The E2E tests are where the real coverage lives.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 3, 2026

Review posted successfully. Here's a summary:

PR #745 — Round 3 Review

The core fix is sound: tracking pendingPathname to fix stale isSameRoute comparisons during rapid navigation is the right approach, and the implementation is clean. The catch-block bug from round 1 was properly fixed, and the E2E test fixtures were updated to address the "same-route query change" concern.

Two minor items remain:

  1. Redundant isServer guard in setPendingPathname (navigation.ts:740) — flagged in both prior reviews, still unaddressed. getClientNavigationState() already returns null when isServer is true, so the extra check is unnecessary and inconsistent with clearPendingPathname. Posted a suggestion for the one-line fix.

  2. Unit tests are no-ops — The two tests in shims.test.ts only verify that server-side calls don't throw, which is trivially true for any function with a if (!state) return guard. Not blocking, but the PR description overstates their coverage value. The E2E tests are doing the real work.

github run

Divkix added 4 commits April 3, 2026 14:52
Update test descriptions to acknowledge these are smoke tests for server-side
safety, not behavioral unit tests. The real coverage is in E2E tests.

Addresses review feedback from PR cloudflare#745.
Prevents stale pendingPathname from affecting isSameRoute calculations
during rapid successive navigations (A→B→C). When a new navigation to
a different route starts, clear the pending pathname so subsequent
navigations compare against the correct current location.

Fixes cloudflare#744
The fix for issue cloudflare#744 introduced a race condition where clearing
pendingPathname when navigating to a different route broke the
navigation chain during rapid successive navigations (A→B→C).

Instead of conditionally clearing, we now always overwrite the
pending pathname with the new target. This ensures subsequent
navigations always compare against the most recent in-flight
target without race conditions.

Fixes CI failure in E2E app-router tests.
… pathname state

Remove the navId === undefined check from commitClientNavigationState
condition to ensure only the navigation that set pendingPathname (or a
newer navigation) can clear it. This prevents superseded navigations
called via drainPrePaintEffects from clearing state belonging to active
navigations during rapid successive clicks (A→B→C pattern).

Fixes race condition in rapid-navigation E2E tests where URL ended up at
intermediate state instead of final target.
@Divkix Divkix force-pushed the fix/744-pending-pathname-race branch from 2616cad to 6299b3d Compare April 4, 2026 01:43
… pathname state

The fix for issue cloudflare#744 introduced a race condition where superseded
navigations could clear pending pathname state belonging to newer
navigations during rapid successive clicks (A→B→C pattern).

Changes:
- Remove navId === undefined check from commitClientNavigationState()
  condition to ensure only the navigation that set pendingPathname
  (or a newer one) can clear it
- Fix E2E tests to use synchronous clicking via page.evaluate() to
  avoid React re-render race conditions where clicks land on wrong
  elements during DOM transitions
- Add page-specific data-testid attributes to fixture links for
  more robust test selectors

Fixes rapid-navigation E2E test failures where URL ended up at
intermediate state (page-b?filter=test) instead of final target (page-c).
@Divkix Divkix force-pushed the fix/744-pending-pathname-race branch from 1f62b07 to cbd5049 Compare April 4, 2026 04:00
Rapid back-to-back client-side navigations (clicking link B then
immediately link C in a single page.evaluate()) abort the first
navigation, causing page.waitForURL() to fail with ERR_ABORTED
since it waits for a 'load' navigation event.

Use expect(page).toHaveURL() instead, which polls the URL without
requiring a navigation event — correct for soft client-side navigations.

Fixes CI failure on fix/744-pending-pathname-race branch.
@Divkix
Copy link
Copy Markdown
Contributor Author

Divkix commented Apr 4, 2026

Finally CI passes

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@Divkix hey, you wanna take a crack at phase 4? #726 (comment)

It should be clean once #750 gets merged.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: pending pathname race condition fix

The core approach is correct — tracking pendingPathname on the client navigation state to prevent stale isSameRoute comparisons during rapid A→B→C navigations. The navId-based ownership model for clearing pendingPathname is a solid design that prevents superseded navigations from clobbering active state.

The previous review rounds caught the important bugs (catch-block clobbering, E2E fixture links). What remains are two minor items — one is a real (if low-risk) correctness issue, the other a consistency nit.

Overall the PR is in good shape. The E2E tests are well-structured and cover the important scenarios.

@@ -767,6 +796,12 @@ async function main(): Promise<void> {
_snapshotPending = false;
commitClientNavigationState();
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 commitClientNavigationState() call (without navId) runs when _snapshotPending is true in the outer catch. It happens to be safe because commitClientNavigationState(undefined) won't match any pendingPathnameNavId that's a number — the condition state.pendingPathnameNavId === navId will be number === undefined which is false.

But this is fragile and relies on an implicit type mismatch. If someone later changes the navId parameter type or the clearing condition, this could silently start clobbering pending pathnames from other navigations.

Since navId is available in scope here, it would be safer and more consistent to pass it:

Suggested change
commitClientNavigationState();
commitClientNavigationState(navId);

This makes the intent explicit and matches the pattern used in createNavigationCommitEffect, renderNavigationPayload, and updateBrowserTree.

*/
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.

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;

'[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.

Comment on lines +17 to +50
test("A→B→C rapid navigation completes smoothly", async ({ page }) => {
// Collect console errors during the 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 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);
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: Tests 1 and 4 ("A→B→C rapid navigation" and "interrupted navigation") are functionally identical — they both click B then C atomically from Page A and assert the final URL is /nav-rapid/page-c. The only difference is test 4 also checks a window.__NAV_MARKER__ to verify no full-page reload.

Consider merging these into a single test, or adding the marker check to test 1 and removing test 4. Having two tests that exercise the exact same code path (with the same clicks, same assertions on URL and heading) adds maintenance surface without additional coverage.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 4, 2026

Review posted successfully. Here's a summary of the feedback:

Overall: The PR is in good shape. The pendingPathname tracking with navId-based ownership is the right approach to fix the stale isSameRoute race condition during rapid navigation.

4 comments posted:

  1. commitClientNavigationState() without navId in catch block (app-browser-entry.ts:797) — This works by accident because undefined !== number is false, but it's fragile. Since navId is in scope, it should be passed explicitly to match the pattern used everywhere else. Suggested fix included.

  2. Redundant isServer guard in setPendingPathname (navigation.ts:743) — Flagged in all three prior review rounds and still unaddressed. One-line fix suggested.

  3. E2E test could use a clarifying comment (rapid-navigation.spec.ts:74) — The "same-route query change" test works correctly now, but the mechanics of why isSameRoute === true for the second navigation are non-obvious and would benefit from an inline explanation.

  4. Tests 1 and 4 are near-duplicates (rapid-navigation.spec.ts) — Both click B→C atomically from Page A and assert the same outcome. The only difference is the __NAV_MARKER__ check. Suggested merging them.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Race condition in isSameRoute classification causes janky transitions during rapid navigation

3 participants