Skip to content

fix(router): preserve basePath in Pages Router events - #2888

Merged
james-elicx merged 14 commits into
mainfrom
codex/fix-basepath-router-events-31439707085
Aug 13, 2026
Merged

fix(router): preserve basePath in Pages Router events#2888
james-elicx merged 14 commits into
mainfrom
codex/fix-basepath-router-events-31439707085

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • preserve browser-visible basePath URLs across Pages Router route, hash, cancellation, failure, popstate, and redirect events
  • align superseded navigation cancellation, redirect recursion, event ordering, and destination-only history state with Next.js
  • keep leading-double-slash redirects pinned to their validated same origin for fetches and history
  • make the deferred popstate regression wait for route completion deterministically

Failure mapping

Fixes all four non-deferred failures from Actions run 31439707085, job 93624401572 in test/e2e/basepath/router-events.test.ts:

  • should use urls with basepath in router events
  • should use urls with basepath in router events for hash changes
  • should use urls with basepath in router events for cancelled routes
  • should use urls with basepath in router events for failed route change

Validation

  • targeted Next.js E2E: 4/4 passed
    • REPO="$(pwd)" NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" VINEXT_BUILD=0 ./scripts/run-targeted-nextjs-e2e.sh test/e2e/basepath/router-events.test.ts
  • vp test run tests/shims.test.ts: 1,293/1,293 passed
  • deferred popstate timing regression: 20/20 isolated runs passed
  • vp check: passed
  • vp run vinext#build: passed
  • independent final review: no findings

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2888
npm i https://pkg.pr.new/create-vinext-app@2888
npm i https://pkg.pr.new/@vinext/types@2888
npm i https://pkg.pr.new/vinext@2888

commit: b1c3ade

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared b1c3ade against base fb7c22f using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 135.4 KB 135.4 KB ⚫ -0.0%
Client entry size (gzip) vinext 122.8 KB 122.8 KB ⚫ +0.0%
Dev server cold start vinext 2.93 s 2.93 s ⚫ -0.1%
Production build time vinext 3.05 s 3.06 s ⚫ +0.2%
RSC entry closure size (gzip) vinext 114.4 KB 114.4 KB ⚫ -0.0%
Server bundle size (gzip) vinext 193.8 KB 193.8 KB ⚫ -0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review

Reviewed the full diff for packages/vinext/src/shims/router.ts (+191/-92) and tests/shims.test.ts (+1028/-112). This is a solid, well-tested change: the migration from the fire-and-forget void performNavigation(...) redirect path to a throw/unwind (InternalNavigationRedirect) + caller re-dispatch is a genuine correctness improvement, and the activeNavigationEvent bookkeeping correctly guarantees the superseded routeChangeError fires before the winner's routeChangeStart across push/replace, popstate, and redirect paths.

Verification performed:

  • vp check packages/vinext/src/shims/router.ts and vp check tests/shims.test.ts — pass (format, lint, types)
  • vitest run tests/shims.test.ts — 1293/1293 pass
  • Confirmed all router-event logic is client-only (next/router shim); no dev/prod-server parity concern (grep of routeChange*/beforeHistoryChange/handleDataRedirect shows all in shims/router.ts)
  • Traced the double-slash redirect security path: resolveSameOriginRedirectedUrl/resolveLocalRedirectUrl both keep // app paths as absolute same-origin URLs, and performNavigation re-validates redirectBrowserHref origin against getWindowOrigin() before use (defense-in-depth) — looks correct.
  • NavigationCancelledError message/name change ("Route Cancelled", plain Error name, cancelled = true) matches Next.js parity and the ported test expectations.

Findings

1. [minor / docs] Stale comment describes the removed redirect mechanismpackages/vinext/src/shims/router.ts:2342-2349

The comment above the __N_REDIRECT handling still describes the old fire-and-forget approach:

Re-enter a fresh navigation to the destination — this increments the navigation id, which supersedes (cancels) the current navigation so the intermediate page is never committed.

That is no longer how it works. handleDataRedirect now throws InternalNavigationRedirect, which runNavigateClient returns as { redirectDestination }, and performNavigation unwinds the source navigation and re-dispatches (only the destination commits history state — matching the updated handleDataRedirect docstring at L1993-1996). This comment was last touched by 8ad8bca in this PR, so the mechanism changed directly around it but the prose was left behind. Recommend updating it to match the "caller unwinds and re-dispatches" model to avoid future confusion.

Non-issues considered

  • Redirect recursion depth: the recursive performNavigation on InternalNavigationRedirect has no explicit loop cap, but this matches Next.js (redirect chains rely on eventual hard-navigation / server behavior rather than a client-side counter), so not flagging.
  • _h: 1 (hydration) redirect preservation: { ...options, locale: false } in the recursion correctly preserves _h, and eventContext remains undefined for query-updating navigations, so no spurious events. Correct.

No blocking issues. Recommend fixing the stale comment (can be a follow-up).

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2888.

Summary

I reviewed the full diff (packages/vinext/src/shims/router.ts +191/-92, tests/shims.test.ts +1028/-112) and verified it with vp check (pass) and vitest run tests/shims.test.ts (1293/1293 pass).

Assessment: Solid, well-tested change. The core refactor — replacing the fire-and-forget void performNavigation(...) redirect path with a throw/unwind (InternalNavigationRedirect) + caller re-dispatch — is a genuine correctness improvement, and the activeNavigationEvent bookkeeping correctly orders the superseded routeChangeError before the winner's routeChangeStart across push/replace/popstate/redirect.

Verified specifically:

  • All router-event logic is client-only, so no dev/prod-server parity gap
  • Double-slash redirect security: same-origin // paths stay absolute and are re-validated against getWindowOrigin() in performNavigation (defense-in-depth)
  • NavigationCancelledError message/name change matches Next.js parity

One finding (minor/docs): The comment at router.ts:2342-2349 still describes the removed "increment navigation id supersedes the current navigation" mechanism, but the code now throws InternalNavigationRedirect and unwinds. It was touched by this PR's commit 8ad8bca, so the prose was left stale. Recommended as a non-blocking follow-up.

No blocking issues.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review

Re-reviewed the full diff after the latest commits (packages/vinext/src/shims/router.ts +195/-96, tests/shims.test.ts +1028/-112). This is a well-constructed change and I have no blocking findings.

Verification performed

  • vp check packages/vinext/src/shims/router.ts — pass (format, lint, types)
  • vitest run tests/shims.test.ts1293/1293 pass
  • Traced all four event paths (route, hash, cancellation, failure) and the redirect recursion end-to-end.

What I confirmed is correct

1. Double-slash redirect security (defense-in-depth holds). For a validated same-origin absolute redirect whose app pathname starts with //, the code keeps the app-relative //evil.com/... form as route/event state but threads the absolute same-origin URL through redirectBrowserHref for every browser-facing operation. I grepped for any remaining bare-full browser I/O and found none — every window.location.assign/replace and updateHistory in the redirect-reachable path now uses redirectBrowserHref ?? full (router.ts:3260, 3310-3311, 3332, 3339). browserEventUrl is re-derived from redirectBrowserHref only after re-validating parsed.origin === getWindowOrigin() (router.ts:3145-3155). So a leading // can never be reinterpreted as a protocol-relative host for history/fetch/hard-nav.

2. activeNavigationEvent lifecycle has no leak. The context is set at router.ts:3323 (push/replace) and 3721 (popstate) and cleared on every exit: routeChangeComplete (3378), redirect unwind (3349), error/failed path (runNavigateClient 2784), cancelActiveNavigationEvent (1513-1515), and popstate completion (3757). The superseded-error ordering guarantee (cancelActiveNavigationEvent runs navigationId += 1 + abort + cancelPreviousRenderCommit() before emitting the prior routeChangeError) correctly fires the loser's error before the winner's routeChangeStart across push/replace/popstate.

3. Redirect throw/unwind mechanism. handleDataRedirect is now correctly typed never (both branches throw), the internal InternalNavigationRedirect signal unwinds cleanly through runNavigateClient{ redirectDestination } → recursive performNavigation, and emitBeforeHistoryChange/updateHistory only run at the render-commit boundary — so the source navigation never commits history and only the destination does. The two-routeChangeStart sequence (source then destination) is validated by the ported test at tests/shims.test.ts:21904-21909 and matches Next.js's this.change() recursion.

4. NavigationCancelledError / "Failed to load static props" parity. Message/name/cancelled changes match the ported basePath event tests (e.g. tests/shims.test.ts:18139-18148 asserts cancelled = null for the failure case and the canonical message).

Prior finding — resolved

The stale comment at router.ts:2342-2349 flagged in the earlier review (describing the removed "increment navigation id supersedes" mechanism) has already been corrected by commit cc0a9e8 ("docs(router): clarify internal redirect flow"). It now accurately describes the throw/unwind + re-dispatch model.

Non-issues considered

  • Redirect recursion depth: no explicit client-side cap, but this matches Next.js (chains rely on eventual server/hard-nav behavior). Not flagging.
  • _h: 1 propagation on redirect: { ...options, locale: false } preserves _h, keeping eventContext undefined for hydration query updates so no spurious events fire. Correct, and redirects can only originate from non-shallow navigations (the runNavigateClient call is if (!shallow)-gated), so shallow never leaks into a redirect destination.

No blocking issues.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2888.

Summary

I re-reviewed the full diff (packages/vinext/src/shims/router.ts +195/-96, tests/shims.test.ts +1028/-112) and verified it with vp check (pass) and vitest run tests/shims.test.ts (1293/1293 pass).

Assessment: no blocking issues. This is a well-constructed change that correctly achieves its goal of preserving basePath-visible URLs across all Pages Router events while aligning cancellation, redirect recursion, and event ordering with Next.js.

What I verified as correct:

  1. Double-slash redirect security — the app-relative //-prefixed form stays as route/event state, while a re-validated same-origin absolute URL (redirectBrowserHref) is used for every browser I/O. I grepped and confirmed no bare-full remains in any window.location.assign/replace or updateHistory call in the redirect path.

  2. activeNavigationEvent lifecycle — set in two places, cleared on all five exit paths; no leak. Superseded-error ordering (abort + navigationId += 1 + render-commit cancel before emitting the loser's routeChangeError) is correct.

  3. Throw/unwind redirect mechanismhandleDataRedirect correctly typed never; source navigation never commits history, only the destination does; two-routeChangeStart sequence matches the ported test and Next.js's this.change() recursion.

  4. Event parityNavigationCancelledError message/name and the "Failed to load static props" error match the ported basePath E2E tests.

Prior finding resolved: the stale comment at router.ts:2342-2349 flagged in the earlier bonk review was already fixed by commit cc0a9e8.

No local .nextjs-ref clone was available, so I relied on the ported test assertions (which cite the exact Next.js source paths) as the authoritative behavior reference.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 11, 2026 04:41
@james-elicx

Copy link
Copy Markdown
Member Author

Independent targeted Next.js E2E verification on immutable head cc0a9e8a6de81e0058a09c68f022d90a363aaee7:

  • Suite: test/e2e/basepath/router-events.test.ts
  • Command: required scripts/run-targeted-nextjs-e2e.sh wrapper
  • Result: PASS — 4 passed, 0 skipped, 0 failed (4 total)
  • Retries: 0/0

The checkout remained clean and the local/remote PR head SHA matched after the run.

@james-elicx
james-elicx marked this pull request as draft August 11, 2026 05:43
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review

Re-reviewed the full diff (packages/vinext/src/shims/router.ts +214/-96, tests/shims.test.ts +1309/-146), with focus on the new commit 05e7397 ("follow redirects reached by popstate") that landed after the prior bonk reviews.

Verification performed

  • vp check packages/vinext/src/shims/router.ts — pass (format, lint, types)
  • vp test run tests/shims.test.ts1295/1295 pass
  • vp test run tests/shims.test.ts -t "popstate" — 9/9 pass (isolated)
  • vp run vinext#build — pass
  • Traced the four event paths (route, hash, cancel, failure), the push/replace redirect recursion, and the new popstate redirect recursion end-to-end.

What I confirmed is correct

1. New popstate-redirect path (05e7397) closes a real gap without weakening the // defense. When a popstate traversal hits a same-origin absolute middleware/config redirect whose app pathname starts with //, runNavigateClient now returns { redirectDestination }, the source event is unwound via clearActiveNavigationEvent, and performNavigation re-dispatches in "replace" mode (router.ts:3795-3812) — matching Next.js threading the popstate change("replaceState", ...) method through recursive redirects. The ported test asserts the security-critical split exactly: the //evil.example form is used only for event payloads, while the validated absolute same-origin target is used for both the fetch and the single replaceState, and location.assign/location.href are never pointed at the protocol-relative host.

2. activeNavigationEvent lifecycle has no leak across the redirect recursion. On redirect, the source context is cleared (router.ts:3349 push/replace, 3801 popstate) before recursing; the recursive call's cancelActiveNavigationEvent({ shallow }) at router.ts:3240 is then a no-op (active is null), so the source never emits a spurious routeChangeError. Superseded losers detect eventContext.cancellationEmitted in runNavigateClient (router.ts:2777) and return "cancelled" without re-emitting or clobbering the winner's context (identity-guarded clear). The two-routeChangeStart sequence (source then destination) is validated by the ported tests.

3. redirectBrowserHref threading is browser-I/O only. The absolute same-origin href flows into every window.location.assign/replace, updateHistory, and the hard-nav fallback (router.ts:2793, 3310-3311, 3332, 3339), while the //-app-relative form stays as route/event state. browserEventUrl is only re-derived from redirectBrowserHref after re-validating parsed.origin === getWindowOrigin() (router.ts:3145-3155). The data-JSON target is still derived from the route URL via resolvePagesDataNavigationTarget, not from the threaded fetchUrl, so the // host cannot leak into the data fetch.

4. Redirect throw/unwind mechanism. handleDataRedirect is correctly typed never; the InternalNavigationRedirect signal unwinds cleanly through runNavigateClient -> { redirectDestination } -> recursive performNavigation; emitBeforeHistoryChange/updateHistory only run at the render-commit boundary, so the source never commits history and only the destination does.

5. Option propagation on recursion. Push/replace passes { ...options, locale: false } (preserves _h; but _h===1 can't produce a redirect since that path skips event context, and shallow navigations never enter runNavigateClient). Popstate correctly passes { locale: false } since a browser popstate carries no TransitionOptions. Both are consistent with their origins.

6. Prior finding resolved. The stale comment at router.ts:2342-2349 flagged in the earlier review was corrected by cc0a9e8.

Non-issues considered

  • Redirect recursion depth: no explicit client-side cap, but this matches Next.js (chains rely on eventual server/hard-nav behavior). Not flagging.
  • stripLocalePrefixForApiRedirect bypassed for // paths in resolveLocalRedirectUrl: a same-origin // redirect that also carries a locale prefix would keep the prefix. This is an extremely narrow edge (protocol-relative-looking same-origin API redirect + i18n) and erring toward the validated absolute URL is the safe choice. Not flagging.

No blocking issues. This is a well-constructed, thoroughly tested change with correct dev/prod parity (all logic is client-only in the next/router shim).

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2888.

Summary

I reviewed the full diff (packages/vinext/src/shims/router.ts +214/-96, tests/shims.test.ts +1309/-146), focusing on the new commit 05e7397 ("follow redirects reached by popstate") that landed after the earlier bonk reviews. Verified with vp check (pass), vp test run tests/shims.test.ts (1295/1295 pass), popstate-isolated tests (9/9), and vp run vinext#build (pass).

Assessment: no blocking issues. This is a well-constructed, thoroughly tested change with correct dev/prod parity (all router-event logic is client-only in the next/router shim).

Key things I verified:

  1. The new popstate-redirect path closes a real gap and preserves the double-slash defense — the //evil.example form is used only for events while the validated absolute same-origin URL drives fetch/replaceState; location.assign/href never point at the protocol-relative host.
  2. activeNavigationEvent has no leak across redirect recursion; source contexts are cleared before recursing and the recursive cancelActiveNavigationEvent is a correct no-op.
  3. redirectBrowserHref is threaded to browser-I/O only; the data-JSON fetch target is still derived from the route URL, so the // host can't leak into the data fetch.
  4. The throw/unwind mechanism (handleDataRedirect typed never) keeps only the destination committing history.
  5. Option propagation differences between push/replace ({ ...options }) and popstate ({ locale: false }) are consistent with their origins.
  6. The prior stale-comment finding was already resolved by cc0a9e8.

Two narrow non-issues considered and dismissed (redirect recursion depth cap, locale-prefix bypass for // API redirects).

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Fresh independent review at exact head 05e7397f2fd99aa51d7a9ec6d2b1881fe248f49d: NO FINDINGS.

Reviewed the full two-file diff with specific focus on the new popstate-redirect commit.

Verified:

  • Popstate redirects re-enter navigation in replace mode, matching Next.js threading replaceState through recursive internal redirects; the traversed source entry never adds or preserves an intermediate history entry.
  • The source event context is cleared before recursion, so destination setup does not emit a spurious cancellation error. Event order is source routeChangeStart, destination routeChangeStart, destination beforeHistoryChange, destination routeChangeComplete; cleanup remains identity-guarded under supersession/failure.
  • Recursive config, middleware/data, and followed-HTML redirects keep the originating replace semantics. Only the final destination commits history and __NEXT_DATA__/rendered DOM state.
  • Same-origin absolute and leading-double-slash redirects retain their validated absolute URL for fetch/history/hard-navigation operations, while app-relative route/event state cannot be reinterpreted as an external protocol-relative host.
  • New regression tests cover config redirect popstate replacement and the security-sensitive absolute middleware redirect path, including loader selection, event order, single replaceState, final history state, final URL, and rendered destination.
  • Router-event behavior is entirely client-shim logic, so the same implementation serves dev and production builds.

Independent validation from a clean detached checkout:

  • vp test run tests/shims.test.ts: 1,295/1,295 passed.
  • Exact required-wrapper test/e2e/basepath/router-events.test.ts: 4 passed, 0 skipped, 0 failed, retry 0/0.
  • vp check: clean.
  • git diff --check: clean.

Local and remote head SHAs still match. PR merge state is currently UNSTABLE due to CI state, not a review finding.

@james-elicx
james-elicx marked this pull request as ready for review August 11, 2026 05:52
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

Fresh full deploy-suite run 31470591156 exposed three candidate-only use-router-with-rewrites failures after the history-timing changes in this PR: query-only push/replace navigations could be observed before their rewritten visible URL was committed.

Head dabc62b81a4a40c154830d87ea09e9f3f0c41aad adds a narrowly gated mounted-route fast path. Exact Next.js v16.2.6 deploy validation now passes all 9/9 assertions in test/e2e/use-router-with-rewrites/use-router-with-rewrites.test.ts; the original basepath/router-events target remains 4/4. Two independent reviews found no actionable parity or correctness issues.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review

Reviewed the full two-file diff (packages/vinext/src/shims/router.ts +261/-96, tests/shims.test.ts +1325/-138), with specific focus on the newest commit dabc62b ("preserve query-only rewrite history") that landed after the earlier bonk reviews.

Verification performed

  • vp check packages/vinext/src/shims/router.ts — pass (format, lint, types)
  • vitest run tests/shims.test.ts1295/1295 pass
  • Traced the new eager-commit path, both redirect recursions (push/replace + popstate), and the //-redirect security threading end-to-end.

What I confirmed is correct

1. New eager-commit path (dabc62b) is correctly gated and cannot orphan history. reusesMountedConfigRewritePage (router.ts:1904-1935) only commits history synchronously via the eager emitBeforeHistoryChange() when all of these hold: same origin+pathname, query actually changed, no config redirect could match, a statically-resolvable beforeFiles rewrite (resolveClientConfigRewriteSync returning undefined/{kind:"document"} both correctly fall through to return false), the rewrite target is the already-mounted component-only page (dataKind === "none", pattern === __NEXT_DATA__.page, no GIP), and no middleware/data probe (getMiddlewarePagesDataFetchUrl(...) === null). Because none of those redirect/fetch paths can fire, the eagerly-committed navigation can only fail on a render error — at which point the URL is already the committed target, matching Next.js's synchronous cached-route commit. The synchronous pushState/replaceState behavior is asserted directly (tests/shims.test.ts:22503-22511, 22531-22538, 22553-22561).

2. Double-slash redirect defense holds across all three entry points. For a validated same-origin absolute redirect whose app pathname begins with //, resolveSameOriginRedirectedUrl (router.ts:1600-1606) and resolveLocalRedirectUrl (router.ts:1649-1658) both return the full absolute same-origin URL (never a bare //), so the recursive new URL(redirectBrowserHref) at router.ts:3188 always parses and browserEventUrl is re-derived only after parsed.origin === getWindowOrigin(). The app-relative //evil.example form stays as route/event state while every browser I/O (updateHistory 3373, location.assign/replace 3333/3351, hard-nav fallback 2834, runNavigateClient fetch arg 3386) uses redirectBrowserHref ?? full. The security tests assert this split exactly for push, redirect, and the new popstate path (tests/shims.test.ts:19569, 19628, 19723, 20264) — event payload uses //evil.example, replaceState/fetch use the validated absolute target, and location.assign is never called.

3. activeNavigationEvent lifecycle has no leak. Set at router.ts:3364 (push/replace) and 3776 (popstate); cleared on every exit (routeChangeComplete 3425, redirect unwind 3396/3848, error/failed path in runNavigateClient 2825, cancelActiveNavigationEvent identity-guarded 3281). Superseded-error ordering (abort + navigationId += 1 + cancelPreviousRenderCommit() before emitting the loser's routeChangeError) is correct, and cancelActiveNavigationEvent runs before the new context is installed so it can only cancel the prior nav.

4. Popstate redirect recursion. Correctly threads "replace" mode (matching Next.js passing the popstate change() method through recursive redirects), clears the source context before recursing, and the recursive performNavigation emits its own routeChangeComplete + dispatchNavigateEvent (router.ts:3433), so no navigate event is lost. Only the destination commits history (single replaceState, asserted at tests/shims.test.ts:19829).

Non-issues considered

  • gSSP/gSP { redirect: { destination: "//evil.com" } }: handleDataRedirect treats a //-prefixed destination as internal (startsWith("/")), throws InternalNavigationRedirect("//evil.com/..."), and the recursion's new URL("//evil.com/...") throws → caught → browserEventUrl falls back to full while redirectBrowserHref drives location.assign("//evil.com/..."). This is a hard-navigation to the app author's chosen destination, matching Next.js's handleHardNavigation for gSSP redirects — not attacker-controllable in a new way. Not flagging.
  • Redirect recursion depth cap: none, but this matches Next.js (chains rely on eventual server/hard-nav). Not flagging.

Coverage note (non-blocking)

The eager-commit path's supersession-during-render case (a second navigation arriving after the eager emitBeforeHistoryChange but before render completes) is not directly asserted. It's low-risk because the gating guarantees no fetch/redirect, and the synchronous commit is exercised by the existing _next/data client navigation tests — but a targeted "eager commit then superseded" test would nail down the intended URL end state.

No blocking issues. This is a well-constructed, thoroughly tested change with correct dev/prod parity (all router-event logic is client-only in the next/router shim). No local .nextjs-ref clone was available, so I relied on the ported test assertions (which cite the exact Next.js source paths) as the authoritative behavior reference.

@ask-bonk

ask-bonk Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2888.

Summary

I reviewed the full two-file diff (packages/vinext/src/shims/router.ts +261/-96, tests/shims.test.ts +1325/-138), focusing on the newest commit dabc62b ("preserve query-only rewrite history") that landed after the prior bonk reviews. Verified with vp check (pass) and vitest run tests/shims.test.ts (1295/1295 pass).

Assessment: no blocking issues. Well-constructed, thoroughly tested change with correct dev/prod parity (all router-event logic is client-only in the next/router shim).

What I verified as correct:

  1. New eager-commit path (dabc62b)reusesMountedConfigRewritePage is tightly gated (static rewrite only, mounted component-only page, no middleware/data probe), so it can't orphan history; the synchronous pushState/replaceState is directly asserted.
  2. Double-slash redirect defense holds across push, redirect, and the new popstate path — validated absolute same-origin URL drives all browser I/O, //evil.example form stays event-only, location.assign never fires.
  3. activeNavigationEvent lifecycle has no leak; superseded-error ordering is correct.
  4. Popstate redirect recursion threads replace mode, clears source context before recursing, and doesn't lose the navigate event.

One non-blocking coverage note: the eager-commit "superseded during render" edge case isn't directly asserted (low-risk given the gating).

Two non-issues considered and dismissed (gSSP // redirect hard-nav matches Next.js; no recursion depth cap matches Next.js).

I couldn't find a real bug — the code was already reviewed multiple times and the security-critical paths are well-defended and thoroughly tested.

github run

@james-elicx
james-elicx merged commit 486653b into main Aug 13, 2026
66 checks passed
@james-elicx
james-elicx deleted the codex/fix-basepath-router-events-31439707085 branch August 13, 2026 09:17
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.

1 participant