Skip to content

fix(app-router): let concrete Pages routes win middleware rewrites - #2730

Open
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:fix/pages-data-rewrite-dynamic-app-match
Open

fix(app-router): let concrete Pages routes win middleware rewrites#2730
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:fix/pages-data-rewrite-dynamic-app-match

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Summary

The Pages-data middleware rewrite shortcut added in #2468 treats any App route match as owning the rewrite target, including dynamic and catch-all matches. A concrete Pages route outranks a dynamic App match everywhere else in this handler, so the shortcut returned a synthetic {} before renderPagesFallback could run getServerSideProps for the target.

Why

handleAppRscRequest encodes App-vs-Pages ownership as match === null || match.route.isDynamic in the two places that arbitrate it: renderPagesForMatchKind (app-rsc-handler.ts:989) and the afterFiles rewrite guard (:1018). A dynamic App match does not own a pathname, because a concrete Pages route is more specific.

The fast path at :902 tested preActionMatch for truthiness alone, so a catch-all such as app/[...slug] was treated as the owner. That matters beyond the response body, because the Pages client reuses the response:

  1. The router stores the middleware probe response plus x-nextjs-rewrite as a MiddlewareDataEffect (shims/router.ts:1826).
  2. When the rewrite target resolves to a Pages route, that stored response is passed to navigateClientData as prefetchedResponse (:2555).
  3. navigateClientData skips the real data fetch when given one (:2126), and {} becomes props = {} with no __N_REDIRECT marker (:2205).

So a client-side navigation whose middleware rewrite lands on a Pages route shadowed by a dynamic App route rendered the page with empty props, without the redirect or notFound result its getServerSideProps would have returned. Document requests were unaffected, since they never take this path.

Client-side ownership already agrees with the server rule: resolveMatchedHybridClientRouteOwner runs compareHybridRoutePatterns, which gives the concrete Pages pattern precedence. Only the server shortcut disagreed.

What changed

Scenario Before After
Rewrite target owned by a static App route {} + x-nextjs-rewrite, middleware headers merged Unchanged
Rewrite target has a dynamic App match and a concrete Pages route {} + x-nextjs-rewrite, getServerSideProps skipped Pages fallback renders; real data plus x-nextjs-rewrite
Rewrite target has a dynamic App match and no Pages route {} + x-nextjs-rewrite, middleware headers merged Unchanged in observable output, now produced by the tail response

The second hunk is required by the first. Restricting the shortcut moves dynamic-match requests onto the tail Pages-data response, which built its headers from buildNextDataNotFoundResponse() alone and dropped whatever the middleware set. Without merging them there, gating the shortcut would trade this bug for a dropped Set-Cookie on every rewrite landing on a genuinely App-owned dynamic route.

Maintainer review path
  1. packages/vinext/src/server/app-rsc-handler.ts:902 for the ownership predicate, which now matches :989 and :1018.
  2. packages/vinext/src/server/app-rsc-handler.ts:1138 for the header merge the fallthrough depends on.
  3. tests/app-rsc-handler.test.ts for the two regression tests.
Validation
  • Added a regression test asserting a concrete Pages route wins a middleware rewrite over a dynamic App match, returning its getServerSideProps redirect marker rather than {}.
  • Added a regression test asserting middleware Set-Cookie and custom headers survive a rewrite that lands on a dynamic App route with no Pages route.
  • Mutation-checked both tests against their own hunk. Removing the predicate fails with expected {} to deeply equal { Object (pageProps) }; removing the merge fails with expected null to be 'probe=1; Path=/'.
  • The existing fix(app-router): expose app rewrites to pages data clients #2468 test still passes unchanged: a static App match still short-circuits.
  • tests/e2e/app-router/pages-to-app-routing.spec.ts passes in full, including the two cases fix(app-router): expose app rewrites to pages data clients #2468 added for Pages-to-App navigation through a middleware rewrite.
Commands
vitest run tests/app-rsc-handler.test.ts                     134 passed
vitest run tests/hybrid-route-priority.test.ts \
  tests/hybrid-client-route-owner.test.ts \
  tests/pages-router.test.ts tests/middleware-runtime.test.ts 434 passed
vitest run tests/routing.test.ts                             284 passed (with the above)
tsc --noEmit -p packages/vinext/tsconfig.json                 clean
PLAYWRIGHT_PROJECT=app-router playwright test \
  tests/e2e/app-router/pages-to-app-routing.spec.ts            5 passed
Risk / compatibility
  • No public API, configuration, or wire format change.
  • Narrows an early return added in fix(app-router): expose app rewrites to pages data clients #2468 so that fewer requests take it. Requests that no longer take it fall into the arbitration that already existed for every non-rewritten Pages data request, so the path is well covered.
  • The only behavioural change for App-owned targets is that the response is now built by the tail branch. Header output is asserted equivalent by the second regression test.

References

Reference Why it matters
#2468 Introduced the shortcut; its test pins the static-App-match behaviour this PR preserves
packages/vinext/src/server/app-rsc-handler.ts:989 The ownership predicate this PR aligns with

A Pages data request that middleware rewrote returned a synthetic empty
JSON body whenever any App route matched the rewrite target, including a
dynamic or catch-all match. Every other App-vs-Pages ownership decision in
this handler treats a dynamic App match as non-owning, so a concrete Pages
route at the same pathname should render instead. Skipping that arbitration
meant getServerSideProps never ran for the rewrite target, and the client
router, which reuses the middleware probe response when the rewrite target
resolves to a Pages route, accepted the empty body as successful page data.
Redirect and notFound markers the Pages route would have returned were
therefore absent during client-side navigation.

Restrict the shortcut to App matches that own the target outright, so
dynamic matches fall through to the existing static and dynamic Pages
fallback arbitration.

That fallthrough reaches the tail Pages data response, which built its
headers from the not-found response alone and dropped headers the
middleware set on the way. Merge the middleware response headers there so
a rewrite landing on a genuinely App-owned dynamic route still carries its
cookies.
@pkg-pr-new

pkg-pr-new Bot commented Jul 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 3cee61b

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 3cee61b against base c9a4a84 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 132.4 KB 132.4 KB ⚫ -0.0%
Client entry size (gzip) vinext 119.8 KB 119.8 KB ⚫ 0.0%
Dev server cold start vinext 2.98 s 2.94 s ⚫ -1.1%
Production build time vinext 3.22 s 3.20 s ⚫ -0.7%
RSC entry closure size (gzip) vinext 105.6 KB 105.6 KB ⚫ -0.0%
Server bundle size (gzip) vinext 179.8 KB 179.8 KB ⚫ +0.0%

View detailed results and traces

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

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