Skip to content

feat(skip): add layout safety observation foundations#1672

Open
NathanDrake2406 wants to merge 7 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/726-skip-01-observation
Open

feat(skip): add layout safety observation foundations#1672
NathanDrake2406 wants to merge 7 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/726-skip-01-observation

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

What this changes

Adds the per-layout observation primitives that future #726-SKIP slices will use to decide whether a retained static layout can be reused. Nothing reads these observations yet, so request behavior is unchanged.

Why

Skip transport is only correct when the retained client layout is indistinguishable from the target server layout for this request. That requires observing every signal a layout depends on (params, request APIs, cacheLife, fetch tags, cacheable/dynamic fetches, unstable_cache, finite revalidate) before any reuse decision is made. This PR contributes the observation surface in isolation so it can be reviewed without the planner, manifest, or encoder churn from later slices.

Approach

  • app-layout-param-observation: per-layout tracker for completeness, observed param keys, structural param scope, finite revalidate, request APIs, cacheLife, cache tags, cacheable/dynamic fetches, and unstable_cache usage.
  • app-page-probe: bounded React server subtree probe that walks layout-returned children including memo, forwardRef, and React.lazy, enforces depth/node limits, and refuses single-use iterable children.
  • Shims: thenable-params observer for sync read, await, destructure, and enumeration. cache, fetch-cache, and unified-request-context record unstable_cache, cacheable fetch, and request-scoped observation state.
  • app-page-params: adds resolveAppPageSegmentParamScopeKeys so empty optional catch-all targets are not treated as param-free downstream.
  • Non-goals: no planner, no manifest, no dispatch wiring, no encoder change. Skip transport stays off.

Validation

  • vp check: format, lint, type check passed.
  • vp test run tests/app-layout-param-observation.test.ts tests/app-page-probe.test.ts tests/app-page-params.test.ts tests/thenable-params.test.ts: 4 files, 37 tests passed.

Risks / follow-ups

Refs #726

Copilot AI review requested due to automatic review settings May 28, 2026 16:53
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 28, 2026

Open in StackBlitz

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

commit: 159923c

Introduce the per-layout observation primitives that later slices need
to decide whether retained static layouts can be reused. This slice
adds the machinery only; nothing reads the observations yet, so
behavior is unchanged.

- app-layout-param-observation: per-layout completeness, param keys,
  structural scope, finite revalidate, request API, cacheLife, cache
  tag, cacheable fetch, dynamic fetch, and unstable_cache observation
  tracker.
- app-page-probe: bounded React server subtree probe that walks
  layout-returned children including memo, forwardRef, and React.lazy
  wrappers, enforces depth and node limits, and refuses single-use
  iterable children.
- shims: thenable params observer for sync, await, destructure, and
  enumeration. Cache, fetch, and unified request context record
  unstable_cache, cacheable fetch, and request-scoped observation
  state.
- app-page-params: structural segment-param scope so empty optional
  catch-all targets are not treated as param-free downstream.

No skip transport behavior. Later slices wire the planner, manifest,
and encoder onto these observations.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-skip-01-observation branch from 26ce54b to 8f61b49 Compare May 28, 2026 17:01
@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: #726-SKIP observation foundations

Clean slice — the observation surface is well-isolated, the test coverage is good, and the PR description is clear about what's in scope and what isn't. A few things worth addressing before merge:

Issues

  1. currentFetchSoftTags not isolated in probe scoperunWithIsolatedProbeDependencies resets currentRequestTags, cacheableFetchUrls, dynamicFetchUrls, etc. but does not reset currentFetchSoftTags. Soft tags are path-derived implicit tags set via setPathTags() for fetch cache reads. If a parent scope has soft tags set, they leak into the probe and affect cacheable fetch behavior inside recordProbeDependencies. Compare with runWithFetchCache which does reset currentFetchSoftTags. This is probably harmless today since the probe doesn't actually run real fetches, but it's an inconsistency that could bite when real fetch integration lands.

  2. stringifyCacheLifeSnapshot does unnecessary work — The function serializes to JSON just to check non-nullness. Since recordProbeDependencies only needs a boolean (cacheLifeObserved), the JSON.stringify is wasted. _peekRequestScopedCacheLife() !== null would suffice.

  3. isPromiseLike is now duplicated in 3 filesapp-layout-param-observation.ts, app-page-probe.ts, and app-page-execution.ts all have identical copies. Worth extracting to a shared utility. Not blocking for this PR, but worth tracking.

Observation

  1. runLayoutProbe error path doesn't mark complete — On rejection, recordProbeDependencies runs but markProbeComplete does not. This means the observation stays completeness: "unknown" on error. The JSDoc on probeReactServerSubtree says "probe failures only make static-layout skip fall back to render-and-send", which is consistent. Just confirming this is intentional — a comment in the rejection handler would help future readers understand this is deliberate.

ctx.renderRequestApiUsage = new Set();
ctx.requestScopedCacheLife = null;
ctx.unstableCacheObservations = new Map();
}, probe);
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.

currentFetchSoftTags is not reset here, unlike runWithFetchCache which resets it alongside currentRequestTags and dynamicFetchUrls. This means soft tags from an outer scope leak into the isolated probe. Probably harmless now since probes don't issue real fetches, but it's an inconsistency with the other reset sites.

Suggested change
}, probe);
return runWithUnifiedStateMutation((ctx) => {
ctx.cacheableFetchUrls = new Set();
ctx.currentFetchSoftTags = [];
ctx.currentRequestTags = [];
ctx.dynamicFetchUrls = new Set();
ctx.dynamicUsageDetected = false;
ctx.renderRequestApiUsage = new Set();
ctx.requestScopedCacheLife = null;
ctx.unstableCacheObservations = new Map();
}, probe);

Comment on lines +117 to +119
const stringifyCacheLifeSnapshot = (): string | null => {
const cacheLife = _peekRequestScopedCacheLife();
return cacheLife === null ? null : JSON.stringify(cacheLife);
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 serializes to JSON just to check for non-null. Since recordProbeDependencies only sets observation.cacheLifeObserved = true, the JSON round-trip is wasted work.

Suggested change
const stringifyCacheLifeSnapshot = (): string | null => {
const cacheLife = _peekRequestScopedCacheLife();
return cacheLife === null ? null : JSON.stringify(cacheLife);
const hasCacheLifeSnapshot = (): boolean => {
return _peekRequestScopedCacheLife() !== null;
};

Then update the call site at line 139 to if (hasCacheLifeSnapshot()) {.

},
(error: unknown) => {
recordProbeDependencies(layoutId);
throw error;
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.

Intentional that markProbeComplete is skipped on error (so the observation stays completeness: "unknown" and the planner falls back to render-and-send). A brief comment here would help future readers understand this is deliberate rather than a missed cleanup.

Suggested change
throw error;
(error: unknown) => {
// Intentionally do NOT markProbeComplete — an incomplete probe
// keeps completeness: "unknown" so the planner falls back to
// full render-and-send instead of reusing a stale layout.
recordProbeDependencies(layoutId);
throw error;
},

: revalidateSeconds === false
? false
: null,
tagCount: tags.length,
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.

Nit: recordUnstableCacheObservation runs on every unstable_cache call, including cache hits. The observation is keyed by cacheKey (which includes args), so two calls with different args create separate observations even for the same wrapped function. Is this the intended granularity? The test only covers same-args hit/miss, not different-args calls to the same cached function.

Not blocking — just want to confirm the dedup-by-key-hash-including-args is deliberate for the planner's purposes.

Comment on lines +110 to +117
if (isPromiseContinuation(prop)) {
const value = Reflect.get(target, prop, receiver);
if (typeof value !== "function") return value;
return (...args: unknown[]) => {
observeAllParamKeys(observer, plain);
return Reflect.apply(value, target, args);
};
}
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 then/catch/finally interception wraps the method to call observeAllParamKeys before forwarding. This means chaining .then().then() on the same thenable records an all-keys observation on the first .then() call. The second .then() is on a plain Promise (not the Proxy), so it doesn't observe again. This is fine, but worth noting that observation happens eagerly at chain-construction time, not at resolution time.

throw new AppPageSubtreeProbeLimitError("App page layout subtree probe exceeded max depth");
}

if (isUnknownFunction(type)) {
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.

When type is a function (server component), this calls type(props) to probe it. If the component throws synchronously (not a Promise rejection), the error propagates up through visitprobeReactServerSubtree and becomes a rejection of the outer promise. This is fine for the current use case (probe failures fall back gracefully), but be aware that a component with side effects that throws will have already executed those side effects before the error is caught.

if (value == null || typeof value === "boolean" || typeof value === "number") return;
if (typeof value === "string" || typeof value === "bigint") return;
if (isPromiseLike(value)) {
await visit(await value, depth);
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.

Awaiting a promise at the same depth (not depth + 1) means a chain of nested promises doesn't increment the depth counter. Only the resolved value's structural nesting counts. This seems intentional — async server components that return promises shouldn't eat up depth budget — but it's a subtle choice worth a comment.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 28, 2026

Review posted. Here's a summary of the feedback:

Two actionable suggestions:

  1. Missing currentFetchSoftTags reset in runWithIsolatedProbeDependencies — every other fetch-state field is reset but soft tags leak from the parent scope. Suggestion provided to add the reset.
  2. Unnecessary JSON.stringify in stringifyCacheLifeSnapshot — it serializes to JSON just to check non-null. A simple !== null check suffices since only a boolean is needed.

One documentation suggestion:
3. Add a comment on the error-path intentional skip of markProbeComplete — the rejection handler in runLayoutProbe records dependencies but deliberately doesn't mark the probe complete, keeping completeness: "unknown". A comment explaining this is intentional would help future readers.

Two informational notes:
4. Confirmed the unstable_cache observation granularity (keyed by args-inclusive hash) and asked whether different-args calls to the same cached function should produce separate observations.
5. Noted the isPromiseLike utility is now duplicated in 3 files — worth extracting eventually but not blocking.

github run

- Add currentFetchSoftTags reset in runWithIsolatedProbeDependencies to prevent soft tag leak from parent scope (matches runWithFetchCache isolation)
- Replace unnecessary JSON.stringify in cacheLife check with direct null comparison
- Add comment documenting intentional omission of markProbeComplete on probe errors (keeps completeness as unknown, safe fallback to render-and-send)
…rst await

The patched fetch deferred observation and tag collection past
await buildFetchCacheKey(). If a layout probe started a cacheable
fetch and returned synchronously without awaiting it, the probe
snapshot would see cacheableFetchCount: 0 -- a false proof that
could let the skip planner reuse a stale retained layout.

Move observation and tag recording before the first await in the
cacheable branch. If key generation later fails and the fetch falls
back to dynamic, recording both is conservative (false unsafe costs
performance, not correctness).

Add an integration test that exercises the real patched fetch shim
without mocks to verify synchronous observation capture.
Do not restore globalThis.fetch in test cleanup. ensureFetchPatch
sets a global _PATCH_KEY guard; restoring fetch without clearing
that marker makes later calls skip reinstallation silently.

Catch the background fetch promise inside the probe so the patched
fetch continuation (cache lookup, network fetch) cannot produce an
unhandled rejection after the synchronous assertions complete.
@NathanDrake2406 NathanDrake2406 marked this pull request as draft May 29, 2026 07:54
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 29, 2026 07:54
@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: #726-SKIP observation foundations (slice 1/4)

The previous review's three actionable suggestions (soft-tag reset, JSON.stringify removal, error-path comment) have all been addressed. The code is clean and well-structured.

I've gone through all 12 changed files. The observation surface is genuinely isolated — nothing reads the observations yet, and the isAppLayoutObservationUnsafeForStaticReuse function is a clear readability win for future planner consumers. A few notes below, none blocking.

Notes (non-blocking)

  1. fetch-cache tag recording was moved before await buildFetchCacheKey() — this is a subtle but correct behavioral change. Tags are now recorded synchronously so probes can snapshot them before the fetch settles. The catch fallback path at line 939 still records recordDynamicFetchObservation, which means a fetch that fails key generation gets recorded as both cacheable and dynamic. The comment at lines 907-913 explicitly acknowledges this as conservative (false-unsafe costs performance, not correctness). Good.

  2. probeReactServerSubtree sequential traversal — the visit function awaits each child serially (for...of + await). This is deliberate given the probe's purpose (observing dependencies in deterministic order for snapshot stability), but worth noting that deeply nested trees with many async server components will be probe-latency-bound. Not a problem at the default limits (32 depth, 1000 nodes).

  3. resolveReactLazyType correctly handles the React lazy throw-promise pattern_init(payload) throws a promise on first call, then returns the resolved module on second call after the promise settles. The two-call pattern matches React's internal lazyInitializer behavior.

  4. isIterable check excludes strings explicitly — good, since strings are iterable but should be treated as leaf values in the React tree, not iterated character-by-character.

if (value == null || typeof value === "boolean" || typeof value === "number") return;
if (typeof value === "string" || typeof value === "bigint") return;
if (isPromiseLike(value)) {
await visit(await value, depth);
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.

Informational: awaiting a promise at the same depth (not depth + 1) means a chain of nested promises doesn't consume depth budget — only the resolved value's structural nesting counts. This is the right call for async server components (their async-ness shouldn't eat depth budget), but a short comment would help future readers understand the choice.

}

if (isUnknownFunction(type)) {
await visit(type(props), depth + 1);
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.

Nit: when type is a function (server component), type(props) executes it synchronously. If it throws synchronously (not a Promise rejection), the error propagates through visitprobeReactServerSubtree and becomes a rejection of the outer promise. This is fine — probe failures fall back gracefully — but a component with side effects that throws will have already executed those side effects. Just confirming this is acceptable since the JSDoc says "probe failures only make static-layout skip fall back to render-and-send".

};
}

if (typeof prop === "string" && !isWellKnownProperty(prop)) {
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.

Nit: the observation fires for any non-well-known string property access, even for properties that don't exist in plain. For example, params.nonExistent would observe ["nonExistent"] even though it returns undefined. This is actually correct for the skip planner's purposes (the layout tried to read a param, so it depends on the param set), but it's a subtlety worth being aware of.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 29, 2026

Review posted and approved. Here's a summary:

Verdict: Approved

All three actionable suggestions from the previous review have been addressed (soft-tag reset, JSON.stringify removal, error-path comment). The observation surface is well-isolated, test coverage is solid (37 tests across 4 files), and the PR correctly scopes itself to observation primitives without wiring anything into the request lifecycle.

Three non-blocking informational notes were left inline:

  1. Promise depth handling in the subtree probe (awaiting at same depth is intentional but worth a comment)
  2. Server component probe execution and side-effect awareness
  3. Thenable params observing non-existent property access (correct for skip planner purposes)

github run

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