fix(skip): centralize final static-layout skip rejection - #1722
Conversation
commit: |
The enable slice omits a static layout from RSC transport once it passes the cache cross-check. The cross-check proves artifact and payload identity, but it does not, on its own, assert that the layout's render was free of per-request behaviour. A layout that reads params, calls a request API, sets a finite revalidate, opens a cacheLife scope, reads unstable_cache, or issues cache-tagged or dynamic fetches must never be reused across a sibling-route navigation. This adds an explicit observation gate at the skip decision point. Before a static layout is omitted, its per-layout observation is checked: an incomplete probe, observed param keys, structural param scope, request APIs, finite revalidate, cacheLife, unstable_cache, cache tags, cacheable fetches, or dynamic fetches each reject the entry with a specific SKIP_LAYOUT_* code and fall the layout back to render-and-send. Scope note: the enable base already classifies most runtime-unsafe layouts as dynamic (so they never enter the static skip set) and the cache cross-check rejects payload-divergent reuse. This gate is a deliberate last line of defence at the omission boundary, with diagnostic rejection codes. The tests assert the end-to-end contract (unsafe layouts are never omitted); they pass on the enable base alone, so this PR is kept as a draft pending rebase once the enable PR merges.
8dcd1e3 to
b64d32b
Compare
|
/bigbonk review |
There was a problem hiding this comment.
Review: centralize final static-layout skip rejection
LGTM overall. This is a clean, well-tested refactor. I verified behavioral parity, types/lint, and the affected tests locally.
What I checked
- Behavioral parity preserved. The 11 conditions in the old
isAppLayoutObservationUnsafeForStaticReuse()boolean chain map 1:1 ontoSTATIC_LAYOUT_OBSERVATION_SKIP_RULES(incomplete, paramScope, observed, dynamicUsage, requestApis, finiteRevalidate, cacheLife, unstableCaches, cacheTags, cacheableFetch, dynamicFetch). The delegating boolean now returnsgetStaticLayoutObservationSkipRejection(observation) !== null, which is equivalent. - Rejection shape matches the existing seam. The manually-constructed object in
rejectStaticLayoutObservation()matchesSkipCacheCrossCheckRejected(kind: "rejected",rejection: { code, entryId, fields },skipDisposition), consistent withrejectSkipCacheCrossCheck()inskip-cache-proof.ts. - Defense-in-depth rationale is real. The primary path classifies unsafe layouts as
"d"viaisLayoutObservationDynamicinapp-page-execution.ts:422/454, so they never enter the"s"skip set. The new render-boundary check is a redundant last invariant, exactly as the PR describes. - Test design is good. The new regression test stubs
isLayoutObservationDynamic: () => falseto simulate classification drift while the probe recordsdynamicUsageObserved: true, proving the transport-boundary gate independently rejects. The table-driven helper test asserts a distinct code per unsafe shape. vp test run tests/app-layout-param-observation.test.ts tests/app-page-render.test.ts→ 45 passed.vp checkon the three changed source files → clean (format + lint + types).- New
SKIP_LAYOUT_DYNAMIC_USAGE_OBSERVEDcode is added to the union and is now actually produced.
Minor / non-blocking
- The PR description lists
tests/app-page-dispatch.test.tsunder Tests/Validation ("drops the broad contract-only dispatch pile"), but that file is not in the changeset. Either the dispatch test edits were dropped from this PR or the description is stale — worth reconciling so the PR body matches reality. - See the inline note on the unreachable-looking
!observationbranch.
| ): ReturnType<typeof crossCheckClientReuseManifestEntryWithCache> | null { | ||
| const observation = layoutParamAccess?.getLayoutObservation(entry.id); | ||
| if (!observation) { | ||
| return rejectStaticLayoutObservation(entry, "SKIP_LAYOUT_PARAMS_OBSERVATION_INCOMPLETE"); |
There was a problem hiding this comment.
Minor: getLayoutObservation is typed to always return an AppLayoutParamAccessObservation (never null/undefined) — for an unknown layout it returns a default object with completeness: "unknown", which would itself yield SKIP_LAYOUT_PARAMS_OBSERVATION_INCOMPLETE. So this !observation branch only fires when layoutParamAccess itself is undefined (the optional-chain short-circuit). That's a legitimate case (no tracker → reject incomplete), but the dual meaning is a little subtle. Consider a clarifying comment that this guards the missing-tracker case rather than a missing observation, since the tracker never returns a missing observation.
| ["SKIP_LAYOUT_CACHE_TAGS_OBSERVED", (observation) => observation.cacheTags.length > 0], | ||
| ["SKIP_LAYOUT_CACHEABLE_FETCHES_OBSERVED", (observation) => observation.cacheableFetchCount > 0], | ||
| ["SKIP_LAYOUT_DYNAMIC_FETCHES_OBSERVED", (observation) => observation.dynamicFetchCount > 0], | ||
| ] satisfies readonly StaticLayoutObservationSkipRule[]; |
There was a problem hiding this comment.
Nice that the rule codes are constrained to Extract<ClientReuseManifestRejectionCode, "SKIP_LAYOUT_${string}"> via the satisfies clause — adding a typo'd code or one outside the SKIP_LAYOUT_ family will fail to compile. One gap: there's no compile-time guarantee that every observation field that can make a layout unsafe has a corresponding rule (the list is hand-maintained). If a new unsafe field is added to AppLayoutParamAccessObservation later, nothing forces a matching rule here. Not actionable for this PR, but worth a follow-up note or test if the observation shape grows.
|
Review posted. Summary of my findings on PR #1722: Verdict: LGTM — clean, well-tested defense-in-depth refactor. Verified:
Minor / non-blocking notes posted:
|
|
/bigbonk re-review |
There was a problem hiding this comment.
Re-review: centralize final static-layout skip rejection
LGTM. Re-reviewed at 48e5ca3. The two minor notes from the prior /bigbonk review have been addressed and the PR is in good shape.
Verified at this commit
- Behavioral parity preserved. The 11 conditions in the old
isAppLayoutObservationUnsafeForStaticReuse()boolean chain map 1:1 ontoSTATIC_LAYOUT_OBSERVATION_SKIP_RULES. The boolean now correctly delegates viagetStaticLayoutObservationSkipRejection(observation) !== null(app-layout-param-observation.ts:122-126). - Type-safe rule codes.
StaticLayoutObservationSkipCode = Extract<ClientReuseManifestRejectionCode, "SKIP_LAYOUT_${string}">plus thesatisfiesclause means a typo'd or out-of-family code fails to compile. - Rejection shape matches the existing seam.
rejectStaticLayoutObservation()produces{ kind: "rejected", rejection: { code, entryId, fields }, skipDisposition }, consistent withcrossCheckClientReuseManifestEntryWithCache's return type andClientReuseManifestEntryRejection(client-reuse-manifest.ts:145-148). - Defense-in-depth is genuine. The primary classification path already marks unsafe layouts
"d"so they never enter the"s"skip set; the new render-boundary check at app-page-render.ts:396-402 is a redundant last invariant, as described. !observationcomment added. Commit48e5ca3adds the clarifying comment (app-page-render.ts:309-312) that the prior review requested — it correctly documents that the optional-chain guards the missing-tracker case, not a missing observation.- PR description reconciled. The Tests/Validation section now lists only
app-layout-param-observation.test.tsandapp-page-render.test.ts, matching the changeset (the staleapp-page-dispatch.test.tsreference is gone). - New
SKIP_LAYOUT_DYNAMIC_USAGE_OBSERVEDis added to the union and is now actually produced and asserted.
Local validation
vp test run tests/app-layout-param-observation.test.ts tests/app-page-render.test.ts-> 45 passed.vp checkon the three changed source files -> clean (format + lint + types).
Non-blocking follow-up (unchanged from prior review)
- There is still no compile-time guarantee that every unsafe field on
AppLayoutParamAccessObservationhas a matching rule — the list is hand-maintained. If the observation shape grows, a new unsafe field could silently lack a rule. Worth a follow-up exhaustiveness test if the shape expands, but not actionable here.
|
Re-review posted on PR #1722. Verdict: LGTM (Approved) — defense-in-depth refactor is clean, well-tested, and the prior review's two minor notes have been addressed. What I verified at
One non-blocking follow-up carried over: no compile-time exhaustiveness guarantee that every unsafe observation field has a matching rule. Not actionable for this PR. |
What this changes
getStaticLayoutObservationSkipRejection()isAppLayoutObservationUnsafeForStaticReuse()delegate to that same helperSKIP_LAYOUT_DYNAMIC_USAGE_OBSERVEDand includesdynamicUsageObservedin the rejection trace fieldsWhy
#1437and#1717already provide the primary correctness path: runtime-unsafe layouts are classified dynamic before they enter the static skip set, and the cache cross-check still protects against payload-divergent reuse.This PR keeps the last transport-boundary invariant. Immediately before omitting a layout from the RSC payload, the server re-checks that the observation is still safe. That keeps the omission boundary conservative even if classification logic later drifts or misses a signal.
Tests
dynamicUsageObserveddynamicUsageObservedValidation
vp test run tests/app-layout-param-observation.test.ts tests/app-page-render.test.tsvp check packages/vinext/src/server/app-layout-param-observation.ts packages/vinext/src/server/app-page-render.ts packages/vinext/src/server/client-reuse-manifest.ts tests/app-layout-param-observation.test.ts tests/app-page-render.test.ts