Skip to content

fix(app-router): refetch same-page search navigations - #1744

Merged
james-elicx merged 8 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/nav-hash-rsc-query
Jun 7, 2026
Merged

fix(app-router): refetch same-page search navigations#1744
james-elicx merged 8 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/nav-hash-rsc-query

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Overview

Area Detail
Goal Match Next.js App Router behavior for same-page search/hash navigations from the navigation deploy suite.
Core changes Bypass stale client RSC payload caches when navigate keeps the pathname but changes search params, and hide the internal _rsc cache-busting query from middleware-facing request.nextUrl.
Main boundary Hash-only changes stay local; search changes are page inputs and request the target RSC payload; internal RSC transport params remain framework-only.
Primary files packages/vinext/src/server/app-browser-entry.ts, packages/vinext/src/server/app-rsc-handler.ts, tests/e2e/app-router/nextjs-compat/hash-rsc-requests.browser.spec.ts, tests/app-rsc-handler.test.ts
Expected impact Query+hash navigations no longer reuse stale prefetched route payloads or fail middleware that rejects visible _rsc; cross-route prefetch reuse remains unchanged.

Why

For App Router, search params are part of the page input. Next.js treats same-page navigations specially: hash-only changes are local, but same-page search changes refetch page segments rather than relying on a cached payload that may have been prefetched before the navigation.

The upstream navigation fixture also asserts that middleware must not observe Next's internal RSC union query. Vinext already uses _rsc to validate RSC cache-busting, but the same raw URL was passed into middleware. That made the query+hash RSC request fail before it could commit and scroll.

Area Principle / invariant What this PR changes
Hash navigation A hash-only URL change should not request unrelated query payloads. The regression keeps asserting no with-query-param RSC request occurs during hash-only clicks and now mirrors the upstream fixture's exact scroll offsets.
Same-page search navigation Changed search params require server work for the page segment. The browser entry bypasses visited and prefetched RSC caches for navigate operations with unchanged pathname and changed search params.
Middleware URL surface Internal RSC cache-busting is framework transport state, not userland request state. The RSC handler validates _rsc first, then strips it from the request clone passed to middleware and post-middleware has/missing matching.

What changed

Scenario Before After
/hash#non-existent to /hash?with-query-param#hash-160 after visible Link prefetch vinext replayed the prefetched query RSC payload, so no request was visible during the click. vinext bypasses the client cache and requests the query-param RSC payload during navigation.
Query+hash RSC request through middleware middleware saw _rsc in request.nextUrl.searchParams, matching the remaining deploy-suite failure. middleware sees only userland query params while RSC cache-busting validation still runs on the real request first.
Hash-only clicks on /hash#... Stayed local. Still local, with exact scroll-position assertions ported from upstream.
Cross-route App Router prefetch reuse Could reuse prefetched payloads. Unchanged.
Validation
  • vp test run tests/app-rsc-handler.test.ts
  • vp run vinext#build
  • PLAYWRIGHT_PROJECT=app-router-chrome-browser-specific pnpm run test:e2e tests/e2e/app-router/nextjs-compat/hash-rsc-requests.browser.spec.ts
  • vp test run tests/app-browser-entry.test.ts tests/prefetch-cache.test.ts
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e tests/e2e/app-router/nextjs-compat/hash-popstate-scroll.spec.ts tests/e2e/app-router/nextjs-compat/router-autoscroll.spec.ts
  • vp check

The new handler regression was red before the source change: middleware received a NextRequest.nextUrl whose searchParams still contained _rsc.

References
Reference Why it matters
Next.js navigation hash test Upstream observable behavior and assertion ported here.
Next.js navigation middleware guard Upstream fixture throws when middleware can see the internal RSC query.
Next.js hash fixture Fixture shape that makes the query link visible and prefetched before the assertion window.
Next.js only-hash check Search must match for a navigation to be hash-only.
Next.js same-page segment refetch rule Same-page navigations refetch page segments instead of reusing existing cached page data.
Next.js createHrefFromUrl Canonical href construction includes pathname, search, and hash.

Non-goals

This PR does not replace vinext's route-level prefetch cache with Next.js's segment cache. It only closes the observed parity gap for same-page search navigations while preserving the existing cache model.

Same-page App Router navigations with a changed search string could replay a prefetched RSC payload instead of requesting the target payload. That diverges from Next.js, which refetches page segments for same-page navigations because search params are page inputs.

Bypass visited and prefetched RSC caches for navigate operations whose pathname is unchanged but search params differ. Add a prerendered production browser regression that ports the Next.js hash navigation request assertion.
@pkg-pr-new

pkg-pr-new Bot commented Jun 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 5332b4f

@NathanDrake2406
NathanDrake2406 marked this pull request as draft June 4, 2026 15:59
@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review June 4, 2026 16:50
@NathanDrake2406
NathanDrake2406 marked this pull request as draft June 4, 2026 16:53
@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review June 6, 2026 07:19
@james-elicx

Copy link
Copy Markdown
Member

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

Reviewed the same-page search refetch + _rsc hiding changes. The core logic is sound and matches repo conventions well:

  • isSamePageSearchNavigation mirrors the existing isSameAppRoutePopstateTarget comparison (stripBasePath pathname + searchParams.toString()), and gating on navigationKind === "navigate" correctly covers both router.push and router.replace since push/replace are distinguished by historyUpdateMode, not navigationKind.
  • requestWithoutRscCacheBustingSearchParam correctly runs after resolveInvalidRscCacheBustingRequest, so cache-busting validation still sees the real request before the strip. Passing userlandRequest to both middleware and buildPostMwRequestContext is consistent — post-middleware rewrite has/missing matching reads url.searchParams, which should not see _rsc.
  • useSearchParams() parity was already handled by the existing stripRscCacheBustingSearchParam(url) at line 540, so this change only closes the middleware gap. Good targeting.

A few minor, non-blocking observations below. None of them block merge.

(2) Optimistic-shell path (app-browser-entry.ts ~line 1911): the optimistic-shell branch (!navResponse && navigationKind === "navigate") is not gated by shouldBypassNavigationCache, so a same-page search change can still render an optimistic shell from cached route templates before the real fetch commits. This is a detached commit superseded by the authoritative fetch (same as cross-route navs), so likely harmless, but worth confirming it doesn't briefly paint stale page-segment content for the changed-search case. A one-line comment noting the optimistic shell is intentionally allowed here would help future readers.

(3) Guard vs stripper matching (app-rsc-handler.ts line 346): the short-circuit uses url.searchParams.has(...) (decoded-key match), while stripRscCacheBustingSearchParam -> isRscCacheBustingSearchPair does its own decodeURIComponent/+-aware matching. For the normal _rsc=<hash> shape these agree and vinext always appends the param itself, so it's fine in practice — just flagging the two different matching strategies in case encoded-key edge cases ever diverge.

path.resolve(process.cwd(), "node_modules"),
path.join(fixtureRoot, "node_modules"),
"junction",
);

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 symlinks the repo-root node_modules (process.cwd()/node_modules), whereas the sibling production-fixture specs (client-reference-runtime-map.browser.spec.ts, inline-css.browser.spec.ts) use a linkFixtureNodeModules helper that symlinks tests/fixtures/app-basic/node_modules per-entry. The fixture-scoped variant pins the same next/react resolution the rest of the suite uses; the root-level link can resolve a different copy if hoisting differs. Consider reusing the established helper pattern for consistency and to avoid resolution drift. Not a correctness blocker, just parity with existing specs.


await checkLink("query-param", 2284);
await expect(page).toHaveURL(
`${app.baseUrl}/nextjs-compat/hash-rsc-requests?with-query-param#hash-160`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The test verifies scroll offsets and the final URL but never asserts that the navigation didn't trip the fixture middleware (which returns HTTP 599 if _rsc leaks). A 599 on the RSC fetch would hard-navigate rather than soft-navigate, so the scroll assertions catch it only indirectly. Consider adding an explicit assertion that no response had status 599 (e.g., track page.on("response") for the with-query-param RSC request and assert response.ok()), so a regression in the _rsc-hiding logic fails with a clear message instead of a confusing scroll-offset mismatch.

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my review of PR #1744:

Review Summary

The PR is well-implemented and follows repo conventions. The core logic is sound:

Verified correct:

  • isSamePageSearchNavigation mirrors the existing isSameAppRoutePopstateTarget comparison logic, and gating on navigationKind === "navigate" correctly covers both router.push and router.replace (push/replace are distinguished by historyUpdateMode, not navigationKind).
  • requestWithoutRscCacheBustingSearchParam runs after RSC cache-busting validation (line 446), so validation sees the real request before the _rsc strip. Passing userlandRequest to both middleware and buildPostMwRequestContext is consistent with the documented Next.js execution order.
  • useSearchParams() parity was already handled by the pre-existing strip at line 540, so this change correctly closes only the middleware gap.
  • The e2e test follows the established production-fixture pattern used by sibling browser specs.

Minor, non-blocking observations posted as inline comments:

  1. The new spec symlinks repo-root node_modules instead of reusing the linkFixtureNodeModules helper that scopes to tests/fixtures/app-basic/node_modules (resolution-drift risk).
  2. The e2e test only catches a _rsc leak indirectly via scroll offsets — suggested an explicit no-599 / response.ok() assertion for a clearer failure mode.
  3. The optimistic-shell path isn't gated by shouldBypassNavigationCache — likely harmless (superseded by the real fetch) but worth a clarifying comment.
  4. The short-circuit guard uses searchParams.has() while the stripper uses encoding-aware matching — agree in practice, flagged for future encoded-key edge cases.

No blockers found. The fix is well-targeted and matches Next.js behavior.

github run

- Reuse fixture-scoped node_modules linking in the hash RSC spec instead of
  symlinking repo-root node_modules, matching sibling production specs.
- Add an explicit assertion that no request leaked `_rsc` to the fixture
  middleware (HTTP 599) for a clear failure mode.
- Document that the optimistic shell is intentionally not gated by
  shouldBypassNavigationCache (detached commit, always superseded).
- Detect the RSC cache-busting param via the encoding-aware stripper so the
  guard and stripper can never diverge on encoded-key edge cases.
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk re-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.

Re-review (/bigbonk). Verified the source changes against the repo's middleware/rewrite execution path and the App Router client navigation snapshot model. The fix is well-targeted and correct, and the earlier review concerns are either already addressed or based on a stale premise.

Verified correct:

  • requestWithoutRscCacheBustingSearchParam + userlandRequest threading is complete. _rsc validation still runs on the real request at resolveInvalidRscCacheBustingRequest (line 451-455) before the strip. The stripped userlandRequest is then passed to both applyAppMiddleware (line 478) and buildPostMwRequestContext (line 497). I confirmed the post-middleware rewrite has/missing matching reads query exclusively from the requestContext (app-post-middleware-context.ts:22 -> url.searchParams), and matchRewrite uses options.requestContext, not options.request (applyRewrite at line 292-297). So _rsc cannot leak into middleware or config-rewrite condition matching. The only remaining use of the original request in the rewrite path is proxyExternalRequest for external rewrites, where preserving the on-the-wire URL is reasonable.

  • isSamePageSearchNavigation is consistent with isSameAppRoutePopstateTarget. Both compare stripBasePath(pathname) + normalized searchParams.toString(). The snapshot's pathname is already basePath-stripped (createClientNavigationRenderSnapshot in navigation.ts:1299) and searchParams is a ReadonlyURLSearchParams, so .toString() normalization matches on both sides. Returning true only when pathname matches AND search differs correctly leaves hash-only and identical-URL navigations on the cached path.

  • The request.body ? request.clone() : request guard is correct for GET RSC navigations (no body) and POST action requests (clone to avoid consuming the stream).

On the prior review's open items:

  1. The linkFixtureNodeModules helper in the new spec is byte-identical to the one in client-reference-runtime-map.browser.spec.ts (lines 22-37), and both resolve tests/fixtures/app-basic/node_modules. There is no resolution drift versus the established pattern — this concern appears to have been based on an earlier revision. No action needed.
  2. The explicit no-599/_rsc-leak assertion is now present (middlewareLeakResponses via page.on("response"), asserted toEqual([])). Addressed.
  3. The optimistic-shell-not-gated behavior is now documented with an accurate comment; the shell is a detached commit superseded by the authoritative fetch, and the bypass path still forces a real fetch. No stale page-segment persistence.

Minor, non-blocking: requestWithoutRscCacheBustingSearchParam decides whether to rebuild by comparing url.search before/after stripRscCacheBustingSearchParam. Because the stripper reserializes the whole query (split/filter/join), a query with empty pairs (e.g. ?a=1&&b=2) would compare unequal even without _rsc and trigger a harmless rebuild that also normalizes the middleware-facing URL. This is a degenerate edge case and doesn't affect correctness, but the comment's claim that the guard and stripper "can never diverge" is about matching intent rather than byte-identical search strings — worth a one-word note if you revisit.

No blockers. LGTM.

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Re-review posted. Summary of findings:

Review Result: LGTM, no blockers

The PR is well-implemented and correct. I verified the two core changes against the repo's actual execution path:

1. _rsc hiding from middleware (app-rsc-handler.ts)

  • Cache-busting validation still runs on the real request before the strip
  • userlandRequest is threaded to both applyAppMiddleware and buildPostMwRequestContext
  • Confirmed matchRewrite reads has/missing conditions from the requestContext (userland-derived url.searchParams), not the raw request — so _rsc can't leak into config-rewrite matching either
  • The body-clone guard is correct for GET (RSC) vs POST (action) requests

2. Same-page search refetch (app-browser-entry.ts)

  • isSamePageSearchNavigation is consistent with the existing isSameAppRoutePopstateTarget comparison (basePath-stripped pathname + normalized search)
  • Correctly bypasses cache only when pathname matches and search differs, leaving hash-only and identical-URL navigations on the cached path

On the prior ask-bonk review's concerns: Concerns 1-3 are either already addressed in the current revision or based on a stale premise. Notably, concern #1 (resolution drift) was inaccurate — the linkFixtureNodeModules helper is byte-identical to the sibling spec and uses the same fixture-scoped path.

One minor non-blocking note posted: the before/after url.search comparison in requestWithoutRscCacheBustingSearchParam can trigger a harmless rebuild for degenerate queries with empty pairs, and the "can never diverge" comment is about matching intent rather than byte-identical strings.

github run

Replace the before/after url.search comparison in
requestWithoutRscCacheBustingSearchParam with a dedicated
hasRscCacheBustingSearchParam helper that reuses the stripper's
isRscCacheBustingSearchPair matcher. This makes the guard and stripper share a
single source of truth and avoids spuriously rebuilding/normalizing requests
whose only difference is degenerate empty query pairs (e.g. ?a=1&&b=2).
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk re-review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk re-review for issues

@ask-bonk

ask-bonk Bot commented Jun 6, 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

/bigbonk re-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.

Re-review (/bigbonk). The core logic is correct and matches Next.js behavior — I re-verified both changes against the repo's execution path:

  • isSamePageSearchNavigation is consistent with the existing isSameAppRoutePopstateTarget (app-browser-entry.ts:1264): both compare stripBasePath(pathname) + normalized searchParams.toString(), and the snapshot's pathname is already basePath-stripped (navigation.ts:1299) with searchParams a ReadonlyURLSearchParams. Returning true only when pathname matches AND search differs correctly leaves hash-only / identical-URL navigations on the cached path.
  • _rsc hiding: validation still runs on the real request at resolveInvalidRscCacheBustingRequest (line 453) before the strip; userlandRequest is threaded to applyAppMiddleware and buildPostMwRequestContext. matchRewrite reads has/missing from requestContext (userland-derived), and the only remaining use of the original request in applyRewrite is proxyExternalRequest, so _rsc cannot leak into middleware or config-rewrite matching.
  • Prior review concern about the spec's node_modules symlink is not valid — linkFixtureNodeModules here is byte-identical to client-reference-runtime-map.browser.spec.ts:22 and resolves the same fixture-scoped path. No resolution drift.

Two new non-blocking findings on requestWithoutRscCacheBustingSearchParam, both edge cases that don't affect the normal GET-navigation flow this PR targets. Details inline.


stripRscCacheBustingSearchParam(url);
const source = request.body ? request.clone() : request;
return new Request(url.toString(), source);

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.

Drops the Workers cf property (and risks throwing on bodied requests). This is the only request-reconstruction site in the codebase that uses a bare new Request(url, source) instead of the established cloneRequestWithHeaders helper (request-pipeline.ts:651), which exists precisely because new Request() does not copy the Workers-specific cf metadata and re-attaches it explicitly.

Two concrete consequences:

  1. cf is lost on RSC navigations. Since this runs before applyAppMiddleware, the userlandRequest passed to middleware no longer carries cf. Vinext's NextRequest.geo/ip read from headers, so geo/ip data survives — but user middleware that reads request.cf directly (a documented Workers escape hatch) would see undefined on every soft RSC navigation while still seeing it on the initial HTML load. That's a dev/prod parity gap between full loads and client navigations.
  2. Missing duplex: "half" for bodied requests. Every other request-reconstruction site sets duplex: "half" when a body is present (prod-server.ts:755, app-route-handler-runtime.ts:150, api-handler.ts:201, cloneRequestWithHeaders fallback at request-pipeline.ts:671). The request.body ? request.clone() : request branch here can produce new Request(string, bodiedRequest) without duplex, which throws RequestInit: duplex option is required when sending a body on Node/undici. Vinext's own client never appends _rsc to POST action requests (they use createServerActionRequestUrl, which omits _rsc), so this won't fire for normal flows — but a non-standard client POSTing to ?_rsc=... with a body would hit it.

Reusing cloneRequestWithHeaders would address both — it preserves cf and handles the duplex/body case:

Suggested change
return new Request(url.toString(), source);
stripRscCacheBustingSearchParam(url);
const source = request.body ? request.clone() : request;
return cloneRequestWithHeaders(new Request(url.toString(), { method: source.method }), source.headers);

(Note: the suggestion above is illustrative — cloneRequestWithHeaders overrides headers but keeps the original URL, so a clean fix likely needs a small variant that also overrides the URL. The key point is to route through the same cf-preserving / duplex-aware path rather than a bare new Request.)

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's my re-review summary of PR #1744:

Review Result: LGTM with two non-blocking edge-case findings

The core logic is correct and matches Next.js behavior. I independently re-verified both changes against the actual execution path:

Verified correct:

  • isSamePageSearchNavigation mirrors the existing isSameAppRoutePopstateTarget comparison; the snapshot's pathname is basePath-stripped and searchParams normalizes via .toString() on both sides. Bypass fires only when pathname matches and search differs.
  • _rsc validation runs on the real request before the strip; userlandRequest is correctly threaded to middleware and buildPostMwRequestContext. matchRewrite reads has/missing from requestContext, so _rsc can't leak into middleware or config-rewrite matching.
  • Prior review's node_modules symlink concern is not valid — the helper is byte-identical to the sibling spec.

New findings (both non-blocking, on requestWithoutRscCacheBustingSearchParam):

  1. Drops the Workers cf property — this is the only request-reconstruction site using a bare new Request(url, source) instead of the codebase's cloneRequestWithHeaders helper (which exists specifically to re-attach cf). User middleware reading request.cf directly would see undefined on RSC navigations but not on initial loads — a dev/prod parity gap. (Geo/IP are safe since vinext reads them from headers.)
  2. Missing duplex: "half" for the bodied-request branch — every other reconstruction site sets it. Won't fire for normal flows (vinext's action POSTs omit _rsc), but a bodied request carrying _rsc would throw on Node/undici.

Both are edge cases outside the GET-navigation flow this PR targets, so they don't block merge. Reusing cloneRequestWithHeaders would address both consistently.

github run

requestWithoutRscCacheBustingSearchParam reconstructed the userland request
with a bare new Request(url, source), which drops the Workers-specific cf
property and omits duplex: "half" for streaming bodies (throwing on
Node/undici). Add a cloneRequestWithUrl helper mirroring cloneRequestWithHeaders
that overrides the URL while preserving headers/cf and setting duplex, and route
the _rsc strip through it.
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk re-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.

Re-review (/bigbonk). The two core changes are correct and well-targeted, and the prior review's cf/duplex concerns are now fully addressed by the new cloneRequestWithUrl helper (it mirrors cloneRequestWithHeaders, re-attaches Workers cf, and sets duplex: "half" on the bodied fallback path, with direct unit coverage).

Verified correct:

  • requestWithoutRscCacheBustingSearchParam runs after resolveInvalidRscCacheBustingRequest (handler line 458), so cache-busting validation still sees the real _rsc before the strip. userlandRequest is threaded to both applyAppMiddleware (line 485) and buildPostMwRequestContext (line 504).
  • hasRscCacheBustingSearchParam and stripRscCacheBustingSearchParam now share isRscCacheBustingSearchPair, so the guard and the strip can never disagree (closes the prior "guard vs stripper matching" concern). Gating on the matcher instead of a before/after url.search comparison also avoids spurious rebuilds on degenerate empty query pairs.
  • isSamePageSearchNavigation is consistent with the existing isSameAppRoutePopstateTarget (stripBasePath pathname + normalized searchParams.toString()); it bypasses cache only when pathname matches AND search differs, leaving hash-only / identical-URL navigations on the cached path.
  • The e2e spec uses the fixture-scoped linkFixtureNodeModules helper (byte-identical to sibling specs) and adds an explicit 599-leak assertion. Both prior e2e concerns are resolved.

One new non-blocking finding (external rewrite _rsc leak): see inline comment. The has/missing matching is correctly userland-derived, but the original request (with _rsc) is still forwarded to proxyExternalRequest for external config rewrites, which leaks the internal transport query to the upstream service — inconsistent with this PR's stated invariant that RSC transport params stay framework-only.

No blockers.


const scriptNonce = getScriptNonceFromHeaderSources(request.headers, middlewareContext.headers);
const postMiddlewareRequestContext = buildPostMwRequestContext(request);
const postMiddlewareRequestContext = buildPostMwRequestContext(userlandRequest);

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.

Threading userlandRequest into middleware + buildPostMwRequestContext is correct, but the applyRewrite calls below still pass the original request — and _rsc can still leak to external rewrite proxies.

The has/missing matching is safe (matchRewrite reads options.requestContext, which derives from userlandRequest). But when a config rewrite resolves to an external URL, applyRewrite forwards the original request to proxyExternalRequest (line 304), which reconstructs the target from request.url and merges its query params (config-matchers.ts:1186-1196). So an RSC navigation matching a beforeFiles/afterFiles/fallback external rewrite (e.g. destination: "https://api.example.com/:path*") forwards _rsc=<hash> to the upstream service.

This is inconsistent with the PR's stated invariant that internal RSC transport params remain framework-only — the same reasoning that motivates hiding _rsc from middleware applies to external proxy destinations. The fix is to pass userlandRequest to all three applyRewrite calls (lines 515, 620, 638); it's already in scope, and for an external POST-action rewrite it's the body-clone, which is safer than re-consuming the original stream. Non-blocking and narrow (only external config rewrites on RSC navigations), but worth closing for consistency.

@ask-bonk

ask-bonk Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of my re-review of PR #1744:

Review Result: LGTM with one new non-blocking finding

Verified correct (re-checked against the execution path):

  • requestWithoutRscCacheBustingSearchParam runs after _rsc validation, so cache-busting still sees the real request before the strip. userlandRequest is threaded to applyAppMiddleware and buildPostMwRequestContext.
  • hasRscCacheBustingSearchParam now shares isRscCacheBustingSearchPair with the stripper — closes the prior "guard vs stripper matching" concern and avoids spurious rebuilds on degenerate query pairs.
  • isSamePageSearchNavigation is consistent with the existing isSameAppRoutePopstateTarget; bypasses cache only when pathname matches and search differs.
  • The prior review's cf/duplex concerns are now fully addressed by the new cloneRequestWithUrl helper, which mirrors cloneRequestWithHeaders and has direct unit coverage.
  • The e2e spec uses the fixture-scoped linkFixtureNodeModules helper and adds an explicit 599-leak assertion — both prior e2e concerns resolved.

New non-blocking finding: The three applyRewrite calls still pass the original request. The has/missing matching is safe (it reads requestContext, which is userland-derived), but for external config rewrites, proxyExternalRequest reconstructs the target from request.url and merges its query params — so _rsc=<hash> leaks to the upstream service. This contradicts the PR's stated invariant that RSC transport params stay framework-only. The fix is to thread userlandRequest into all three applyRewrite calls (it's already in scope). Narrow scope (only external config rewrites on RSC navigations), so it doesn't block merge.

No blockers found.

github run

The beforeFiles/afterFiles/fallback applyRewrite calls forwarded the original
request (with _rsc) to proxyExternalRequest, which reconstructs the upstream URL
from request.url and merges its query params — leaking the internal RSC
transport query to external rewrite destinations. Pass the _rsc-stripped
userlandRequest to all three calls, consistent with the middleware invariant.

Add a regression test that points an external rewrite at a loopback server and
asserts the forwarded URL drops _rsc while preserving userland query params.
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk re-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.

Re-review (/bigbonk). All prior-round findings are now resolved in this revision, and I re-verified the core logic against the actual execution path. No blockers.

Verified correct:

  • _rsc validation order + userlandRequest threading. Cache-busting validation runs on the real request at resolveInvalidRscCacheBustingRequest (app-rsc-handler.ts:458) before the strip. The stripped userlandRequest is then threaded to applyAppMiddleware (485), buildPostMwRequestContext (504), and all three applyRewrite calls (517 / 627 / 646). This closes the prior external-rewrite leak finding: proxyExternalRequest reconstructs the target from request.url and merges its query (config-matchers.ts:1186-1196), so passing the _rsc-stripped request prevents the internal transport query from reaching upstream services.

  • cloneRequestWithUrl mirrors cloneRequestWithHeaders. It preserves the Workers cf property via Object.defineProperty and sets duplex: "half" on the bodied fallback path (request-pipeline.ts:699-733), resolving the prior cf/duplex concerns. Direct unit coverage added in tests/request-pipeline.test.ts.

  • Guard/stripper share one matcher. hasRscCacheBustingSearchParam (app-rsc-cache-busting.ts:239) derives rawQuery identically to getSearchPairsWithoutRscCacheBusting and reuses isRscCacheBustingSearchPair, so the guard and strip can never disagree, and degenerate empty query pairs no longer trigger spurious rebuilds.

  • Body-stream safety. requestWithoutRscCacheBustingSearchParam clones when a body is present; applyAppMiddleware also clones before reading (app-middleware.ts:80), so the shared userlandRequest body remains readable for the external applyRewrite -> proxyExternalRequest path. Only one external rewrite resolves per request, so the body is consumed at most once.

  • isSamePageSearchNavigation is consistent with the existing isSameAppRoutePopstateTarget (app-browser-entry.ts:1264): both compare stripBasePath(pathname) + normalized searchParams.toString(). It bypasses the visited/prefetch caches only when pathname matches AND search differs, leaving hash-only and identical-URL navigations on the cached path. The optimistic-shell exemption is now documented.

  • Tests. tests/app-rsc-handler.test.ts and tests/request-pipeline.test.ts pass locally (133 tests). The new handler test asserts both the middleware-leak guard and the external-rewrite leak guard; the e2e spec uses the fixture-scoped linkFixtureNodeModules helper and adds an explicit no-599 assertion.

LGTM.

@ask-bonk

ask-bonk Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Re-review posted.

Summary

PR #1744 has converged through several review rounds, and this revision resolves all prior findings. I independently verified the core logic against the execution path:

No blockers found. Key verifications:

  1. _rsc validation ordering — validation runs on the real request before the strip, then userlandRequest is threaded to middleware, buildPostMwRequestContext, and all three applyRewrite calls. This closes the external-rewrite leak that an earlier round flagged.

  2. cloneRequestWithUrl — the new helper mirrors cloneRequestWithHeaders, preserving Workers cf and setting duplex: "half", with dedicated unit tests. Resolves the prior cf/duplex concern.

  3. Guard/stripper unificationhasRscCacheBustingSearchParam and the stripper share isRscCacheBustingSearchPair, so they can't disagree, and degenerate query pairs no longer trigger spurious rebuilds.

  4. Body-stream safety — both the strip helper and middleware clone before reading, so the shared userlandRequest body stays readable for the external proxy path.

  5. isSamePageSearchNavigation is consistent with the existing isSameAppRoutePopstateTarget and bypasses caches only when pathname matches and search differs.

Unit tests pass locally (133 tests across the two affected files).

github run

The query-param link is in the viewport and gets prefetched on load. On slower
runtimes (WebKit), that prefetch could land after rscRequestUrls.clear(), so it
was misattributed to the hash-only navigations and tripped the
"no with-query-param request" assertion. Wait for networkidle before clearing,
mirroring upstream's waitForIdleNetwork() in the ported Next.js test.
@james-elicx
james-elicx enabled auto-merge (squash) June 7, 2026 00:18
@james-elicx
james-elicx merged commit dab28c4 into cloudflare:main Jun 7, 2026
39 checks passed
@github-actions github-actions Bot mentioned this pull request Jun 7, 2026
@NathanDrake2406
NathanDrake2406 deleted the nathan/nav-hash-rsc-query branch June 13, 2026 13:25
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.

2 participants