Skip to content

feat(skip): plumb client reuse manifests through the app request path#1717

Merged
james-elicx merged 6 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/skip-03-manifest-plumbing
Jun 2, 2026
Merged

feat(skip): plumb client reuse manifests through the app request path#1717
james-elicx merged 6 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/skip-03-manifest-plumbing

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Jun 2, 2026

What this changes

Adds the transport wiring that carries a client reuse manifest from the browser to the server. The browser now derives a bounded manifest from the visible AppElements state and attaches it on soft navigations; the server parses it and threads the parsed result through the RSC handler, the generated app entry, page dispatch, and the render lifecycle. Nothing reads the manifest to authorize a skip yet — no skip decision, no RSC payload omission, no cache-control change.

Note: this is not a pure no-op plumbing slice. Two observable, intentional (more Next-accurate) behavioral changes ride along with the wiring so the enable slice has correct inputs to consume; both are called out under "Behavioral changes" below and pinned by parity assertions.

Bonk: Pls read #1437 to see the context. Tests will come in the next PR. Please do not block on missing tests since churning is time wasting. Focus on the logic/implementation please.

Why

The static-layout skip planner (#1674) and observation foundations (#1672) have no input to act on until a manifest actually reaches the server. Landing the plumbing on its own keeps it reviewable in isolation and lets the enable-transport slice focus purely on the skip decision and payload omission.

Approach

  • app-browser-client-reuse-manifest: bounded producer that builds the manifest from visible AppElements state and caps entries to the skip planner's verification budget. Attached as X-Vinext-Client-Reuse-Manifest only for normal navigate transitions (not refresh).
  • client-reuse-manifest: parse the header into a render/send-by-default disposition.
  • Thread the parsed result through app-rsc-handler -> generated app-rsc-entry -> app-page-dispatch -> app-page-render.
  • Wire the per-layout observation tracker (app-layout-param-observation) and segment-config classification into dispatch, and register per-element render dependencies via app-page-route-wiring, so the enable slice can read them.
  • Non-goals: no skip decision, no RSC payload omission, no cache-control change, no releaseAppElementRenderDependency consumer (that lands with enable).

Behavioral changes (intentional, not no-op)

  1. Layout error-probe now renders the full layout subtree, not just the layout function with children: null. probeLayoutAt was rewritten to go through probeAppPageLayoutWithTracking -> probeReactServerSubtree, which (a) passes <Fragment /> (truthy) as children instead of null and (b) recursively invokes child server components. Because probeLayoutForErrors runs on the build-time-classified path too, a layout whose chrome only renders when children is truthy (if (!children) return null), or a child component deep in the subtree, can now throw notFound()/redirect() (or run fetch/side effects) during the probe where it previously did not — which can change the emitted HTTP response on an App Router request. This matches Next's render order (layouts render with their children present) and is required so observation tracking sees the dependencies created below a layout's immediate body. Pinned by tests/app-page-route-wiring.test.ts ("probes layout branches that render only when children are present").
  2. __layoutFlags output can change. isLayoutObservationDynamic is now live in classification and flips a build-time static layout to d whenever isAppLayoutObservationUnsafeForStaticReuse is true (finite revalidate, cache tags, request APIs, param scope, etc.). Additionally, classifyLayoutSegmentConfigFromModule runs at request time and is merged on top of the build-time map (module segment config is authoritative), so layouts not captured at build time (e.g. dev mode) are now classified. __layoutFlags is serialized to the wire (app-elements-wire.ts) and stored in client router state (app-browser-state.ts), so the flags a client receives — and which entries enter the manifest — can differ from before this PR. Pinned by tests/app-page-execution.test.ts ("flips a build-time static layout to dynamic when a runtime observation is unsafe for static reuse") plus the existing classification suite.

Validation

  • vp check: format, lint, type check passed.
  • vp test run (full suite): 243 files, 6827 tests passed.
  • Manifest threading covered by tests/app-rsc-handler.test.ts ("passes parsed ClientReuseManifest hints..."), tests/app-browser-client-reuse-manifest.test.ts, and tests/app-rsc-cache-busting.test.ts (manifest attached without becoming a shared cache variant).

Risks / follow-ups

  • The parsed manifest is carried but never authorizes a skip; absent and unproven manifests render and send exactly as before.
  • The two behavioral changes above are observable today (with no manifest consumer yet); they are pinned by the parity assertions noted in each item.
  • The producer's per-entry guards (maxEntryIdLength, maxEntryCount) and the byte budget (maxManifestBytes, enforced in serializeBoundedClientReuseManifest) are not independently observable: an entry can pass the per-entry guards yet still be trimmed by the byte budget. The enable slice must not assume layoutFlags[id] === "s" && id in elements implies the entry made it into the emitted header.
  • Slice 3 of 4 from feat(skip): enable proven static layout transport skips #1437. Enable-transport (the big PR) and SKIP-05 safety hardening follow as stacked PRs.

Refs #726

The browser never sends a client reuse manifest and the server has no path
to receive one, so the static-layout skip planner (cloudflare#1674) and observation
foundations (cloudflare#1672) have nothing to act on. Skip transport stays fully off,
but the transport wiring those slices depend on is missing.

Add a bounded browser producer that derives a client reuse manifest from the
visible AppElements state and attaches it as X-Vinext-Client-Reuse-Manifest
only on soft navigations. Parse the header into a render/send-by-default
disposition and thread the parsed result through the RSC handler, the
generated app entry, page dispatch, and the render lifecycle. Wire the
per-layout observation tracker and segment-config classification into dispatch
so the enable-transport slice can read them.

No RSC payload is omitted and no response cache-control changes: the parsed
manifest is carried but never authorizes a skip yet. Unproven and absent-
manifest requests render and send exactly as before.

Slice 3 of 4 from cloudflare#1437; enable-transport follows.

Refs cloudflare#726
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 2, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1717

commit: e247d58

@NathanDrake2406 NathanDrake2406 marked this pull request as draft June 2, 2026 08:05
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 2, 2026 08:17
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Reviewed the transport plumbing for the client reuse manifest. The threading from browser -> RSC handler -> dispatch -> render is consistent and correctly wired, the byte-bounded producer is sound (binary-search prefix fit, returns null rather than emitting oversized headers, capped to the skip verification budget), and the mounted-slots header refactor is behavior-preserving (getMountedSlotIdsHeader never returns "", so moving from if (mountedSlotsHeader) to the !== null && !== undefined guard in createRscRequestHeaders is equivalent). Per the PR description I did not block on missing tests.

A few things worth a closer look, mostly around the "request behavior is unchanged" claim:

  1. Layout classification is now recomputed at request time, which changes layoutFlags output. getEffectiveLayoutClassifications is called unconditionally and merges classifyLayoutSegmentConfigFromModule results on top of the build-time map, and probeAppPageLayouts now also folds in an observation-based dynamic override. layoutFlags is serialized to the wire under __layoutFlags and stored in client router state (app-browser-state.ts / app-browser-visible-commit.ts), so the values a client receives can differ from before this PR. That is a real (likely intended, more-Next-accurate) behavioral change, not pure plumbing — worth calling out explicitly in the description and ideally covered by a parity assertion before the enable slice lands.

  2. New per-request render cost. probeAppPageLayoutWithTracking now renders each layout's subtree (with a Fragment child) for observation on every App Router request, where probeLayoutAt previously only did error/dynamic probing. This runs unconditionally now that layoutParamAccess is always constructed. Bounded by probeReactServerSubtree's depth/node caps, but it's added work on the hot path with no consumer yet — flagging for awareness.

  3. Debug classification reason can disagree with the emitted flag in the build-time path (diagnostics-only). See inline comment.

  4. Write-only WeakMap. appElementRenderDependencies is registered but never read in this PR (acknowledged as a non-goal). Harmless (WeakMap GCs with the elements object), just noting it is dead until the enable slice.

Nothing here looks blocking for a plumbing-only slice; the classification-output change (#1) is the item most worth confirming is deliberate.

const errorResponse = await probeLayoutForErrors(options, layoutIndex);
if (errorResponse) return errorResponse;
const observationDynamic = cls.isLayoutObservationDynamic?.(layoutId) === true;
layoutFlags[layoutId] = buildTimeResult === "dynamic" || observationDynamic ? "d" : "s";
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.

In the build-time-classified branch, debugClassification is reported earlier (line 420-423) with the build-time reason, but the final flag here can be flipped to "d" by observationDynamic even when buildTimeResult === "static". After that flip, the emitted layoutFlags[layoutId] disagrees with the debug reason that was already recorded (e.g. reason says segment-config: dynamic=force-static but the flag is d). The Layer-3 branch below avoids this by computing layoutDynamic before calling debugClassification. Diagnostics-only, but if any debug-variant invariant test asserts reason/flag agreement this will trip it. Consider deferring the debug call until after the observation override, or emitting a runtime-observation reason when the override flips the flag.

const reasons = includeReasons ? new Map(route.__buildTimeReasons ?? []) : null;

for (let index = 0; index < route.layouts.length; index++) {
const classification = classifyLayoutSegmentConfigFromModule(route.layouts[index]);
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.

classifyLayoutSegmentConfigFromModule duplicates the build-time classifyLayoutSegmentConfig (build/report.ts:472) but is applied at request time and merged on top of __buildTimeClassifications. Two notes: (a) this request-time pass adds revalidate === false handling that the build-time classifier lacks (it reads a numeric literal), so the two can diverge — intentional, but worth a shared helper or a comment cross-referencing the build-time version to keep them in sync; (b) because this now mutates the effective classification for every request, layouts whose segment config was not captured at build time (e.g. dev mode) will now be classified where they previously were not, which is the source of the layoutFlags output change noted in the summary.

ReadonlyMap<string, AppRenderDependency>
>();

export function registerAppElementRenderDependencies(
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.

registerAppElementRenderDependencies writes into a module-level WeakMap that has no reader in this PR (releaseAppElementRenderDependency consumer lands with enable, per the description). It's harmless since the WeakMap is keyed by the elements object and GCs with it, but a one-line comment marking this as write-only-until-enable would help reviewers of the next slice avoid wondering whether the read path was dropped.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 2, 2026

Review posted successfully. Summary of what I reviewed and found:

Review of PR #1717 — client reuse manifest plumbing

What's solid:

  • Manifest threading (browser -> RSC handler -> dispatch -> render) is consistent and correctly wired across all four hops, including the generated entry.
  • The byte-bounded producer (serializeBoundedClientReuseManifest) is sound: binary-search prefix fit, returns null rather than emitting oversized headers, capped to the skip verification budget.
  • The mounted-slots header refactor is behavior-preserving — getMountedSlotIdsHeader/normalizeMountedSlotsHeader never return "", so moving from if (mountedSlotsHeader) to the !== null && !== undefined guard is equivalent.
  • The unused clientReuseManifest/layoutParamAccess fields on the render lifecycle match the stated non-goals.

Findings posted (4, none blocking for a plumbing slice):

  1. layoutFlags output changes — request-time segment-config classification + observation override now feed __layoutFlags, which is serialized to the wire and stored in client state. Contradicts the "request behavior unchanged" claim; likely intended (more Next-accurate) but should be deliberate and covered by a parity check.
  2. New per-request render costprobeAppPageLayoutWithTracking renders each layout subtree on every App Router request, with no consumer yet.
  3. Debug classification reason can disagree with the emitted flag in the build-time branch (diagnostics-only).
  4. Write-only WeakMapappElementRenderDependencies is registered but never read (acknowledged future-slice work).

The most important item to confirm is #1 — the classification-output change is real and not pure plumbing.

github run

- app-page-execution: defer layout debug classification until after the
  observation override so the emitted reason agrees with the final flag;
  emit a runtime-probe reason when an observation flips a build-time static
  layout to dynamic.
- app-page-dispatch: document classifyLayoutSegmentConfigFromModule as the
  request-time counterpart to the build-time classifier, noting the
  intentional revalidate===false divergence and the dev-mode classification.
- app-render-dependency: mark registerAppElementRenderDependencies as
  write-only until the enable slice's consumer lands.
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 2, 2026

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review — client reuse manifest plumbing (slice 3 of 4)

Reviewed the transport wiring end to end. The threading (browser producer -> RSC handler -> normalization -> dispatch -> render lifecycle) is consistent and correctly typed across every hop, including the generated app-rsc-entry. The byte-bounded producer is sound (binary-search prefix fit, returns null instead of an oversized header, capped to the skip-verification budget of 8). Parsing remains byte-bounded in parseClientReuseManifestHeader, so the wider parse path is not a new DoS surface. Per the PR description I did not block on missing tests.

Good news: the debug-reason/flag disagreement that an earlier review flagged in the build-time branch has been fixed in this revision — layoutFlags[layoutId] is now recomputed before debugClassification is called, and a runtime-probe reason is emitted when an observation flips a build-time static to dynamic. That concern is resolved.

The central issue is that "request behavior is unchanged" is not accurate for this slice. Two observable changes ride along with the plumbing:

  1. The error-probe now renders the full layout subtree, not just the layout function with children: null. probeLayoutAt was rewritten to call probeAppPageLayoutWithTracking -> probeReactServerSubtree, which (a) passes <Fragment /> as children instead of null, and (b) recursively invokes child server components. Because probeLayoutForErrors runs on the build-time-classified path too, a layout whose chrome only renders when children is truthy (e.g. if (!children) return null) — or a child component deep in the subtree — can now throw notFound()/redirect() (or run fetch/side effects) during the probe where it previously did not. That can change the emitted HTTP response, on every App Router request, with no consumer reading the manifest yet. The full suite passing shows no current fixture trips it, but it is a latent behavioral/parity change, not pure plumbing.

  2. __layoutFlags output can change. isLayoutObservationDynamic is wired into classification and flips a build-time static layout to d whenever isAppLayoutObservationUnsafeForStaticReuse is true (finite revalidate, cache tags, request APIs, param-scope, etc.). __layoutFlags is serialized to the wire and stored in client router state, and the new producer reads layoutFlags[layoutId] === "s", so the values a client receives — and which entries enter the manifest — differ from before this PR.

Neither is necessarily wrong (both look more Next-accurate), but they should be called out explicitly as intentional behavioral changes and ideally pinned by a parity assertion before the enable slice lands, rather than described as a no-op plumbing slice. Inline notes below, plus two smaller items (write-only WeakMap, merge-precedence in createEffectiveLayoutClassifications).

Nothing here is blocking for a stacked plumbing slice given the stated non-goals.

params,
)),
children: null,
probeLayoutAt(li, layoutParamAccess) {
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 rewrite changes the error-probe semantics, not just observation. The old probeLayoutAt called the layout once with children: null and did not recurse. probeAppPageLayoutWithTracking now renders via probeReactServerSubtree, which (1) passes <Fragment /> (truthy) as children and (2) recursively invokes child server components.

Since probeLayoutForErrors calls this on the build-time-classified path too (app-page-execution.ts:416), a layout that conditionally renders children-dependent chrome (if (!children) return null) — or any child component in the subtree — can now throw notFound()/redirect() or run side effects during the probe where it previously could not. That can change the HTTP response on every App Router request. The full suite passing means no current fixture trips it, but this is a real behavioral change that contradicts the "request behavior unchanged" claim. Worth an explicit callout in the description and a parity fixture (a layout that throws only when children is present).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirmed and intentional. The probe now renders the full layout subtree (Fragment children + recursive child server components) so observation tracking sees data dependencies created below a layout's immediate body, matching Next's render order. We've stopped describing this slice as a no-op: the PR description now has a "Behavioral changes" section calling this out explicitly, and the change is pinned by tests/app-page-route-wiring.test.ts → "probes layout branches that render only when children are present" (a layout with if (!children) return null whose children-dependent chrome now runs during the probe and contributes its cache tags). No current fixture trips a new throw; CI is green.

buildTimeClassifications: layoutClassifications.buildTimeClassifications,
buildTimeReasons: layoutClassifications.buildTimeReasons,
debugClassification: options.debugClassification,
isLayoutObservationDynamic(layoutId) {
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.

isLayoutObservationDynamic is now live in classification, so a build-time static layout flips to d whenever isAppLayoutObservationUnsafeForStaticReuse returns true (finite revalidate, cache tags, request APIs, param scope, etc.). __layoutFlags is serialized to the wire (app-elements-wire.ts:611) and stored in client router state (app-browser-state.ts:488), and the new producer keys off layoutFlags[layoutId] === "s". So the flags a client receives can differ from before this PR. Likely intended (more Next-accurate), but it is an observable output change, not plumbing — please document it and add a parity assertion before the enable slice consumes it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Documented and pinned. The PR description "Behavioral changes" section now states explicitly that __layoutFlags output can differ: isLayoutObservationDynamic flips a build-time static layout to d when the observation is unsafe for static reuse, and request-time classifyLayoutSegmentConfigFromModule is merged on top of the build-time map. Added a parity assertion in tests/app-page-execution.test.ts → "flips a build-time static layout to dynamic when a runtime observation is unsafe for static reuse", which verifies a build-time static layout downgrades to d with a runtime-probe reason while a safe observation stays s.

* normalizes `false` to `Infinity` upstream. The runtime override is merged on
* top of the build-time classification map in
* `createEffectiveLayoutClassifications`, so layouts not captured at build time
* (e.g. dev mode) are classified here where they previously were not.
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 "intentional divergence" note is slightly misleading: the build-time classifier already normalizes revalidate = false to Infinity upstream (build/report.ts:238), so both versions treat false as static — they agree on that case, they don't diverge. The genuine difference is the one in the next sentence (request-time classifies layouts that were never captured at build time, e.g. dev mode). Consider trimming the false/Infinity framing so a future reader doesn't go looking for a divergence that isn't there.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed. The docblock on classifyLayoutSegmentConfigFromModule no longer frames revalidate === false/Infinity as a divergence — it now notes both the build-time and request-time classifiers treat false as static (the build-time version normalizes falseInfinity upstream), and that the genuine difference is scope: the request-time pass classifies layouts never captured at build time (e.g. dev mode).


for (let index = 0; index < route.layouts.length; index++) {
const classification = classifyLayoutSegmentConfigFromModule(route.layouts[index]);
if (classification === null) continue;
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.

Minor precedence note: this unconditionally overrides the build-time map for any layout whose module config classifies (classifications.set(index, ...)). If a Layer 1/2 build-time classifier marked a layout dynamic for a non-segment-config reason and the module's segment config says force-static, request-time will now win and flip it to static. That is probably the desired "module config is authoritative" behavior, but it is a deliberate precedence decision worth a one-line comment so the next slice doesn't treat the build-time map as the source of truth.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Clarified. The precedence is intentional ("module config is authoritative") and is now documented inline in createEffectiveLayoutClassifications: when a layout's module segment config classifies, it overrides the build-time map for that layout even if a Layer 1/2 classifier marked it differently for a non-segment-config reason, and downstream consumers should treat the merged result — not __buildTimeClassifications — as the source of truth.

// (`releaseAppElementRenderDependency`) that lands with enable-transport. It is
// keyed by the elements object and GCs with it, so it is harmless while unread.
export function registerAppElementRenderDependencies(
elements: Readonly<Record<string, unknown>>,
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 write-only-until-enable comment is clear and the WeakMap GCs with the elements object, so this is harmless. Just confirming for the next reviewer: there is no reader of appElementRenderDependencies in this PR, and releaseAppElementRenderDependency is the planned consumer in the enable slice. No action needed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Acknowledged, no behavior change needed. registerAppElementRenderDependencies is marked write-only-until-enable with an inline comment; releaseAppElementRenderDependency is the planned reader in the enable slice, and the WeakMap GCs with the elements object so there's no leak in the interim.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 2, 2026

Review posted successfully. Summary of what I reviewed and found:

Review of PR #1717 — client reuse manifest plumbing (slice 3 of 4)

What's solid:

  • Manifest threading (browser producer → RSC handler → normalization → dispatch → render lifecycle) is consistent and correctly typed across every hop, including the generated app-rsc-entry.
  • The byte-bounded producer (serializeBoundedClientReuseManifest) is sound: binary-search prefix fit, returns null rather than an oversized header, capped to the skip-verification entry budget (8).
  • parseClientReuseManifestHeader enforces byte limits, so the parse path isn't a new DoS surface.
  • The mounted-slots header refactor is behavior-preserving.
  • The debug-reason/flag disagreement an earlier review flagged is now fixed — the flag is recomputed before debugClassification, and a runtime-probe reason is emitted on a flip.

Findings posted (5, none blocking for a stacked plumbing slice):

  1. Error-probe semantics changed (most important). probeLayoutAt was rewritten to call probeAppPageLayoutWithTrackingprobeReactServerSubtree, which passes <Fragment /> (truthy) as children instead of null and recursively invokes child server components. Since this runs on the build-time-classified error-probe path too, a layout/child that throws notFound()/redirect() only when children are present can now alter the HTTP response on every request. The earlier review undercaptured this — it's broader than just observation cost.

  2. __layoutFlags output can change. isLayoutObservationDynamic is now live and flips build-time staticd. Since __layoutFlags is serialized to the wire and stored in client state, the values a client receives differ. Both items contradict the "request behavior unchanged" claim — likely intended/more Next-accurate, but should be deliberate and pinned by a parity assertion.

  3. Misleading divergence comment — build-time already normalizes revalidate=falseInfinity, so the two classifiers agree on that case.

  4. Merge-precedence note in createEffectiveLayoutClassifications (request-time overrides build-time map).

  5. Write-only WeakMap confirmed harmless and acknowledged future-slice work.

Review link

github run

- app-page-dispatch: correct the classifyLayoutSegmentConfigFromModule
  docblock — the build-time classifier already normalizes revalidate=false
  to Infinity, so both agree on that case; the real difference is scope
  (request-time classifies dev-mode layouts not captured at build time).
- app-page-dispatch: document the merge precedence in
  createEffectiveLayoutClassifications — module segment config is
  authoritative over the build-time map.
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

…t to dynamic

Adds a parity assertion for the request-time behavioral change the review
flagged: isLayoutObservationDynamic downgrades a build-time `static` layout
to `d` (and emits a runtime-probe reason) when the observed render is unsafe
for static reuse, while a safe observation keeps it static. Complements the
existing children-subtree probe assertion in app-page-route-wiring.test.ts.
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Reviewed the manifest plumbing end to end. I agree with the prior /bigbonk rounds: the browser→handler→normalization→dispatch→render threading is consistent and correctly typed, the byte-bounded producer (serializeBoundedClientReuseManifest) is sound (binary-search prefix fit on a monotonic JSON array, returns null rather than an oversized header, capped to the skip-verification budget), the mounted-slots header refactor is behavior-preserving, and the two ride-along behavioral changes (full-subtree error probe + __layoutFlags flips) are now documented in the description and pinned by parity tests. No need to re-litigate those.

I have one new finding the earlier rounds did not surface: the rewrite of probeLayoutAt to run inside runLayoutProbe's isolated dependency scope can mask the Layer-3 dynamicDetected signal, and the compensating observationDynamic tracks a slightly narrower set of signals than consumeDynamicUsage(). Detail inline. Not blocking for a plumbing slice with the planner not yet consuming the flag, but worth confirming before the enable slice keys skip decisions off __layoutFlags.

// dynamic API usage (headers(), cookies(), connection(), etc.)
try {
const { dynamicDetected } = await cls.runWithIsolatedDynamicScope(() =>
options.probeLayoutAt(layoutIndex),
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.

New (not flagged in prior rounds): this PR can mask Layer-3 dynamic detection.

Before this PR, options.probeLayoutAt(layoutIndex) ran the layout body directly inside runWithIsolatedDynamicScope, so any markDynamicUsage() during the probe was caught by the surrounding consumeDynamicUsage() and surfaced as dynamicDetected.

Now probeLayoutAt is wired (generated entry app-rsc-entry.ts:656) to probeAppPageLayoutWithTrackingrunLayoutProberunWithIsolatedProbeDependencies, which opens a child unified scope and resets ctx.dynamicUsageDetected = false (app-layout-param-observation.ts:126). markDynamicUsage() calls inside the probe now write to the child scope and are discarded when it exits, so the parent's consumeDynamicUsage() at line 454 returns false.

The new observationDynamic (isLayoutObservationDynamic) is the intended compensator, but it only fires on the signals captured by recordProbeDependencies (request APIs, cache tags, finite revalidate, fetch observations, param scope). It does not cover markDynamicUsage() paths that don't also record a RenderRequestApiKind or other observed dependency — e.g. "use cache: private" during prerender (cache-runtime.ts:471) calls markDynamicUsage() with no paired request-API record. A layout hitting such a path would previously classify "d" via dynamicDetected and now classifies "s".

For a static-reuse skip feature a false-static is a correctness risk (it controls which entries enter the manifest), not just diagnostics. The full suite passing means no current fixture trips it, but please confirm the observationDynamic signal set is a strict superset of every markDynamicUsage() trigger before the enable slice authorizes skips off __layoutFlags — or fold the probe's child-scope dynamicUsageDetected back into the observation so the two signals can't diverge.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good catch — fixed rather than deferred, since it's a direct consequence of this PR's probe wiring. recordProbeDependencies now peeks the probe's child-scope dynamicUsageDetected (via a new non-resetting peekDynamicUsage()) before the isolated scope is discarded, and folds it into the per-layout observation as dynamicUsageObserved. isAppLayoutObservationUnsafeForStaticReuse treats that flag as unsafe, so the observation signal is now a strict superset of every markDynamicUsage() trigger the old Layer-3 dynamicDetected path caught (including markDynamicUsage() paths with no paired request-API/observed-dependency record, e.g. "use cache: private"). The two signals can no longer diverge into a false-static. Pinned by tests/app-layout-param-observation.test.ts → "folds a probe-scoped markDynamicUsage() into the observation as unsafe for static reuse".

if (entries.length >= limits.maxEntryCount) break;
if (layoutId.length > limits.maxEntryIdLength) continue;
if (metadata.layoutFlags[layoutId] !== "s") continue;
if (!hasRetainedElement(state.elements, layoutId)) continue;
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.

Minor: the per-entry maxEntryIdLength and maxEntryCount guards run against metadata.layoutIds, but the actual byte budget (maxManifestBytes) is only enforced afterward in serializeBoundedClientReuseManifest. That's fine functionally — just noting that an entry id passing maxEntryIdLength here can still be dropped later by the byte trim, so the two limits aren't independently observable from a caller's perspective. No change needed; flagging so the enable slice doesn't assume layoutFlags[id] === "s" && id in elements implies the entry made it into the emitted header.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Acknowledged, no change needed — agreed the byte-budget trim in serializeBoundedClientReuseManifest can drop an entry that passed the per-entry maxEntryIdLength/maxEntryCount guards, so layoutFlags[id] === "s" && id in elements does not imply the entry made it into the emitted header. Noted in the PR description's risks so the enable slice doesn't assume independence between the two limits.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 2, 2026

Review posted successfully.

Summary

I reviewed PR #1717 (client reuse manifest plumbing, slice 3 of 4). The prior /bigbonk rounds already validated the threading, the byte-bounded producer, and the two ride-along behavioral changes (now documented + pinned by parity tests), so I didn't re-litigate those.

New finding (the value-add of this pass): The rewrite of probeLayoutAt to run inside runLayoutProbe's isolated dependency scope can mask Layer-3 dynamic detection.

  • Before: the layout body ran directly inside runWithIsolatedDynamicScope, so markDynamicUsage() was caught by the surrounding consumeDynamicUsage()dynamicDetected.
  • Now: probeLayoutAtprobeAppPageLayoutWithTrackingrunLayoutProberunWithIsolatedProbeDependencies opens a child unified scope and resets dynamicUsageDetected = false (app-layout-param-observation.ts:126). Dynamic-usage marks inside the probe are discarded on scope exit, so the parent's consumeDynamicUsage() returns false.
  • The compensating observationDynamic only fires on signals captured by recordProbeDependencies (request APIs, cache tags, revalidate, fetch obs, param scope). It does not cover markDynamicUsage() paths with no paired record — e.g. "use cache: private" during prerender (cache-runtime.ts:471). Such a layout would have classified "d" before and now classifies "s".

For a static-reuse skip feature a false-static is a correctness risk (it controls manifest entries / skip eligibility), not just diagnostics. Not blocking for a plumbing slice since the planner doesn't consume the flag yet, but it should be confirmed (or the child-scope dynamicUsageDetected folded back into the observation) before the enable slice authorizes skips off __layoutFlags.

I also left a minor note on the producer about the per-entry vs. byte-budget limits not being independently observable.

github run

The probe rewrite runs layout probes inside runWithIsolatedProbeDependencies,
which opens a child unified scope and resets dynamicUsageDetected. A
markDynamicUsage() during the probe that leaves no other observable trace
(e.g. "use cache: private") was therefore discarded on scope exit, so the
parent's consumeDynamicUsage() in the Layer-3 path returned false and the
layout could classify static where the pre-PR direct probe classified dynamic.

recordProbeDependencies now peeks the child scope's dynamic-usage flag before
it is reset and folds it into the per-layout observation
(dynamicUsageObserved), which isAppLayoutObservationUnsafeForStaticReuse
treats as unsafe. This keeps the observation signal a superset of the
markDynamicUsage() triggers the Layer-3 dynamicDetected path covered, so the
two signals can no longer diverge into a false-static. Adds peekDynamicUsage()
(non-resetting read) and a regression test.
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review — client reuse manifest plumbing (slice 3 of 4)

Reviewed the full transport wiring end to end. The prior /bigbonk rounds were thorough and the author addressed everything I'd otherwise have raised — so this pass confirms the resolutions and adds one new performance note.

Confirmed resolved from earlier rounds:

  • Threading (browser producer → normalizeRscRequestapp-rsc-handlerdispatchMatchedPagerenderAppPageLifecycle) is consistent and correctly typed across every hop, including the generated app-rsc-entry.
  • Dynamic-detection masking is genuinely fixed, not deferred. recordProbeDependencies now peekDynamicUsage()s the probe's child scope before it's discarded and folds it into dynamicUsageObserved (app-layout-param-observation.ts:150), and isAppLayoutObservationUnsafeForStaticReuse treats it as unsafe (:55). So the observation signal is now a strict superset of the old Layer-3 dynamicDetected path — a markDynamicUsage() with no paired record (e.g. "use cache: private") can no longer produce a false-static. Pinned by tests/app-layout-param-observation.test.ts.
  • Debug reason/flag agreement is fixed: the flag is recomputed before debugClassification and a runtime-probe reason is emitted on a flip (app-page-execution.ts:418-435). Pinned by tests/app-page-execution.test.ts.
  • revalidate=false comment corrected; merge precedence documented in createEffectiveLayoutClassifications; write-only WeakMap clearly annotated.
  • Manifest is correctly NOT a cache variant: VINEXT_CLIENT_REUSE_MANIFEST_HEADER is absent from VINEXT_RSC_VARY_HEADER (app-rsc-cache-busting.ts:32-42), pinned by the cache-busting test.

Confirmed behavioral changes (documented + pinned): the full-subtree error probe (probeReactServerSubtree recurses child server components, passing a truthy <Fragment/> as children) and the __layoutFlags output change. Both are now in the description's "Behavioral changes" section with parity assertions. I verified probeLayoutForErrors routes any child notFound()/redirect() through onLayoutError → special-error response, and non-special throws are swallowed (app-page-execution.ts:494-503), and probe-phase fetch() is deduped against the real render because runWithIsolatedProbeDependencies does not reset currentFetchDedupeEntries. Sound.

One new finding (non-blocking): the manifest producer runs on the visited-response cache hit path, wasting work. Detail inline.

Nothing blocking for a stacked plumbing slice given the stated non-goals.

const clientReuseManifestHeader =
navigationKind === "navigate"
? createClientReuseManifestHeaderFromVisibleAppState(routerStateAtNavStart)
: null;
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.

Minor (perf, not correctness): the manifest is produced before the getVisitedResponse cache check below (line 1675), so createClientReuseManifestHeaderFromVisibleAppState runs on every navigate — including navigations that resolve entirely from the visited-response cache and never send a request. The producer is not free: it iterates metadata.layoutIds, builds a cache-variant proof per static layout (buildCacheVariantWithRouteBudget), and then binary-searches the byte budget in serializeBoundedClientReuseManifest.

It also doesn't affect createRscRequestUrl — the manifest header is excluded from VINEXT_RSC_VARY_HEADER, so it never changes the cache-busting hash. So on the cache-hit path the work is pure waste.

Not blocking (it's bounded by the skip-verification entry budget), but since this is on the soft-nav hot path, consider deferring production until after the getVisitedResponse miss is confirmed, or memoizing per routerStateAtNavStart.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed. Manifest production is now deferred to just before the network fetch (gated on the same navigationKind === "navigate" condition, computed from the captured nav-start router state), so soft navigations that resolve from the visited-response cache or a prefetch no longer run createClientReuseManifestHeaderFromVisibleAppState / buildCacheVariantWithRouteBudget / the byte-budget binary search. Since the header is excluded from VINEXT_RSC_VARY_HEADER, the cache-busting URL and the emitted request header are both unchanged on the request path. All browser-entry navigation tests still pass.

// observable trace (e.g. `"use cache: private"`) would otherwise be lost
// when the child scope resets `dynamicUsageDetected`, masking the Layer-3
// `dynamicDetected` signal this probe wiring replaced.
if (peekDynamicUsage()) {
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.

Good fix — folding the child-scope dynamicUsageDetected in here (via the new non-resetting peekDynamicUsage()) closes the masking gap cleanly, and isAppLayoutObservationUnsafeForStaticReuse now treats the whole observation as a strict superset of the old Layer-3 dynamicDetected signal. Worth confirming once more before the enable slice keys skips off __layoutFlags: this peek captures the flag at recordProbeDependencies time, which for the async-probe path runs in the .then/.catch after the layout promise settles (line 242/252). Any markDynamicUsage() that fires strictly after the probe's awaited result resolves (e.g. in a not-yet-awaited child microtask the probe didn't surface) would be outside the isolated scope by then and wouldn't be peeked. The subtree probe's recursive await visit(...) should drain those, but it's the one residual edge to keep in mind for the enable slice's safety hardening.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks — agreed on the residual edge. For this plumbing slice the fold-in closes the divergence the rewrite introduced (the two signals now agree on everything the awaited probe surfaces). The remaining case you flag — a markDynamicUsage() in a microtask that fires strictly after the awaited result resolves and that the subtree probe's recursive await visit(...) didn't drain — is a pre-existing observation-completeness concern rather than something this PR regresses, and it lands on the safe side anyway: an incompletely-drained probe leaves completeness: "unknown", which isAppLayoutObservationUnsafeForStaticReuse already treats as unsafe (render-and-send). I've left a note for the enable slice's safety-hardening pass to confirm the drain is exhaustive before skips key off __layoutFlags.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 2, 2026

Review posted successfully.

Summary

I reviewed PR #1717 (client reuse manifest plumbing, slice 3 of 4) end to end. The prior 5 /bigbonk rounds were thorough and the author addressed every material finding, so this pass primarily confirmed the resolutions and added one new note.

Confirmed resolved:

  • Manifest threading is consistent and correctly typed across all hops including the generated entry.
  • The dynamic-detection masking gap is genuinely fixed (not deferred): recordProbeDependencies peeks the probe's child-scope dynamicUsageDetected via the new peekDynamicUsage() and folds it into the observation, making the signal a strict superset of the old Layer-3 path.
  • Debug reason/flag agreement fixed and pinned.
  • The two ride-along behavioral changes (full-subtree error probe + __layoutFlags flips) are documented and pinned by parity tests.
  • Verified the manifest header is correctly excluded from VINEXT_RSC_VARY_HEADER so it never becomes a cache variant.

New finding (non-blocking): The manifest producer runs before the visited-response cache check in app-browser-entry.ts, so it executes on every navigate even when the navigation resolves from cache and the header is never sent — pure wasted work (per-layout cache-proof construction + byte-budget binary search) on the soft-nav hot path. Suggested deferring production until after the cache miss.

I also left a forward-looking note on the async-probe peek timing for the enable slice's safety hardening. Nothing blocking for a stacked plumbing slice.

github run

…path

The manifest header is excluded from VINEXT_RSC_VARY_HEADER, so it never
affects the cache-busting URL or the visited-response cache lookup. Producing
it up front ran createClientReuseManifestHeaderFromVisibleAppState (iterates
visible layout ids, builds a cache-variant proof per static layout, then
binary-searches the byte budget) on every soft navigate — including ones that
resolve entirely from the visited-response cache or a prefetch and never send
a request, where the work is pure waste.

Move production to just before the network fetch, gated on the same
`navigationKind === "navigate"` condition and computed from the captured
nav-start router state, so the emitted header is unchanged on the request path
while the cache-hit/prefetch-hit hot paths skip it entirely.
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review — client reuse manifest plumbing (slice 3 of 4)

Reviewed end to end. The six prior /bigbonk rounds were thorough and every material finding was addressed, so I independently re-verified the resolutions rather than re-litigating them. They hold up:

Verified correct:

  • Threading (browser producer → normalizeRscRequestapp-rsc-handlerdispatchMatchedPagerenderAppPageLifecycle) is consistent and correctly typed across every hop, including the generated app-rsc-entry.
  • Dynamic-detection masking is genuinely fixed. runWithUnifiedStateMutation shallow-clones dynamicUsageDetected (a primitive) into the child probe scope, so probe-scoped markDynamicUsage() cannot pollute the real render scope; and recordProbeDependencies now peekDynamicUsage()s the child scope before it is discarded and folds it into dynamicUsageObserved, which isAppLayoutObservationUnsafeForStaticReuse treats as unsafe. The observation is a strict superset of the old Layer-3 dynamicDetected signal. This fix also covers the build-time-classified branch (which probes via probeLayoutForErrors without an isolated dynamic scope), since isLayoutObservationDynamic is checked after the probe completes.
  • revalidate=false docblock is now accurate. I confirmed the build-time extractNumberFromConstInitializer normalizes falseInfinity (build/report.ts:238), so classifyLayoutSegmentConfig and the new classifyLayoutSegmentConfigFromModule agree on false→static. The genuine difference is scope (request-time reads the resolved module value, so it also classifies computed/dev-mode layouts the static AST extractor can't).
  • Mounted-slots refactor is behavior-preserving. getMountedSlotIdsHeader returns null (never "") via normalizeMountedSlotsHeader, so moving to createRscRequestHeaders's !== null && !== undefined guard is equivalent.
  • Manifest is correctly NOT a cache variant. VINEXT_CLIENT_REUSE_MANIFEST_HEADER is absent from VINEXT_RSC_VARY_HEADER, pinned by the cache-busting test, so it never changes the cache-busting hash.
  • Producer is robust to missing wire fields. readAppElementsMetadata defaults missing __layoutFlags to {}, so layoutFlags[id] !== "s" safely skips.
  • The deferred manifest production (now gated behind the prefetch/optimistic/cache miss) correctly avoids wasted work on the soft-nav hot path.

Behavioral changes (full-subtree error probe + __layoutFlags flips) are documented in the description and pinned by parity tests — agreed they are more Next-accurate.

Nothing blocking for a stacked plumbing slice. Two non-blocking notes inline for the enable slice.

options.clientReuseManifestHeader !== undefined &&
options.clientReuseManifestHeader !== null
) {
headers.set(VINEXT_CLIENT_REUSE_MANIFEST_HEADER, options.clientReuseManifestHeader);
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.

Forward-looking note for the enable slice (not this PR): the manifest header is deliberately excluded from VINEXT_RSC_VARY_HEADER, so it does not split the cache key. That is correct here because the manifest is carried but never authorizes a skip. Once the enable slice uses the manifest to omit RSC payload, a skip-shortened response must not be storable in any shared cache under the same key as a full response for that URL — otherwise a client that sent a manifest could poison the entry served to a client that didn't. Worth an explicit Cache-Control: private/no-store (or a cache-key dimension) decision in the enable PR, since the vary-header exclusion means shared caches won't distinguish the two responses on their own.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Agreed and noted for the enable slice. This PR keeps the manifest header out of VINEXT_RSC_VARY_HEADER because it is carried but never authorizes a skip (no payload omission, no cache-control change). When the enable slice uses the manifest to shorten the RSC payload, a skip-shortened response must not be storable in any shared cache under the same key as a full response for that URL — so that PR will need an explicit Cache-Control: private/no-store or a cache-key dimension decision. Captured as a follow-up for the enable/SKIP-05 hardening work.

}

if (entries.length === 0) {
return null;
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.

Minor robustness note (non-blocking): serializeBoundedClientReuseManifest is only called with entries.length > 0, and its binary search starts at low = 1. If a single entry already exceeds maxManifestBytes, best stays null and the function returns null — correct. Just confirming this is intentional: an oversized single entry yields no header at all rather than a one-entry header that blows the budget. Fine as-is; flagging so the enable slice doesn't assume a non-null entries input guarantees a non-null serialized result.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirmed intentional. An oversized single entry returning null (no header) rather than a one-entry header that blows maxManifestBytes is the desired behavior — the server treats an absent manifest as render-and-send, which is the safe default. Noted that a non-null entries input does not guarantee a non-null serialized result, so the enable slice won't assume otherwise. No change needed here.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 2, 2026

Review posted successfully.

Summary

I reviewed PR #1717 (client reuse manifest plumbing, slice 3 of 4) end to end. The six prior /bigbonk rounds were exhaustive, so I independently re-verified the resolutions rather than re-litigating them — they all hold up.

Confirmed correct (verified against source, not just the diff):

  • Threading is consistent and correctly typed across all hops including the generated app-rsc-entry.
  • Dynamic-detection masking fix is sound — I traced runWithUnifiedStateMutation: it shallow-clones the primitive dynamicUsageDetected into the child probe scope (so the probe can't pollute the real render), and peekDynamicUsage() folds the child-scope flag into dynamicUsageObserved before the scope is discarded. I also confirmed the fix covers the build-time-classified branch (probed via probeLayoutForErrors without an isolated scope), since isLayoutObservationDynamic is read after the probe completes.
  • The revalidate=false docblock claim is actually correct — I initially suspected a divergence, but verified extractNumberFromConstInitializer (build/report.ts:238) normalizes falseInfinity, so build-time and request-time classifiers agree.
  • Mounted-slots refactor is behavior-preservinggetMountedSlotIdsHeader returns null never "".
  • Manifest is correctly excluded from VINEXT_RSC_VARY_HEADER, and the producer is robust to missing __layoutFlags.

Two non-blocking notes for the enable slice:

  1. A cache-poisoning consideration: because the manifest header is excluded from the vary header, the enable slice must ensure a skip-shortened response isn't stored in a shared cache under the same key as a full response (Cache-Control: private/no-store or a cache-key dimension).
  2. Confirmed serializeBoundedClientReuseManifest correctly returns null (not an oversized header) when a single entry exceeds the byte budget.

No blocking issues for a stacked plumbing slice.

github run

@james-elicx james-elicx merged commit 49d2f09 into cloudflare:main Jun 2, 2026
39 checks passed
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