fix(app-router): isolate page CSS chunks in production#1738
Conversation
commit: |
16feaf7 to
14a7a5e
Compare
Production App Router builds statically imported every page module into the generated RSC manifest. That let Vite/Rolldown concatenate page-level global CSS from sibling routes into shared CSS assets, so a sibling hash page with scroll-padding-top changed native hash scroll offsets on unrelated pages. The manifest now emits cached dynamic loaders for page modules and loads the matched page module at the route dispatch boundary. Page generateStaticParams sources use the same loader with an explicit missing-export sentinel so prerender still distinguishes absent exports from malformed results.
14a7a5e to
a9852d2
Compare
|
/bigbonk review |
There was a problem hiding this comment.
Review: isolate page CSS chunks in production
Nice, well-structured change. The core idea — moving page modules from static manifest imports to cached dynamic import() loaders so a route only pulls in its own CSS chunk — is sound and matches Next.js's per-route CSS scoping. I traced the request, action, intercept, and prerender paths and the lazy-load gating is consistent.
Correctness — verified
- Request path:
app-rsc-handler.ts:664-666loads the matched route vialoadRouteModulesbefore dispatch, sohandlePageRequest(route.page?.default, segment config,generateStaticParams) reads a populatedroute.page. ✓ - Action path:
handleServerActionRequestis nowasyncand awaits__loadRouteMatch(matchRoute(...))before reading__actionMatch.route.page; the innermatchRouteoption is also wrapped, so action rerenders/redirect targets load first. ✓ - Intercept path:
findInterceptreturns a per-request shallow copy ({ ...entry, matchedParams }inapp-rsc-route-matching.ts:129), so theintercept.page = await intercept.__pageLoader()mutation inapp-page-request.ts:336does not mutate the shared lookup entry. ✓ - Prerender parity: the switch from
mod?.generateStaticParams(filtered out when absent) to an always-present__createLazyGenerateStaticParamsSource(...)that returns theMISSING_GENERATE_STATIC_PARAMSsentinel is handled equivalently downstream. The build prerender path already treats a function-returning-nullthe same as a missing function (build/prerender.ts:1096vs:1139), so theoutput: 'export'error andno-static-paramsskip behaviors are preserved. ✓
Notes (non-blocking)
-
No direct unit test for the new helper module. Per AGENTS.md ("If you move behavior out of a template string, add a focused unit test for the new helper module"),
app-route-module-loader.tshas meaningful logic (WeakMap dedup, shared-promise reuse, page mutation) but is only covered indirectly viaapp-router.test.ts. A small focused test would lock in the dedup/caching contract. -
Rejected loads are cached permanently. See inline comment — both
createRouteModuleLoaderandloadRouteModulescache the in-flight promise, so a transientimport()failure poisons the route for the lifetime of the worker instance with no retry. Likely acceptable for built chunks, but worth a comment documenting the intent. -
Minor inefficiency: intercept route modules are keyed in the
loadRouteStatesWeakMap by the per-request{ ...entry }copy, so the route-level load cache is not reused across requests for intercepts (the underlyingcreateRouteModuleLoaderpromise cache still dedups the actualimport(), so this is harmless).
Nothing blocking from my read.
| await promise; | ||
| state.loaded = true; | ||
| return route; | ||
| } |
There was a problem hiding this comment.
Rejected loads are cached forever. If loadLazyModules(route) rejects, state.promise holds the rejected promise and state.loaded stays false, so every later loadRouteModules(route) call falls into the state.promise branch (line 47-50) and re-awaits the same rejection — the route is permanently broken within this worker instance with no retry. createRouteModuleLoader (line 34-40) has the same behavior. This is probably fine for built page chunks (a failure means a genuinely missing/broken chunk), but it's a deliberate-looking tradeoff that isn't documented. Consider a short comment noting the no-retry caching is intentional, or clearing state.promise on rejection if transient retries are desired.
| return MISSING_GENERATE_STATIC_PARAMS; | ||
| } | ||
| return generateStaticParams(input); | ||
| }; |
There was a problem hiding this comment.
Behavioral note (not a bug): because lazy sources are now always functions, a dynamic route with a page that has no generateStaticParams now eagerly import()s the page module during prerender enumeration just to discover the export is absent (returning the sentinel). Previously the absent export was filtered out at resolver-build time without loading the module. This is fine since prerender runs at build time, but it does mean more module loads during generateStaticParams discovery. Worth confirming this doesn't measurably slow large App Router prerender builds.
|
Review posted successfully to PR #1738. SummaryI reviewed the PR, which isolates page CSS chunks in production by moving App Router page modules from static manifest imports to cached dynamic Verdict: well-structured, nothing blocking. I traced all four affected paths and confirmed the lazy-load gating is consistent:
Non-blocking notes I flagged:
|
Address the non-blocking review note from PR cloudflare#1738: per AGENTS.md, behavior moved out of a template string should get a focused helper test. Cover createRouteModuleLoader (promise dedup, rejection propagation), loadRouteModules (page assignment, concurrent dedup, no-op cases, missing `page` key) and loadRouteMatch (null short-circuit, match load). Also cover createLazyGenerateStaticParamsSource (delegation, missing sentinel for null/empty/non-function exports). Document the rejected-load cache intent and the per-request intercept copy in the WeakMap so future readers don't try to "fix" them as bugs.
|
Thanks for the review! Addressed all three non-blocking notes in 1e33ba5:
CI is green on a9852d2 — let me know if you want any further tweaks. |
|
Agreed — a positive case proving the modal CSS is emitted when the intercept is active is a good follow-up, but not blocking. The runtime already loads the intercept page before render (the |
|
/bigbonk review |
There was a problem hiding this comment.
Re-review: isolate page CSS chunks in production
I independently traced all four request paths and re-ran the targeted suites. The change is sound and the three follow-ups from the prior review are addressed. Nothing blocking.
Verified
- Request path —
app-rsc-handler.ts:663-666awaitsloadRouteModules(matchedRoute)beforedispatchMatchedPagereadsroute.page?.default(app-rsc-entry.ts:595). ✓ - Action path —
handleServerActionRscRequestisasync;matchRouteis wired to__loadRouteMatch(matchRoute(...))andgetSourceRouteto__loadRouteModules(routes[i]), soroute.pageis populated before reads atapp-server-action-execution.ts:645(canRenderActionRedirectTarget) andapp-rsc-entry.ts:797. ✓ - Intercept path —
findInterceptreturns a fresh shallow copy per call (app-rsc-route-matching.ts:129), so theintercept.page = await intercept.__pageLoader()mutation (app-page-request.ts:335-336) never touches the sharedinterceptLookupentry. The shared__pageLoaderclosure still dedups the underlyingimport(). ✓ - Prerender parity — the always-present lazy source returns
MISSING_GENERATE_STATIC_PARAMS, and the newresolvedThisSource/resolvedAnySourcetracking makes a layout-only pattern (page absent) behave identically to the oldlength === 1branch. The sentinel is converted tonullat the resolver boundary (lines 37, 78), which the build prerender path already treats the same as a missing function (build/prerender.ts:1096,:1117,:1135-1149) — sooutput: 'export'errors andno-static-paramsskips are preserved. ✓
Checks I ran locally
vp test run tests/app-route-module-loader.test.ts tests/app-prerender-endpoints.test.ts tests/app-page-request.test.ts tests/entry-templates.test.ts→ 75 passedvp test run tests/app-router.test.ts -t "global CSS isolated"→ both isolation tests pass (sibling-route and intercepted-modal)vp checkon the changed server modules + new test → clean (format, lint, types)
Notes (non-blocking, already acknowledged)
- Rejected
import()results are cached for the worker lifetime in bothcreateRouteModuleLoaderandloadRouteModules(a rejection poisons the route with no retry). The added comment documents this as intentional for stable built chunks; fine. - The intercept-modal test only asserts the negative direction (modal CSS absent on direct
/feedvisit). A positive guard (modal CSS present when the intercept is active) would lock in the lazy-load-on-intercept path as a regression guard — good follow-up, not blocking.
LGTM.
|
Review posted successfully to PR #1738. SummaryI independently re-reviewed the PR, which isolates App Router page CSS chunks in production by moving page modules from static manifest imports to cached dynamic Verdict: well-structured, nothing blocking. I traced all four affected paths against the actual code (not just the diff) and confirmed lazy-load gating is consistent:
Verification I ran locally: 75 unit tests pass, both CSS-isolation integration tests pass, and The two non-blocking notes (permanent rejection caching, missing positive intercept-CSS assertion) were already acknowledged by the author and prior reviewer. |
…-isolation # Conflicts: # tests/app-router.test.ts
|
/bigbonk review for issues |
|
UnknownError: "Overloaded" |
|
@james-elicx Bonk workflow failed. Check the logs for details. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues |
|
UnknownError: "Overloaded" |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review for issues: isolate page CSS chunks in production
I traced all four lazy-load paths against the actual code (not just the diff) and ran the targeted suites locally. No blocking issues. The change is correct, the dedup/cache contract is sound, and prerender parity holds.
Verified correct
- Request path —
app-rsc-handler.ts:670-673awaitsloadRouteModules(matchedRoute)beforedispatchMatchedPagereadsroute.page?.default(app-rsc-entry.ts:597). ✓ - Action path —
handleServerActionRscRequestisasync;matchRouteis wrapped with__loadRouteMatchandgetSourceRoutewith__loadRouteModules, soroute.pageis populated beforecanRenderActionRedirectTargetreads it (app-server-action-execution.ts:645) and before the segment-config read atapp-rsc-entry.ts:799. ✓ - Intercept path —
findInterceptreturns a per-request shallow copy (app-rsc-route-matching.ts:129) carrying the shared__pageLoader, so theintercept.page = await intercept.__pageLoader()mutation (app-page-request.ts:335-336) never corrupts the shared lookup entry, and the shared loader closure still dedups theimport(). ✓ - Prerender parity — the always-present lazy source returning
MISSING_GENERATE_STATIC_PARAMS, combined withresolvedThisSource/resolvedAnySource, makes a layout-GSP + missing-page-GSP route behave identically to the old filtered single-source path. The resolver'snullreturn is treated equivalently to a missing function by the build path (build/prerender.ts:1096,:1117,:1139), sooutput: 'export'errors andno-static-paramsskips are preserved. ✓
Checks I ran
vp test run tests/app-route-module-loader.test.ts tests/app-prerender-endpoints.test.ts→ 28 passedvp test run tests/entry-templates.test.ts tests/app-page-request.test.ts→ 48 passedvp test run tests/app-router-production-server.test.ts→ 42 passed (both CSS-isolation tests green)
One new observation (non-blocking, scope note)
Parallel-slot page modules (slot.pagePath, app-rsc-manifest.ts:163) are still statically imported (imports.getImportVar), unlike the matched route page and the intercepting-route page on line 169 (which now use getLazyImportVar). A non-intercepting parallel-route page that imports route-global CSS would therefore still contribute its CSS to the shared manifest chunk — the same class of leak this PR fixes for the main page. This is consistent with the PR's stated scope, and the current fixtures don't exercise it (the @modal slot only has an intercepting page), so it's not a regression. Worth tracking as a follow-up so the remaining gap is explicit.
The other two known notes (permanent rejection caching, missing positive intercept-CSS assertion) were already raised and acknowledged. LGTM.
| if (slot.errorPath) imports.getImportVar(slot.errorPath); | ||
| for (const ir of slot.interceptingRoutes) { | ||
| imports.getImportVar(ir.pagePath); | ||
| imports.getLazyImportVar(ir.pagePath); |
There was a problem hiding this comment.
This intercepting-route page is now lazy (getLazyImportVar), but the parallel-slot page on line 163 (if (slot.pagePath) imports.getImportVar(slot.pagePath);) is still a static import. A non-intercepting parallel-route page that imports route-global CSS would still leak its CSS into the shared manifest chunk — the same class of leak this PR fixes for the main page. Not a regression and out of this PR's scope (current fixtures only have an intercepting modal page), but worth a follow-up so the remaining gap is tracked.
|
Review posted successfully to PR #1738. SummaryI reviewed the PR for issues, tracing all four lazy-load paths against the actual source and running the targeted test suites locally. Verdict: no blocking issues. The change is correct. What I verified
Tests run locally (all green)
One new observation (non-blocking)Parallel-slot page modules ( The two previously-known notes (permanent rejection caching, missing positive intercept-CSS assertion) were already acknowledged by the author and prior reviews. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: isolate page CSS chunks in production
Reviewed against the actual source (not just the diff), and ran the targeted suites locally. No blocking issues. The change is correct, the lazy-load gating is complete, and the new helper is well tested.
Verified correct
- Manifest: intercepting-route pages now emit
page: null+__pageLoader: load_N(app-rsc-manifest.ts:226-227, registered as a lazy loader at:191), keeping their CSS out of the shared RSC chunk. Matches the existing model for static/dynamic page modules. - Request path:
app-rsc-handler.ts:712awaitsensureRouteLoaded(route)before both the route-handler branch (:730readsroute.routeHandler) anddispatchMatchedPage(:748, which readsroute.page?.defaultatapp-rsc-entry.ts:599). ✓ - Action path:
handleServerActionRscRequestawaitsresolveAppPageActionRerenderTarget(...)and thenensureRouteLoaded(actionRerenderTarget.route)atapp-server-action-execution.ts:1171before reading the page module. ✓ - Intercept path:
findInterceptreturns a per-request shallow copy (app-rsc-route-matching.ts:129 → { ...entry, ... }), so theintercept.page = await intercept.__pageLoader()mutation atapp-page-request.ts:335-336never corrupts the shared lookup entry. The source route's page is loaded viabuildPageElements(await __ensureRouteLoaded(route)atapp-rsc-entry.ts:499) and the revalidation/action paths both hydrate the target route first (app-page-dispatch.ts:577). ✓ - Prerender parity:
createAppPrerenderStaticParamsResolverloads lazy{ load }page sources on demand and filtersfn !== null(app-prerender-static-params.ts:80, 83). A page with nogenerateStaticParamsyields anullfn that's dropped →fns.length === 0→ resolver returnsnull, identical to the old eager-filtered behavior, sooutput: 'export'errors andno-static-paramsskips are preserved. ✓
Tests run locally (all green)
app-route-module-loader+app-prerender-endpoints+app-page-request+entry-templates→ 70 passedapp-router-production-server -t "global CSS isolated"→ both isolation tests pass
Notes for the record (non-blocking)
- Rejection caching — actually correct here (clarifying prior reviews). The earlier bonk reviews stated rejected
import()s are "cached forever." That's not what the code does:ensureAppRouteModulesLoadedclears__loadingand leaves__loadedfalse on rejection (app-route-module-loader.ts:78), so the next request retries. The unit test atapp-route-module-loader.test.ts:93locks this in. No change needed — just flagging that the prior review's premise was inaccurate. - Parallel-slot page leak still open.
slot.pagePathis still a staticgetImportVar(app-rsc-manifest.ts:185), unlike the intercepting page on:191. A non-intercepting parallel-route page importing route-global CSS would still leak into the shared chunk. Consistent with this PR's stated scope and not exercised by fixtures; worth a tracking follow-up. - Intercept-modal test asserts only the negative direction. It proves modal CSS is absent on a direct
/feedvisit but not present when the intercept is active, so a regression where the lazy modal page fails to load its CSS could pass silently. Author already acknowledged this as a follow-up. - Bundled e2e flakiness fix. The
dev-error-overlay.spec.tsserial-mode +waitForCleanRscchange is unrelated to CSS isolation. Benign, but tangential to the PR's stated scope.
LGTM.
| const feedInlineCss = getInlineStyleText(feedHtml); | ||
| expect(feedInlineCss).not.toContain("scroll-padding-top"); | ||
| }); | ||
|
|
There was a problem hiding this comment.
This intercept-modal test only asserts the negative direction — modal CSS is absent on a direct /feed visit. It does not prove the modal CSS is emitted when the intercept is active, so a regression where the lazy modal page fails to load its CSS would pass this test silently. A positive guard (navigate /feed -> intercepted /photos/[id] and assert scroll-padding-top is present) would lock in the lazy-load-on-intercept path. Non-blocking follow-up.
| if (slot.errorPath) imports.getImportVar(slot.errorPath); | ||
| for (const ir of slot.interceptingRoutes) { | ||
| imports.getImportVar(ir.pagePath); | ||
| imports.getLazyLoaderVar(ir.pagePath); |
There was a problem hiding this comment.
Intercepting-route pages are now lazy here. Note the parallel-slot page module a few lines up (if (slot.pagePath) imports.getImportVar(slot.pagePath);) is still a static eager import. A non-intercepting parallel-route page that imports route-global CSS would still contribute its CSS to the shared manifest chunk — the same class of leak this PR fixes for the main and intercepting pages. Out of scope and not exercised by current fixtures, but worth tracking as a follow-up so the remaining gap is explicit.
|
Review posted successfully to PR #1738. SummaryI reviewed PR #1738 against the actual source (not just the diff) and ran the targeted suites locally. No blocking issues — the change is correct, the lazy-load gating is complete, and the new helper is well tested. What I verified
Notable correctionThe two prior bonk reviews claimed rejected Non-blocking follow-ups flagged inline
LGTM. |
… probe Adds the positive-direction guard the bonk review asked for: navigating /feed -> intercepted /photos/[id] must load the lazy modal page and emit its scroll-padding-top CSS chunk. The existing test only proved the negative (CSS absent on a direct /feed visit), so a regression where the lazy modal page failed to load its CSS would have passed silently. Also fixes the probePage() intercept branch, which read intercept.page without resolving the now-lazy __pageLoader. The render path awaits the loader (resolveAppPageInterceptState), but the probe path did not, so the dynamic-bailout probe silently inspected an undefined component and never observed the intercept page's searchParams/headers access. Resolve the lazy page before probing to match the render path.
|
Pushed 669433e addressing the latest bonk review: 1. Positive intercept-modal CSS guard ( Self-review finding — probe path didn't load the lazy intercept page. While verifying, I found that Parallel-slot Targeted suites green locally (production-server, page-request, dispatch, rsc-handler, prerender-endpoints, dev-server intercept, route-matching, page-wiring, probe) and |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: isolate page CSS chunks in production
Reviewed the full diff against the actual source and ran the targeted suites locally with vp. No blocking issues. The lazy-load gating for intercepting-route pages is correct and complete across every path that reads intercept.page, and the new probePage fix closes a real gap.
Verified correct
- Manifest — intercepting-route pages now emit
page: null+__pageLoader(app-rsc-manifest.ts:226-227), registered viagetLazyLoaderVar(:191), matching the existing lazy model for static/dynamic pages. Parallel-slot pages (slot.pagePath,:185/:234) remain eagergetImportVar, consistent within the PR's stated scope. - Async propagation —
resolveAppPageInterceptStateis nowasync, and its three callers (resolveAppPageInterceptMatch,resolveAppPageInterceptionRerenderTarget,resolveAppPageIntercept) plus the publicresolveAppPageActionRerenderTargetall return promises. Every call site awaits:app-server-action-execution.ts:1154,app-page-dispatch.ts:558(the fixedawaitin this PR), andapp-page-dispatch.ts:718. No un-awaited callers remain. - Mutation safety —
findInterceptreturns a per-request shallow copy (app-rsc-route-matching.ts:129,{ ...entry, ... }), so theintercept.page = await intercept.__pageLoader()mutation (app-page-request.ts:335-336,app-rsc-entry.ts:695-696) never corrupts the sharedinterceptLookupentry. The generated() => import()loader (app-rsc-manifest.ts:130) is deduped by the ES module system, so even though probe and render each callfindInterceptindependently and reload, the module evaluates once. - Ordering —
toInterceptOpts(intercept)readsintercept.page->interceptPage(app-rsc-entry.ts:909). In every path theresolveAppPageInterceptStateload runs beforetoInterceptOpts/buildPageElement, sointerceptPageis always populated. - probePage fix —
buildAppPageProbesreadsintercept.page?.default(app-page-probe.ts:381). Before this PR the lazy intercept page wasnullat probe time, so the dynamic-bailout probe silently inspectedundefined. The new resolve-before-probe (app-rsc-entry.ts:687-697) correctly mirrors the render path. Sinceapp-rsc-entry.tsis the shared dev+prod App Router entry, this covers both — no separate dev-server probe path to keep in sync. - Prerender parity —
createAppPrerenderStaticParamsResolverloads lazy{ load }page sources on demand and dropsnullfns (app-prerender-static-params.ts:80,83); a missinggenerateStaticParams->fns.length === 0-> resolver returnsnull, identical to the old eager-filtered behavior. - Rejection caching —
ensureAppRouteModulesLoadedclears__loadingand leaves__loadedfalse on rejection (app-route-module-loader.ts:78), so a transient import failure retries on the next request. The earlier bonk reviews that called this "cached forever" were inaccurate; the unit test atapp-route-module-loader.test.ts:93locks the retry behavior in.
Tests run locally (vp), all green
app-page-request+app-prerender-endpoints+entry-templates-> 62 passedapp-page-dispatch+app-server-action-execution+app-rsc-handler-> 110 passedapp-route-module-loader-> 8 passedapp-router-production-server -t "CSS isolated"and-t "intercepted modal CSS"-> both isolation + positive intercept tests passvp checkon all 7 changed server/entry modules -> clean
Non-blocking notes
- Parallel-slot page modules (
slot.pagePath,app-rsc-manifest.ts:185) are still eagerly imported, so a non-intercepting parallel-route page importing route-global CSS would still leak into the shared chunk. Out of scope and not exercised by fixtures; worth tracking as a follow-up so the remaining gap stays explicit. - The
navigation-trace.tsexport downgrade and thedev-error-overlay.spec.tsserial-mode flakiness fix are both unrelated to CSS isolation. Benign, but tangential scope creep.
LGTM.
| if (slot.errorPath) imports.getImportVar(slot.errorPath); | ||
| for (const ir of slot.interceptingRoutes) { | ||
| imports.getImportVar(ir.pagePath); | ||
| imports.getLazyLoaderVar(ir.pagePath); |
There was a problem hiding this comment.
Tracking note (non-blocking): this intercepting-route page is now lazy (getLazyLoaderVar), but the parallel-slot page a few lines up (if (slot.pagePath) imports.getImportVar(slot.pagePath);) is still an eager static import. A non-intercepting parallel-route page that imports route-global CSS would still contribute its CSS to the shared manifest chunk — the same class of leak this PR fixes for the main and intercepting pages. Out of scope and not exercised by current fixtures, but worth filing a follow-up so the remaining gap is explicit.
|
Review posted successfully to PR #1738. SummaryI reviewed PR #1738 against the actual source (not just the diff) and ran the targeted suites locally with What I verified
Tests run locally (all green)
Non-blocking notes flagged
Verdict: LGTM. |
Integrate sibling interceptions with the lazy intercept-page loading introduced on main in cloudflare#1738 (CSS-chunk isolation). main made slot intercept pages lazy (manifest emits `page: null` + `__pageLoader`, awaited on demand by resolveAppPageInterceptState / probePage). The auto-merge added `__pageLoader` to the sibling lookup push without the matching type/manifest support, breaking `vp check`. Resolution: make sibling intercept pages lazy too, mirroring slots: - add `__pageLoader?` to AppRscSiblingInterceptForMatching - manifest emits `page: null` + `__pageLoader` (getLazyLoaderVar) for sibling intercepts and allocates a lazy loader instead of an eager import, so the intercepting page's CSS chunk stays isolated in prod
The main merge auto-resolved incorrectly: it moved the `__pageLoader` field that cloudflare#1738 added to the *slot* intercept push block onto the new *sibling* push block instead (the two `interceptLookup.push({...})` calls are textually similar). As a result slot intercept lookup entries lost `__pageLoader`, so resolveAppPageInterceptState never loaded the lazy intercepting page (`page` stayed null) and the modal slot fell back to default.tsx — breaking every slot interception. Restore `__pageLoader: intercept.__pageLoader` on the slot push block. The sibling block already carries it (see merge commit), and both intercept types declare the optional field. Fixes the app-router-dev-server intercept tests and the interception e2e suites that regressed after merging main.
…1804) * test(interception): add failing sibling-interception graph tests * feat(routing): discover sibling-style interception markers * feat(routing): emit sibling interception manifest facts * feat(app-router): serialize sibling intercepts in route manifest * feat(app-router): match sibling intercepts in route matcher * feat(app-router): render sibling intercept as full page response * test: add siblingIntercepts field to AppRoute test fixtures * test(interception): add sibling interception fixtures * fix(app-router): emit null proof for sibling intercepts to bypass slot-validation * test(interception): add sibling interception e2e specs * fix(interception): correct (...)→(..)(..) marker in interception-from-root fixture * fix(interception): resolve sourceRouteIndex to slot-owner route in intercept lookup When a route inherits a @slot from an ancestor via discoverInheritedParallelSlots, the inherited slot's interceptingRoutes are copied from the ancestor. Previously, createInterceptLookup used routeIndex (the inheriting descendant's index) as sourceRouteIndex, causing resolveAppPageInterceptState to see sourceRoute === currentRoute → kind='current-route' instead of kind='source-route'. Fix: build a patternToIndex map and resolve sourceRouteIndex via the intercept's sourceMatchPattern (which names the actual slot-owner route), falling back to routeIndex only when no match is found. * fix(interception): use locale-prefixed href so middleware interception fires correctly * fix(interception): address codex review issues in sibling intercept implementation - Pass effectiveParams to resolveAppPageHead so generateMetadata/generateViewport sees the intercepted route's params instead of the source route's params - Rename SIBLING_INTERCEPT_SLOT_NAME from __page to __vinext_sibling_intercept to prevent collision with a user @__page parallel route directory - Restore middleware-rewrite fixture link without locale prefix so the interception-dynamic-segment-middleware spec exercises the intended middleware rewrite path * fix(test): update slotId assertions and restore locale-prefixed fixture link - Update test assertions from slot:__page to slot:__vinext_sibling_intercept following the rename in commit 2248b8f - Restore locale-prefixed href in interception-mw fixture so the interception-dynamic-segment-middleware E2E tests pass (reverts the accidental revert from 2248b8f back to the working state from 02e80c2) * fix(interception): address review findings on sibling interception PR - Restore locale-less href in interception-mw fixture and fix the underlying middleware-rewrite interception gap: add a Referer-based fallback in app-rsc-handler so interception fires when middleware rewrites the URL before the server matches it (the client-side manifest check sees the pre-rewrite target and skips setting the interception context header; the Referer carries the source page the server needs) - Apply interceptLayouts for sibling intercepts in buildPageElements so a layout.tsx under the interception marker dir wraps the intercepting page, matching Next.js segment-layout wrapping behaviour; add fixture layout and E2E assertion - Add unit test proving findOwnerRouteForDir ancestor walk attaches a sibling intercept to the nearest ancestor route when the marker's parent dir has no page.tsx (not dropped silently) * fix(interception): send current pathname as fallback interception context The client-side manifest check compares the pre-middleware target URL against declared interception patterns. When middleware rewrites the URL (e.g. /foo/p/1 → /en/foo/p/1), the pre-rewrite URL has fewer segments and doesn't match the pattern, so no X-Vinext-Interception-Context header is sent and the server cannot fire interception. Fix: when the manifest check yields no match and there is no prior intercepted navigation in browser state, fall back to sending the current page pathname as the interception context. This mirrors Next.js sending Next-URL on every RSC navigation. The server's findIntercept is double-gated on both source and target matching, so false positives cannot occur. The previousNextUrl is also populated so back/forward traversal can restore intercepted state. Revert the Referer-based server-side fallback (unreliable across environments) in favour of this explicit browser-side fix. * fix(interception): gate middleware-rewrite fallback on declared source pattern The previous fix sent window.location.pathname as fallback interception context on every unmatched navigation, which broke prefetch cache reuse: a prefetch stored with null context wouldn't be found when the navigation used a non-null context key. Refine the fallback to only fire when the current page URL matches at least one sourcePatternParts declared in the route manifest — i.e., the current page is a known interception source. This preserves prefetch cache reuse for ordinary navigations while still enabling middleware- rewrite interception for pages that declare an interception relationship. * fix(interception): support middleware-rewrite targets in fallback context and planner The previous fallback-context gate only checked whether the current page is a declared interception source; it did not verify the target URL could plausibly be a middleware-rewritten version of the declared target pattern. This meant the client sent interception context when it shouldn't (pre-middleware target had no static anchors in common with the pattern) and the planner hard-navigated when the proof target matched only after the rewrite. Three coordinated fixes: 1. Client fallback gate (app-browser-entry / app-browser-interception-context): replace the broad source-only check with matchRoutePatternWithOptionalDynamicSegments on the target side — the pre-middleware URL must still share every static anchor of the declared target pattern, in order, while allowing dynamic segments to be absent. 2. Planner interception proof (navigation-planner): findRouteManifestInterceptionForProof now accepts a proof whose target matched URL equals the pre-rewrite canonical pathname (shorter than the full pattern) when matchRoutePatternWithOptionalDynamicSegments passes. Exact pattern match still skips the targetRouteId guard; the relaxed path skips it too so the middleware-added locale segment does not invalidate an otherwise valid proof. 3. displayPathname threading (app-rsc-handler / app-rsc-entry / app-page-element-builder): the handler now passes canonicalPathname as displayPathname separately from cleanPathname. buildPageElements uses cleanPathname for all internal routing work (slot param extraction, slot overrides) and displayPathname only for render identity — keeping the browser-visible URL in RSC metadata without affecting internal matching. * fix(interception): guard siblingInterceptElement construction and document reserved slot name * fix(interception): surface missing default export on sibling intercept pages A sibling-style interception whose intercepting page module is missing its `default` export previously fell back to the source route's page component (`effectivePageModule?.default ?? PageComponent`), silently rendering a *different* page than the one requested. Resolve the effective component from the intercepting page only and extend the existing no-default-export guard to cover sibling intercepts, so a missing export surfaces the same explicit "Page has no default export" error the source/slot paths already produce. For a normal request this is identical to the previous behavior since `effectivePageModule === pageModule`. Addresses bonk review feedback on #1804. * test(app-router): guard canonical pathname commit on soft-nav config rewrites The `displayPathname = canonicalPathname` change feeds the RSC payload identity the client planner uses to commit a navigation, so it affects every rewritten route, not just interceptions. Existing coverage (nextjs-compat/hooks.test.ts) only exercises the hard-nav (SSR) path. Add an e2e that soft-navigates (client-side) to an afterFiles-rewritten route and asserts the committed URL and `usePathname()` stay canonical (pre-rewrite), with a marker check proving no full page reload occurred. Addresses bonk review feedback on #1804. * fix(interception): restore __pageLoader on slot intercept lookup entries The main merge auto-resolved incorrectly: it moved the `__pageLoader` field that #1738 added to the *slot* intercept push block onto the new *sibling* push block instead (the two `interceptLookup.push({...})` calls are textually similar). As a result slot intercept lookup entries lost `__pageLoader`, so resolveAppPageInterceptState never loaded the lazy intercepting page (`page` stayed null) and the modal slot fell back to default.tsx — breaking every slot interception. Restore `__pageLoader: intercept.__pageLoader` on the slot push block. The sibling block already carries it (see merge commit), and both intercept types declare the optional field. Fixes the app-router-dev-server intercept tests and the interception e2e suites that regressed after merging main. --------- Co-authored-by: James Anderson <james@eli.cx>
Overview
import()loaders instead of static importsgenerateStaticParams, and intercepted/source-route renderspackages/vinext/src/entries/app-rsc-manifest.ts,packages/vinext/src/entries/app-rsc-entry.ts,packages/vinext/src/server/app-rsc-handler.ts,tests/app-router.test.tsscroll-padding-topinto unrelated hash pagesWhy
Native hash scrolling uses the browser's layout state. Next.js calls
scrollIntoView()for hash fragments, so a 20px-short scroll offset means unrelated CSS is present, not that hash math is wrong.generateStaticParamsmust remain distinguishable from malformed return valuesWhat changed
/hashwith sibling/hash-with-scroll-offsetscroll-padding-top:20pxfrom the sibling pagegenerateStaticParamsValidation
vp test run tests/app-router.test.ts -t "keeps production route-level global CSS isolated"vp test run tests/app-router.test.ts tests/app-prerender-endpoints.test.ts tests/app-page-request.test.ts tests/app-page-dispatch.test.ts tests/app-server-action-execution.test.ts tests/app-rsc-handler.test.ts tests/entry-templates.test.tsvp checkvp check, and knipvp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/app-dir/navigation/navigation.test.ts/hash-link-back-to-same-pagescroll-offset failure now passes./hash-with-scroll-offsetpasses.useParamslog capture, hashwith-query-paramRSC request detection, redirect-with-loading duplicate render, nested navigation, Promise proxy navigation, and scroll restoration.Risk / compatibility
References
scrollIntoView()