Skip to content

fix(app-router): preserve rewritten route identity#2358

Merged
james-elicx merged 6 commits into
mainfrom
codex/fix-app-rewrite-route-identity
Jun 30, 2026
Merged

fix(app-router): preserve rewritten route identity#2358
james-elicx merged 6 commits into
mainfrom
codex/fix-app-rewrite-route-identity

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • keep browser-visible App Router pathnames separate from the route identity selected after middleware rewrites
  • encode RSC route/page keys from the rewritten matched route instead of the original visible URL
  • add a production regression for a prefetched route A whose navigation request rewrites to route B without changing the address bar

Next.js parity

Targets test/e2e/app-dir/concurrent-navigations/mismatching-prefetch.test.ts from Next.js v16.2.6.

The baseline deploy suite stops earlier in that test while waiting for Next.js's Suspense-derived partial prefetch request. Vinext's equivalent explicit loading-boundary fixture reproduces the latent next-stage failure: the response had params/segment data for B but __route and page element keys for A, causing the client RSC error boundary. This PR fixes that independently reproduced route-identity mismatch. The exact upstream suite still requires the separate Suspense shell-classification parity gap before it can reach this assertion.

Validation

  • vp check on all changed files
  • vp test run tests/app-page-render-identity.test.ts tests/app-page-element-builder.test.ts — 43 passed
  • production app-basic build and one-worker Playwright regression — 1 passed
  • vp run vinext#build
  • targeted Next.js deploy suite deployed successfully but retained the baseline first-stage timeout waiting for the Suspense-derived prefetch request

Excludes cacheComponents, PPR, resume, fallback-shell, and external endpoint failures.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2358
npm i https://pkg.pr.new/vinext@2358

commit: 1030e0b

@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

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): preserve rewritten route identity

I traced the change end-to-end and it is correct, minimal, and well-tested. Approving with one minor (non-blocking) suggestion.

What the change does

createAppPageRenderIdentity previously derived the RSC routeId/pageId from displayPathname (the browser-visible URL). After a middleware rewrite, displayPathname is the canonical URL the user sees (route A) while routePath is the post-rewrite matched route (route B). Encoding the keys from the visible URL produced a payload whose params/segment data were for B but whose __route/page element keys were for A — tripping the client RSC error boundary and forcing a hard reload.

The fix threads the matched route through a new optional matchedRoutePathname input and uses it (falling back to displayPathname) for routeId/pageId. app-page-element-builder.ts:229 wires it to routePath.

Correctness checks I verified

  • Architecture alignment. app-rsc-handler.ts:506 captures canonicalPathname = cleanPathname before rewrites mutate cleanPathname, and displayPathname: canonicalPathname is passed at app-rsc-handler.ts:1018. routePath is the rewritten match. So displayPathname (visible) vs routePath (matched) is exactly the right pair to split here.
  • Interception precedence preserved. matchedRoutePathname = sourceMatchedPathname ?? requestedMatchedRoutePathname keeps interception-source identity winning over the rewrite path, matching prior behavior. The interception unit tests still pass unchanged.
  • targetMatchedPathname still derives from displayPathname, so interception targetMatchedUrl/targetRouteId are unaffected; in the non-interception rewrite case interception is null so it's harmless.
  • Backward compatible. The new input is optional and defaults to displayPathname; the single call site is the only consumer.
  • Header value is real. The fixture middleware gates on x-vinext-rsc-render-mode !== "prefetch-loading-shell", which matches VINEXT_RSC_RENDER_MODE_HEADER (X-Vinext-Rsc-Render-Mode, headers.ts:62) and the prefetch-loading-shell mode (app-rsc-render-mode.ts:9). Case-insensitive header lookup is fine.
  • Fixture deps exist. connection() is exported from the next/server shim (shims/server.ts:1049).

Test quality

  • The e2e spec is a strong regression: it sets a window marker, asserts the loading boundary from A renders, content from B renders, the URL stays at /dynamic-page/a (visible) while serving B (rewritten), documentRequests is empty, and the marker survives — proving a soft SPA transition rather than a hard reload. Good fidelity to the upstream Next.js test it ports.
  • vp check clean on both changed source files; vitest on app-page-render-identity.test.ts + app-page-element-builder.test.ts → 43 passed.

Minor suggestion (non-blocking)

The new identity unit test covers the function directly, and the e2e covers the full integration, but there is no app-page-element-builder.test.ts case asserting that when displayPathname !== routePath the returned element map is keyed on the routePath-derived routeId (not the displayPathname one). That's the one seam between the builder wiring (matchedRoutePathname: routePath) and the identity function. A small builder-level assertion would close the loop and guard against a future regression where the wiring is dropped while the identity function stays correct. Optional.

Overall: correct, narrowly scoped, good parity comments and test coverage. LGTM.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2358.

Summary

PR #2358 (fix(app-router): preserve rewritten route identity) is correct, minimal, and well-tested. I traced the change end-to-end and approved it with one minor, non-blocking suggestion.

The fix: createAppPageRenderIdentity previously derived RSC routeId/pageId from displayPathname (browser-visible URL). After a middleware rewrite, that URL is route A while the matched route is B, so the payload had B's params/segments but A's route/page keys — tripping the client RSC error boundary into a hard reload. The PR threads a new optional matchedRoutePathname (wired to routePath) and encodes the keys from the matched route instead.

What I verified:

  • The visible-vs-matched split aligns with the handler's canonicalPathname (captured pre-rewrite) and post-rewrite routePath.
  • Interception precedence is preserved (sourceMatchedPathname ?? requestedMatchedRoutePathname); targetMatchedPathname still uses displayPathname.
  • Backward compatible — optional param defaults to displayPathname, single call site.
  • The fixture's x-vinext-rsc-render-mode/prefetch-loading-shell header values match real vinext internals; connection() is a real shim export.
  • The e2e spec is a strong regression (window marker, loading boundary A, content B, URL stays at A, no document requests).
  • vp check clean; 43 unit tests pass.

Minor suggestion: add a builder-level test asserting the returned element map is keyed on the routePath-derived routeId when displayPathname !== routePath, to guard the wiring seam. Non-blocking.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

Updated the regression to an isolated production-only browser fixture. This removes shared app-basic middleware changes, avoids dev mode where Link prefetch is intentionally disabled, and resolves the unrelated middleware invocation flake. Local exact browser spec passes at f1b7ce6c.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

UnknownError: "Overloaded"

github run

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow failed. Check the logs for details.

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 left a comment

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.

Review: fix(app-router): preserve rewritten route identity

The core fix is correct, minimal, and well-tested. I traced it end-to-end and verified the unit tests (44 pass). I found one leftover-dead-code issue that should be cleaned up before merge.

Core fix — correct

createAppPageRenderIdentity now threads a new optional matchedRoutePathname (wired to routePath at app-page-element-builder.ts:229) and derives routeId/pageId from it instead of from displayPathname. After a middleware rewrite, displayPathname is the visible URL (route A) and routePath is the post-rewrite matched route (B), so encoding the keys from the visible URL produced a payload whose params/segment data were for B but whose __route/page element keys were for A — tripping the client RSC error boundary.

Things I verified:

  • Aligns with the existing fallback. app-page-route-wiring.tsx:546-550 already encodes routeId/pageId from options.routePath when renderIdentity is absent. Before this PR, the renderIdentity-present path used displayPathname, so the two paths disagreed. The fix makes them consistent, and consistent with the rest of the element map (sourcePage, slot bindings, params all derive from route B).
  • Interception precedence preserved. matchedRoutePathname = sourceMatchedPathname ?? requestedMatchedRoutePathname keeps interception-source identity winning; targetMatchedPathname still derives from displayPathname, so targetMatchedUrl/targetRouteId are unaffected. The interception unit tests pass unchanged.
  • Backward compatible. The new input is optional and defaults to displayPathname; single call site.
  • The builder-level test (tests/app-page-element-builder.test.ts) asserts the element map is keyed on the routePath-derived id when displayPathname !== routePath — this closes the wiring-seam gap noted in the earlier review.

Issue: orphaned app-basic/middleware.ts changes (should be reverted)

tests/fixtures/app-basic/middleware.ts still contains the rewrite branch and matcher entry from the first commit (ec5a77c):

  • the /nextjs-compat/mismatching-prefetch/dynamic-page/ rewrite branch (lines 89-97)
  • the /nextjs-compat/mismatching-prefetch/:path* matcher entry (line 374)

But commit f1b7ce6 ("isolate rewrite prefetch regression") deleted the corresponding app-basic fixture pages (app/nextjs-compat/mismatching-prefetch/page.tsx, dynamic-page/[param]/page.tsx, dynamic-page/[param]/loading.tsx) and the old spec, replacing them with the self-contained mismatching-prefetch.browser.spec.ts (which writes its own temp fixture + middleware and uses a different route path, /mismatching-prefetch/..., without the /nextjs-compat prefix).

The result is that these app-basic middleware additions are dead code:

  • No fixture page exists under tests/fixtures/app-basic/app/nextjs-compat/mismatching-prefetch/.
  • No test references /nextjs-compat/mismatching-prefetch — the only two matches in the tree are the orphaned middleware lines themselves.
  • The matcher entry needlessly runs middleware on a non-existent route in the shared fixture.

This also contradicts the PR comment: "This removes shared app-basic middleware changes." They were not actually removed when the fixture was isolated in f1b7ce6.

Suggested fix: revert the two additions in tests/fixtures/app-basic/middleware.ts (the lines 89-97 branch and the line 374 matcher entry) so the shared fixture is untouched, matching the stated intent.

Verification

  • vp check / vitest run tests/app-page-render-identity.test.ts tests/app-page-element-builder.test.ts → 44 passed.
  • Removing the orphaned middleware lines is safe: nothing in the suite exercises that route.

Overall: the actual fix is good and should ship. Please drop the orphaned app-basic/middleware.ts additions first.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2358.

Summary

The core fix in PR #2358 is correct, minimal, and well-tested. It threads a new optional matchedRoutePathname (wired to routePath) so RSC routeId/pageId are encoded from the post-rewrite matched route (B) instead of the browser-visible displayPathname (A). I verified:

  • It aligns the renderIdentity-present path with the existing fallback in app-page-route-wiring.tsx:546-550 (which already used routePath) and with the rest of the element map.
  • Interception precedence and targetMatchedPathname are preserved; interception tests pass unchanged.
  • The new builder-level test closes the wiring-seam gap from the earlier review.
  • 44 unit tests pass.

One issue found (non-fatal, should fix before merge): orphaned dead code in tests/fixtures/app-basic/middleware.ts. Commit f1b7ce6 isolated the regression into a self-contained mismatching-prefetch.browser.spec.ts and deleted the app-basic fixture pages, but never reverted the middleware additions from ec5a77c:

  • the /nextjs-compat/mismatching-prefetch/dynamic-page/ rewrite branch (lines 89-97)
  • the /nextjs-compat/mismatching-prefetch/:path* matcher entry (line 374)

There are no fixture pages and no tests referencing that route — the only matches in the tree are the orphaned lines themselves. This contradicts the PR comment claiming the shared app-basic middleware changes were removed. I recommended reverting those two additions.

github run

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 8e7d88c against base de014d4 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 117.1 KB 117.1 KB ⚫ +0.0%
Client entry size (gzip) vinext 112.5 KB 112.5 KB ⚫ +0.0%
Dev server cold start vinext 2.64 s 2.60 s ⚫ -1.5%
Production build time vinext 3.19 s 3.14 s 🟢 -1.6%
RSC entry closure size (gzip) vinext 93.0 KB 93.0 KB ⚫ +0.0%
Server bundle size (gzip) vinext 156.9 KB 157.0 KB ⚫ +0.0%

View detailed results and traces

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

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(app-router): preserve rewritten route identity

Re-reviewed at 8e7d88c (HEAD). The core fix is correct, minimal, well-tested, and the prior review's blocker has been resolved. Recommend merge.

Core fix — correct (re-verified)

createAppPageRenderIdentity now threads an optional matchedRoutePathname (wired to routePath at app-page-element-builder.ts:229) and derives routeId/pageId from it instead of displayPathname. After a middleware rewrite, displayPathname is the visible URL (route A) while routePath is the post-rewrite matched route (B); encoding the keys from the visible URL produced a payload with B's params/segments but A's __route/page keys, tripping the client RSC error boundary.

I traced and confirmed:

  • Pre/post-rewrite split is the right pair. app-rsc-handler.ts:506 captures canonicalPathname = cleanPathname before rewrites mutate cleanPathname, and passes it as displayPathname at app-rsc-handler.ts:1018. routePath is the rewritten match. Splitting visible vs matched here is exactly correct.
  • Consistency with existing fallback. app-page-route-wiring.tsx:546-550 already encodes routeId/pageId from options.routePath when renderIdentity is absent. Before this PR the renderIdentity-present path disagreed (used displayPathname); the fix makes both paths consistent, and consistent with the rest of the element map (params/segments derive from route B).
  • routePath is always absolute (basePath-stripped request pathname), so the new call into normalizeAppPageRenderMatchedPathname will not spuriously throw its absolute-path assertion.
  • Interception precedence preserved. matchedRoutePathname = sourceMatchedPathname ?? requestedMatchedRoutePathname keeps interception-source identity winning; targetMatchedPathname still derives from displayPathname, so targetMatchedUrl/targetRouteId are unaffected. Interception unit tests pass unchanged.
  • Backward compatible. New input is optional, defaults to displayPathname; single call site.

Prior blocker resolved

The orphaned app-basic/middleware.ts additions flagged in the previous review were removed in commit 8e7d88c ("remove orphaned rewrite fixture", -17 lines). A repo-wide search for nextjs-compat/mismatching-prefetch now returns zero matches — the shared fixture is clean and the PR comment ("removes shared app-basic middleware changes") is now accurate.

Tests

  • 44 unit tests pass (app-page-render-identity.test.ts + app-page-element-builder.test.ts).
  • The new builder-level test keys the element map on the routePath-derived id when displayPathname !== routePath and asserts the /visible keys are absent — closes the wiring-seam gap from the first review.
  • The browser spec follows the established self-contained build-and-serve pattern (linkFixtureNodeModules / buildApp / runPrerender / startProdServer / closeServer) used by ssr-error-shell-recovery.browser.spec.ts and hash-rsc-requests.browser.spec.ts. It is a strong regression: content B renders while the URL stays at A, documentRequests is empty, and the window marker survives — proving a soft SPA transition rather than a hard reload.
  • vp check clean on both changed source files.

Minor (non-blocking) — test does not assert the loading shell

The spec creates loading.tsx (#dynamic-page-loading-a) and the PR description states the test "asserts the loading boundary from A renders," but the spec never asserts #dynamic-page-loading-a is visible. The loading.tsx is still functionally necessary (it creates the Suspense boundary that drives the prefetch-loading-shell request the test waits on), so this doesn't weaken the core assertion — but adding await expect(page.locator("#dynamic-page-loading-a")).toBeVisible() between the click and the content-B assertion would match the stated intent and tighten the regression. Optional.

Overall: correct, narrowly scoped, the previous blocker is fixed. LGTM.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2358.

Summary

I re-reviewed PR #2358 at HEAD (8e7d88c). The fix is correct, minimal, well-tested, and the prior review's blocker is resolved. Recommended merge.

Core fix — verified correct:

  • createAppPageRenderIdentity threads a new optional matchedRoutePathname (wired to routePath) and encodes RSC routeId/pageId from the post-rewrite matched route (B) instead of the browser-visible displayPathname (A). This fixes the payload mismatch that tripped the client RSC error boundary.
  • Traced canonicalPathname (captured pre-rewrite at app-rsc-handler.ts:506) → displayPathname, vs routePath (post-rewrite match) — the visible/matched split is the right pair.
  • The fix makes the renderIdentity-present path consistent with the existing fallback at app-page-route-wiring.tsx:546-550, which already used routePath.
  • Interception precedence and targetMatchedPathname preserved; backward compatible.

Prior blocker resolved: The orphaned app-basic/middleware.ts additions flagged in the earlier review were removed in commit 8e7d88c. A repo-wide search for nextjs-compat/mismatching-prefetch now returns zero matches — the shared fixture is clean.

Verification I ran:

  • 44 unit tests pass
  • vp check clean on both changed source files
  • Confirmed the browser spec follows the established self-contained prod-server pattern

One minor non-blocking note: the spec creates loading.tsx (#dynamic-page-loading-a) and the PR description claims the test asserts the loading boundary renders, but it never actually asserts that locator is visible. The loading.tsx is still functionally needed (it drives the prefetch-loading-shell request), so this doesn't weaken correctness — adding one toBeVisible() assertion would match the stated intent. Optional.

github run

@james-elicx james-elicx marked this pull request as ready for review June 25, 2026 18:25
…-route-identity

# Conflicts:
#	packages/vinext/src/server/app-page-element-builder.ts
#	packages/vinext/src/server/app-page-render-identity.ts
@james-elicx james-elicx merged commit e0cb913 into main Jun 30, 2026
63 of 80 checks passed
@james-elicx james-elicx deleted the codex/fix-app-rewrite-route-identity branch June 30, 2026 20:14
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