fix(app-router): preserve active slots across segment-cache refresh - #2461
fix(app-router): preserve active slots across segment-cache refresh#2461james-elicx wants to merge 3 commits into
Conversation
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: preserve active slots across segment-cache refresh
Reviewed the full diff (23 files). The core mechanism — a new X-Vinext-Mounted-Slot-Active-Routes side-channel header that carries slotId=routeId pairs so the server can rerender/preserve active parallel slots during refresh, prefetch, and action-rerender — is coherent, well-tested, and centralized behind normalizeRscRequest (single ingestion point shared by dev and prod RSC handlers, so dev/prod parity holds). Type check and the touched Vitest files (app-browser-entry, app-page-element-builder, app-server-action-execution, link-navigation, entry-templates) all pass.
No blocking issues found. A few observations, roughly in priority order:
1. resolveRouteSlotId fallback diverges from the canonical slot-id computation (potential silent miss)
app-page-element-builder.ts:575-586 computes a slot id when slot.id is absent:
const layoutIndex = slot.layoutIndex && slot.layoutIndex >= 0 ? slot.layoutIndex : 0;
const treePosition = route.layoutTreePositions?.[layoutIndex] ?? 0;The canonical wiring path (app-page-route-wiring.tsx:513) uses slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1 and resolves the tree path from the actual resolved layout entry. When layoutIndex < 0 these disagree (0 vs last layout), so activeSlotRoutes.get(slotId) would silently miss and the active slot would not be preserved. In practice slot.id is populated from the route graph (AppRouteGraphParallelSlot.id is "Required"), so the fallback is rarely hit — but the manifest emits id: slot.id ?? null, so the null path is reachable. Consider either asserting slot.id is present here or making the fallback match the wiring's layoutEntries.length - 1 semantics. Also note slot.layoutIndex && ... treats layoutIndex === 0 as falsy; it lands on the right answer by coincidence, but typeof slot.layoutIndex === "number" && slot.layoutIndex >= 0 would be clearer.
2. renderObservation metadata is cast without validation, and its consumer can throw
app-elements-wire.ts:860-862 accepts renderObservation with only a shallow typeof === "object" check, then casts to RenderObservation. This is inconsistent with every adjacent field (slotBindings, interception, cacheEntryReuseProof) which have strict parsers. The consumer hasObservedDynamicRenderWork (app-browser-entry.ts) then does observation.dynamicFetches.length and observation.requestApis.some(...) unguarded, which would throw a TypeError on a malformed object. It is self-generated RSC metadata (not a cross-origin header) and the call sites are inside the snapshot-store try/catch, so severity is low — but given the strictness of the surrounding parsers this is worth tightening for consistency.
3. Behavioral change: dynamicOnly revalidation no longer clears the client navigation cache
app-browser-action-result.ts and app-browser-server-action-client.ts change the gate from revalidation !== "none" to only clearing on staticAndDynamic. This is intentional (clearing the whole nav cache would defeat active-slot preservation) and the test was updated to expect false for dynamicOnly. Flagging it as the most consumer-visible semantic change so a reviewer confirms it matches Next.js segment-cache handoff behavior for revalidateTag/revalidatePath on dynamic content. Worth a link to the Next.js reference in the commit/PR body per repo convention.
4. Behavioral change: non-HMR active-slot preservation is loosened to any layout in the target's layoutIds
app-browser-visible-commit.ts:168-178 previously required the slot owner's layout to be a bfcache-compatible preserved element (unchanged bfcache id). For the non-HMR/non-refresh lane it now only requires targetLayoutIdSet.has(ownerLayoutId). This is the crux of the fix and is covered by the new tests, but it does allow preserving slot content when the owner layout id matches even if its bfcache id changed. Reviewers should confirm this can't retain semantically-stale slot content across a navigation that re-renders the owning layout.
5. Perf: App Router viewport prefetches now bypass idle scheduling
link.tsx changes prefetchUrl so if (priority === "high" || hasAppNavigationRuntime()) startPrefetch() runs immediately; only Pages Router low-priority fallbacks keep requestIdleCallback. Combined with useEffect → useLayoutEffect for the intersection-observer registration, this shifts more work onto the synchronous commit path. It appears necessary so a visible-link ping seeds the cache before refresh, but it is a real change to main-thread scheduling for viewport prefetches — worth a note in the PR body.
6. Minor: redundant header sets and a no-op wrapper
- In
navigation.ts(_appRouter.prefetch) andlink.tsx,mountedSlotActiveRoutesHeaderis passed tocreateRscRequestHeaders({ mountedSlotActiveRoutesHeader })and then set again via explicitheaders.set(VINEXT_MOUNTED_SLOT_ACTIVE_ROUTES_HEADER, ...).createRscRequestHeadersalready sets it (app-rsc-cache-busting.ts:297-302), so the explicit set is dead code. (It mirrors the pre-existingmountedSlotsHeaderpattern, so it is consistent, just redundant.) resolveActionRerenderRenderMode()(app-server-action-execution.ts:385) just returns theAPP_RSC_RENDER_MODE_ACTION_RERENDER_PRESERVE_UIconstant with no added logic; the previous inline constant was clearer.
None of these block merge. Items 1 and 2 are the ones I would most want addressed (or explicitly acknowledged as safe) before shipping.
|
Review posted to PR #2461. SummaryI reviewed all 23 changed files in PR #2461. The core mechanism — a new No blocking issues. Findings, in priority order:
Items 1 and 2 are the ones I'd most want addressed or explicitly acknowledged as safe before shipping. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Re-review: preserve active slots across segment-cache refresh (post-f374819)
Re-reviewed after the two hardening commits. The two high-priority items from the prior review are now resolved, and I found one additional cache-correctness concern worth confirming before merge.
Resolved since last review
-
resolveRouteSlotIdfallback now matches the canonical wiring.app-page-element-builder.ts:575-590now usestypeof slot.layoutIndex === "number" && slot.layoutIndex >= 0and falls back toMath.max((layoutPositions?.length ?? 1) - 1, 0)(last layout), matchingapp-page-route-wiring.tsx:513(layoutEntries.length - 1). SincelayoutEntries[i].treePath === createAppPageTreePath(routeSegments, layoutTreePositions[i] ?? 0)(wiringcreateAppPageLayoutEntries, line 360-372), the two slot-id computations now agree in thelayoutIndex < 0fallback. Good. -
renderObservationis now validated.app-elements-wire.ts:806-825(parseRenderObservationMetadata) checksArray.isArrayonrequestApis/dynamicFetches/cacheTags/pathTagsandisUnknownRecord/typeofon the rest before casting, so the downstreamobservation.dynamicFetches.length/observation.requestApis.some(...)accesses inapp-browser-entry.tscan no longer throw on a malformed object. Consistent with the adjacent strict parsers now.
New finding (worth confirming before merge)
The ISR RSC cache key does not incorporate mountedSlotActiveRoutesHeader, but that header can change the cached RSC output in cacheable render modes.
app-page-cache.ts:311-318builds the ISR key fromcleanPathname+(mountedSlotsHeader, renderMode, interceptionContext)only —mountedSlotActiveRoutesHeaderis not threaded in.isr-cache.ts:401-419(appIsrRscKey) hashesslots:<mountedSlotsHeader>, the render-mode variant, andsource:<interceptionContext>into the key variant — but not the active-routes header.- The
_rsccache-busting search param does includeVINEXT_MOUNTED_SLOT_ACTIVE_ROUTES_HEADER(app-rsc-cache-busting.ts:180), so the client prefetch cache and any URL-keyed CDN layer disambiguate correctly. But the server ISR key is built fromcleanPathname(search params stripped), so that disambiguation does not reach the ISR store. - The header is consumed on cacheable paths, not just refresh/action-rerender. In
app-page-element-builder.ts:531-540, the!shouldRerenderActiveMountedSlots(renderMode)branch (navigation /PREFETCH_LOADING_SHELL) setspreserveMountedContent: truewhenactiveRouteId === targetRouteId || !slotHasTargetPage. That flag then causes the slot to be omitted from the RSC payload inapp-page-route-wiring.tsx:817-823(preserveMountedContent && isRscRequest && mountedSlotIds.has(slotId)), which is a distinct output from the non-preserve case for a slot that does have a page. - The header is emitted on prefetch/navigation requests (
link.tsx:498-514,navigation.ts:1977-1987) and is set whenever there are active slot bindings (app-browser-entry.ts:1128-1131), so it is routinely present.
Net: two cacheable requests with identical cleanPathname / mountedSlotsHeader / renderMode / interceptionContext but different mountedSlotActiveRoutesHeader collide on the same ISR key while potentially rendering different RSC (one omits an active slot's page, the other includes it). That is a stale-slot / cache-poisoning shape for apps using parallel routes with active slots.
Neither isr-cache.ts nor app-page-cache.ts was touched in this PR, so this looks like the header was threaded through element building without extending the ISR key. Suggested fixes, in order of preference:
- Add
mountedSlotActiveRoutesHeadertoappIsrRscKey's variant (mirroring the existingslots:variant), and thread it throughreadAppPageCacheResponse/ the key builder — analogous to howmountedSlotsHeaderis already handled. - Or, if active-slot preservation is intended to be dynamic-only, skip the ISR cache read/write when
mountedSlotActiveRoutesHeaderis present.
Please confirm which of these matches the intended behavior, and add a focused isr-cache/app-page-cache test asserting the key varies with the active-routes header. If Next.js's segment cache keys these variants separately (it uses the router state tree in the cache key), aligning with that would be the reference.
Carried-forward observations (from prior review, non-blocking)
dynamicOnlyrevalidation no longer clears the client nav cache — intentional; still worth a Next.js reference link in the PR body.- Non-HMR active-slot preservation loosened to
targetLayoutIdSet.has(ownerLayoutId)(app-browser-visible-commit.ts:176) — the crux of the fix; covered by tests. - Viewport prefetches now bypass idle scheduling +
useEffect→useLayoutEffect(link.tsx) — real main-thread scheduling shift; note it in the PR body. - Redundant explicit
headers.set(VINEXT_MOUNTED_SLOT_ACTIVE_ROUTES_HEADER, ...)after already passingmountedSlotActiveRoutesHeaderintocreateRscRequestHeaders(link.tsx:512-514,568-573;navigation.ts:1986-1987) — dead code, sinceapp-rsc-cache-busting.ts:297-302already sets it. AndresolveActionRerenderRenderMode()(app-server-action-execution.ts:25) is a no-op wrapper over a constant.
Item under "New finding" is the one I'd want addressed or explicitly acknowledged as safe before shipping. Type check and the touched Vitest files (app-page-element-builder, app-elements, app-browser-entry, app-server-action-execution, link-navigation) all pass locally.
|
Review posted to PR #2461. SummaryRe-reviewed after the two hardening commits ( Resolved
New finding (the one to address before merge)The ISR RSC cache key omits
Net: two cacheable requests differing only in Items 3-6 from the prior review were carried forward as non-blocking. Type check and all touched Vitest files pass locally. |
|
/bigbonk review for issues |
Re-review: preserve active slots across segment-cache refresh (post-
|
|
Review posted to PR #2461. SummaryRe-reviewed after the new cache-keying commit ( ResolvedThe ISR RSC cache key now incorporates
The stale-slot / cache-poisoning collision flagged previously can no longer occur. Non-blocking (carried forward)Items 3-6 remain: the intentional Verdict: no blocking issues. |
|
Deploy-suite run 28478866791 / report job 84413308650 triage: this PR appears to cover the App Router refresh behavior rows from Exact rows:
Since this PR is already James-owned, I am skipping duplicate implementation work for these rows. |
|
Deploy-suite run 31290819291 maps all three current failures in
The targeted Next.js v16.2.6 validation on this PR already passed this exact upstream file 3/3, so I am skipping duplicate implementation work and leaving its branch untouched. |
|
This PR is now conflicted and its implementation predates the current App Router cache architecture. I opened #2873 as the reviewed current-main replacement for the same |
Summary
Validation