From 8f61b49f6a09eea929ff91a3ba20927ff4c65c05 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 29 May 2026 02:51:16 +1000 Subject: [PATCH 1/6] feat(skip): add layout safety observation foundations 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. --- knip.ts | 3 + .../server/app-layout-param-observation.ts | 238 ++++++++++++++++++ packages/vinext/src/server/app-page-params.ts | 22 ++ packages/vinext/src/server/app-page-probe.ts | 207 +++++++++++++++ packages/vinext/src/shims/cache.ts | 34 +++ packages/vinext/src/shims/fetch-cache.ts | 14 ++ packages/vinext/src/shims/thenable-params.ts | 59 ++++- .../src/shims/unified-request-context.ts | 5 +- tests/app-layout-param-observation.test.ts | 101 ++++++++ tests/app-page-params.test.ts | 12 +- tests/app-page-probe.test.ts | 109 ++++++++ tests/thenable-params.test.ts | 48 ++++ 12 files changed, 849 insertions(+), 3 deletions(-) create mode 100644 packages/vinext/src/server/app-layout-param-observation.ts create mode 100644 tests/app-layout-param-observation.test.ts diff --git a/knip.ts b/knip.ts index d9b2a9c8e..ea89f15ad 100644 --- a/knip.ts +++ b/knip.ts @@ -74,6 +74,9 @@ export default { // #726-CACHE-01/04 defines the disabled proof boundary before runtime // observation recording or cache reuse is wired in later slices. "src/server/cache-proof.ts", + // #726-SKIP layout-safety observation foundation. Consumed by the + // planner, dispatch wiring, and render in later slices. + "src/server/app-layout-param-observation.ts", ], project: ["src/**/*.{ts,tsx}"], }, diff --git a/packages/vinext/src/server/app-layout-param-observation.ts b/packages/vinext/src/server/app-layout-param-observation.ts new file mode 100644 index 000000000..6ac85f7fc --- /dev/null +++ b/packages/vinext/src/server/app-layout-param-observation.ts @@ -0,0 +1,238 @@ +import type { ThenableParamsObserver } from "vinext/shims/thenable-params"; +import { + _peekRequestScopedCacheLife, + _peekUnstableCacheObservations, + type UnstableCacheObservation, +} from "vinext/shims/cache"; +import { + getCollectedFetchTags, + peekCacheableFetchObservations, + peekDynamicFetchObservations, +} from "vinext/shims/fetch-cache"; +import { peekRenderRequestApiUsage } from "vinext/shims/headers"; +import { + isInsideUnifiedScope, + runWithUnifiedStateMutation, +} from "vinext/shims/unified-request-context"; +import type { RenderRequestApiKind } from "./cache-proof.js"; + +export type AppLayoutParamAccessObservation = Readonly<{ + cacheLifeObserved: boolean; + cacheTags: readonly string[]; + cacheableFetchCount: number; + completeness: "complete" | "unknown"; + dynamicFetchCount: number; + finiteRevalidateSeconds: number | null; + keys: readonly string[]; + observed: boolean; + paramScopeKeys: readonly string[]; + requestApis: readonly RenderRequestApiKind[]; + unstableCaches: readonly UnstableCacheObservation[]; +}>; + +export type AppLayoutParamAccessTracker = Readonly<{ + createThenableParamsObserver: (layoutId: string) => ThenableParamsObserver; + getLayoutObservation: (layoutId: string) => AppLayoutParamAccessObservation; + recordLayoutFiniteRevalidate: (layoutId: string, revalidateSeconds: number) => void; + recordLayoutParamScope: (layoutId: string, paramScopeKeys: readonly string[]) => void; + runLayoutProbe: (layoutId: string, probe: () => unknown) => unknown; +}>; + +export function isAppLayoutObservationUnsafeForStaticReuse( + observation: AppLayoutParamAccessObservation, +): boolean { + return ( + observation.completeness !== "complete" || + observation.paramScopeKeys.length > 0 || + observation.observed || + observation.requestApis.length > 0 || + observation.finiteRevalidateSeconds !== null || + observation.cacheLifeObserved || + observation.cacheTags.length > 0 || + observation.cacheableFetchCount > 0 || + observation.dynamicFetchCount > 0 || + observation.unstableCaches.length > 0 + ); +} + +type MutableLayoutParamAccessObservation = { + cacheLifeObserved: boolean; + cacheTags: Set; + cacheableFetches: Set; + dynamicFetches: Set; + finiteRevalidateSeconds: number | null; + keys: Set; + observed: boolean; + paramScopeKeys: Set; + probeComplete: boolean; + requestApis: Set; + unstableCaches: Map; +}; + +function isPromiseLike(value: unknown): value is PromiseLike { + return Boolean( + value && + (typeof value === "object" || typeof value === "function") && + "then" in value && + typeof value.then === "function", + ); +} + +export function createAppLayoutParamAccessTracker(): AppLayoutParamAccessTracker { + const observations = new Map(); + + const ensureObservation = (layoutId: string): MutableLayoutParamAccessObservation => { + const existing = observations.get(layoutId); + if (existing) return existing; + + const created: MutableLayoutParamAccessObservation = { + cacheLifeObserved: false, + cacheTags: new Set(), + cacheableFetches: new Set(), + dynamicFetches: new Set(), + finiteRevalidateSeconds: null, + keys: new Set(), + observed: false, + paramScopeKeys: new Set(), + probeComplete: false, + requestApis: new Set(), + unstableCaches: new Map(), + }; + observations.set(layoutId, created); + return created; + }; + + const markObserved = (layoutId: string, keys: readonly string[]) => { + const observation = ensureObservation(layoutId); + observation.observed = true; + for (const key of keys) { + observation.keys.add(key); + } + }; + + const markProbeComplete = (layoutId: string) => { + ensureObservation(layoutId).probeComplete = true; + }; + + const stringifyCacheLifeSnapshot = (): string | null => { + const cacheLife = _peekRequestScopedCacheLife(); + return cacheLife === null ? null : JSON.stringify(cacheLife); + }; + + const runWithIsolatedProbeDependencies = (probe: () => unknown): unknown => { + if (!isInsideUnifiedScope()) { + return probe(); + } + return runWithUnifiedStateMutation((ctx) => { + ctx.cacheableFetchUrls = new Set(); + ctx.currentRequestTags = []; + ctx.dynamicFetchUrls = new Set(); + ctx.dynamicUsageDetected = false; + ctx.renderRequestApiUsage = new Set(); + ctx.requestScopedCacheLife = null; + ctx.unstableCacheObservations = new Map(); + }, probe); + }; + + const recordProbeDependencies = (layoutId: string) => { + const observation = ensureObservation(layoutId); + if (stringifyCacheLifeSnapshot() !== null) { + observation.cacheLifeObserved = true; + } + for (const tag of getCollectedFetchTags()) { + observation.cacheTags.add(tag); + } + for (const url of peekCacheableFetchObservations()) { + observation.cacheableFetches.add(url); + } + for (const url of peekDynamicFetchObservations()) { + observation.dynamicFetches.add(url); + } + for (const requestApi of peekRenderRequestApiUsage()) { + observation.requestApis.add(requestApi); + } + for (const unstableCache of _peekUnstableCacheObservations()) { + observation.unstableCaches.set(unstableCache.keyHash, unstableCache); + } + }; + + return { + createThenableParamsObserver(layoutId) { + return { + observeParamAccess(keys) { + markObserved(layoutId, keys); + }, + }; + }, + getLayoutObservation(layoutId) { + const observation = observations.get(layoutId); + if (!observation) { + return { + cacheLifeObserved: false, + cacheTags: [], + cacheableFetchCount: 0, + completeness: "unknown", + dynamicFetchCount: 0, + finiteRevalidateSeconds: null, + keys: [], + observed: false, + paramScopeKeys: [], + requestApis: [], + unstableCaches: [], + }; + } + + return { + cacheLifeObserved: observation.cacheLifeObserved, + cacheTags: [...observation.cacheTags].sort(), + cacheableFetchCount: observation.cacheableFetches.size, + completeness: observation.probeComplete ? "complete" : "unknown", + dynamicFetchCount: observation.dynamicFetches.size, + finiteRevalidateSeconds: observation.finiteRevalidateSeconds, + keys: [...observation.keys].sort(), + observed: observation.observed, + paramScopeKeys: [...observation.paramScopeKeys].sort(), + requestApis: [...observation.requestApis].sort(), + unstableCaches: [...observation.unstableCaches.values()].sort((a, b) => + a.keyHash.localeCompare(b.keyHash), + ), + }; + }, + recordLayoutFiniteRevalidate(layoutId, revalidateSeconds) { + if (!Number.isFinite(revalidateSeconds) || revalidateSeconds <= 0) return; + const observation = ensureObservation(layoutId); + observation.finiteRevalidateSeconds = + observation.finiteRevalidateSeconds === null + ? revalidateSeconds + : Math.min(observation.finiteRevalidateSeconds, revalidateSeconds); + }, + recordLayoutParamScope(layoutId, paramScopeKeys) { + const observation = ensureObservation(layoutId); + for (const key of paramScopeKeys) { + observation.paramScopeKeys.add(key); + } + }, + runLayoutProbe(layoutId, probe) { + return runWithIsolatedProbeDependencies(() => { + const result = probe(); + if (!isPromiseLike(result)) { + recordProbeDependencies(layoutId); + markProbeComplete(layoutId); + return result; + } + + return Promise.resolve(result).then( + (resolved) => { + recordProbeDependencies(layoutId); + markProbeComplete(layoutId); + return resolved; + }, + (error: unknown) => { + recordProbeDependencies(layoutId); + throw error; + }, + ); + }); + }, + }; +} diff --git a/packages/vinext/src/server/app-page-params.ts b/packages/vinext/src/server/app-page-params.ts index 21efe1cac..b7b766bb4 100644 --- a/packages/vinext/src/server/app-page-params.ts +++ b/packages/vinext/src/server/app-page-params.ts @@ -25,6 +25,28 @@ function isEmptyOptionalCatchAll(segment: string, paramValue: string | string[]) return segment.startsWith("[[...") && Array.isArray(paramValue) && paramValue.length === 0; } +export function resolveAppPageSegmentParamScopeKeys( + routeSegments: readonly string[] | null | undefined, + treePosition: number, +): readonly string[] { + const paramNames: string[] = []; + const seen = new Set(); + const segments = routeSegments ?? []; + const end = Math.min(Math.max(treePosition, 0), segments.length); + + for (let index = 0; index < end; index++) { + const paramName = getAppPageSegmentParamName(segments[index]); + if (!paramName || seen.has(paramName)) { + continue; + } + + seen.add(paramName); + paramNames.push(paramName); + } + + return paramNames; +} + export function resolveAppPageSegmentParams( routeSegments: readonly string[] | null | undefined, treePosition: number, diff --git a/packages/vinext/src/server/app-page-probe.ts b/packages/vinext/src/server/app-page-probe.ts index 260df574f..37e732c20 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -1,3 +1,4 @@ +import { Fragment, isValidElement, type ReactElement, type ReactNode } from "react"; import { makeThenableParams } from "vinext/shims/thenable-params"; import { collectAppPageSearchParams } from "./app-page-head.js"; import { @@ -8,6 +9,212 @@ import { type LayoutFlags, } from "./app-page-execution.js"; +const DEFAULT_SUBTREE_PROBE_MAX_DEPTH = 32; +const DEFAULT_SUBTREE_PROBE_MAX_NODES = 1000; +const REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref"); +const REACT_LAZY_TYPE = Symbol.for("react.lazy"); +const REACT_MEMO_TYPE = Symbol.for("react.memo"); + +type ProbeReactServerSubtreeOptions = Readonly<{ + maxDepth?: number; + maxNodes?: number; +}>; + +type ProbeReactElementProps = Readonly<{ + children?: ReactNode; +}>; + +type UnknownFunction = (...args: unknown[]) => unknown; + +type ReactMemoType = Readonly<{ + innerType: unknown; +}>; + +type ReactLazyType = Readonly<{ + init: UnknownFunction; + payload: unknown; +}>; + +class AppPageSubtreeProbeLimitError extends Error { + constructor(message: string) { + super(message); + this.name = "AppPageSubtreeProbeLimitError"; + } +} + +class AppPageSubtreeProbeUnsupportedIterableError extends Error { + constructor() { + super("App page layout subtree probe cannot safely inspect iterable children"); + this.name = "AppPageSubtreeProbeUnsupportedIterableError"; + } +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return Boolean( + value && + (typeof value === "object" || typeof value === "function") && + "then" in value && + typeof value.then === "function", + ); +} + +function isIterable(value: unknown): value is Iterable { + return Boolean( + value && + typeof value !== "string" && + typeof value === "object" && + Symbol.iterator in value && + typeof value[Symbol.iterator] === "function", + ); +} + +function isProbeReactElement(value: unknown): value is ReactElement { + return isValidElement(value); +} + +function isObjectLike(value: unknown): value is object { + return (typeof value === "object" || typeof value === "function") && value !== null; +} + +function isUnknownFunction(value: unknown): value is UnknownFunction { + return typeof value === "function"; +} + +function readReactMemoType(value: unknown): ReactMemoType | null { + if (!isObjectLike(value) || Reflect.get(value, "$$typeof") !== REACT_MEMO_TYPE) { + return null; + } + return { innerType: Reflect.get(value, "type") }; +} + +function readReactLazyType(value: unknown): ReactLazyType | null { + if (!isObjectLike(value) || Reflect.get(value, "$$typeof") !== REACT_LAZY_TYPE) { + return null; + } + const init = Reflect.get(value, "_init"); + if (!isUnknownFunction(init)) { + return null; + } + return { init, payload: Reflect.get(value, "_payload") }; +} + +function readReactForwardRefRender(value: unknown): UnknownFunction | null { + if (!isObjectLike(value) || Reflect.get(value, "$$typeof") !== REACT_FORWARD_REF_TYPE) { + return null; + } + const render = Reflect.get(value, "render"); + return isUnknownFunction(render) ? render : null; +} + +async function resolveReactLazyType(lazyType: ReactLazyType): Promise { + try { + return lazyType.init(lazyType.payload); + } catch (error) { + if (!isPromiseLike(error)) { + throw error; + } + await error; + return lazyType.init(lazyType.payload); + } +} + +/** + * Invokes server-component children returned by a layout probe so per-layout + * skip eligibility observes data dependencies created below the layout's + * immediate function body. The real RSC render remains authoritative; probe + * failures only make static-layout skip fall back to render-and-send. + */ +export async function probeReactServerSubtree( + node: unknown, + options: ProbeReactServerSubtreeOptions = {}, +): Promise { + const maxDepth = options.maxDepth ?? DEFAULT_SUBTREE_PROBE_MAX_DEPTH; + const maxNodes = options.maxNodes ?? DEFAULT_SUBTREE_PROBE_MAX_NODES; + let visitedNodes = 0; + + const enterProbeNode = (depth: number): void => { + if (depth > maxDepth) { + throw new AppPageSubtreeProbeLimitError("App page layout subtree probe exceeded max depth"); + } + visitedNodes += 1; + if (visitedNodes > maxNodes) { + throw new AppPageSubtreeProbeLimitError("App page layout subtree probe exceeded max nodes"); + } + }; + + const renderElementType = async ( + type: unknown, + props: ProbeReactElementProps, + depth: number, + wrapperDepth = 0, + ): Promise => { + if (wrapperDepth > maxDepth) { + throw new AppPageSubtreeProbeLimitError("App page layout subtree probe exceeded max depth"); + } + + if (isUnknownFunction(type)) { + await visit(type(props), depth + 1); + return true; + } + + const memoType = readReactMemoType(type); + if (memoType) { + return renderElementType(memoType.innerType, props, depth, wrapperDepth + 1); + } + + const lazyType = readReactLazyType(type); + if (lazyType) { + return renderElementType( + await resolveReactLazyType(lazyType), + props, + depth, + wrapperDepth + 1, + ); + } + + const forwardRefRender = readReactForwardRefRender(type); + if (forwardRefRender) { + await visit(forwardRefRender(props, null), depth + 1); + return true; + } + + return false; + }; + + const visit = async (value: unknown, depth: number): Promise => { + enterProbeNode(depth); + 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); + return; + } + if (Array.isArray(value)) { + for (const child of value) { + await visit(child, depth + 1); + } + return; + } + if (isIterable(value) && !isProbeReactElement(value)) { + throw new AppPageSubtreeProbeUnsupportedIterableError(); + } + if (!isProbeReactElement(value)) return; + + if (value.type === Fragment || typeof value.type === "string") { + await visit(value.props.children, depth + 1); + return; + } + + if (await renderElementType(value.type, value.props, depth)) { + return; + } + + await visit(value.props.children, depth + 1); + }; + + await visit(node, 0); +} + /** * Build a probePage() invocation for the App Router request lifecycle. * diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 55adcefbf..a10d868d1 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -574,10 +574,18 @@ let _unstableIoWarned = false; // --------------------------------------------------------------------------- export type UnstableCacheRevalidationMode = "foreground" | "background"; export type ActionRevalidationKind = 0 | 1 | 2; +export type UnstableCacheObservation = Readonly<{ + kind: "unstable_cache"; + keyHash: string; + revalidate: number | false | null; + tagCount: number; + tagHash: string | null; +}>; export type CacheState = { actionRevalidationKind: ActionRevalidationKind; requestScopedCacheLife: CacheLifeConfig | null; + unstableCacheObservations: Map; unstableCacheRevalidation: UnstableCacheRevalidationMode; }; @@ -588,6 +596,7 @@ const _cacheAls = getOrCreateAls("vinext.cache.als"); const _cacheFallbackState = (_g[_FALLBACK_KEY] ??= { actionRevalidationKind: 0, requestScopedCacheLife: null, + unstableCacheObservations: new Map(), unstableCacheRevalidation: "foreground", } satisfies CacheState) as CacheState; @@ -615,12 +624,14 @@ export function _runWithCacheState(fn: () => T | Promise): T | Promise return runWithUnifiedStateMutation((uCtx) => { uCtx.actionRevalidationKind = ACTION_DID_NOT_REVALIDATE; uCtx.requestScopedCacheLife = null; + uCtx.unstableCacheObservations = new Map(); uCtx.unstableCacheRevalidation = "foreground"; }, fn); } const state: CacheState = { actionRevalidationKind: ACTION_DID_NOT_REVALIDATE, requestScopedCacheLife: null, + unstableCacheObservations: new Map(), unstableCacheRevalidation: "foreground", }; return _cacheAls.run(state, fn); @@ -635,6 +646,7 @@ export function _initRequestScopedCacheState(): void { const state = _getCacheState(); state.actionRevalidationKind = ACTION_DID_NOT_REVALIDATE; state.requestScopedCacheLife = null; + state.unstableCacheObservations = new Map(); } function markActionRevalidation(kind: ActionRevalidationKind): void { @@ -710,6 +722,16 @@ export function _consumeRequestScopedCacheLife(): CacheLifeConfig | null { return config; } +function recordUnstableCacheObservation(observation: UnstableCacheObservation): void { + _getCacheState().unstableCacheObservations.set(observation.keyHash, observation); +} + +export function _peekUnstableCacheObservations(): UnstableCacheObservation[] { + return [..._getCacheState().unstableCacheObservations.values()].sort((a, b) => + a.keyHash.localeCompare(b.keyHash), + ); +} + // --------------------------------------------------------------------------- // cacheLife / cacheTag — Next.js 15+ "use cache" APIs // --------------------------------------------------------------------------- @@ -1047,6 +1069,18 @@ export function unstable_cache Promise>( const cachedFn = async (...args: Parameters) => { const argsKey = JSON.stringify(args); const cacheKey = `unstable_cache:${baseKey}:${argsKey}`; + recordUnstableCacheObservation({ + kind: "unstable_cache", + keyHash: fnv1a64(cacheKey), + revalidate: + typeof revalidateSeconds === "number" + ? revalidateSeconds + : revalidateSeconds === false + ? false + : null, + tagCount: tags.length, + tagHash: tags.length > 0 ? fnv1a64(JSON.stringify(tags)) : null, + }); // Try to get from cache. Stale entries are usable in normal App Router // requests, but foreground-refresh inside revalidation scopes so the diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index 9a6fa0cbc..f74264171 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -474,6 +474,7 @@ const originalFetch: typeof globalThis.fetch = (_gFetch[_ORIG_FETCH_KEY] ??= // multi-environment module instances. // --------------------------------------------------------------------------- export type FetchCacheState = { + cacheableFetchUrls: Set; currentRequestTags: string[]; currentFetchSoftTags: string[]; currentFetchCacheMode: FetchCacheMode | null; @@ -508,6 +509,7 @@ if (globalThis.FinalizationRegistry) { } const _fallbackState = (_g[_FALLBACK_KEY] ??= { + cacheableFetchUrls: new Set(), currentRequestTags: [], currentFetchSoftTags: [], currentFetchCacheMode: null, @@ -528,6 +530,7 @@ function _getState(): FetchCacheState { * in single-threaded contexts where ALS.run() isn't used. */ function _resetFallbackState(isFetchDedupeActive: boolean): void { + _fallbackState.cacheableFetchUrls = new Set(); _fallbackState.currentRequestTags = []; _fallbackState.currentFetchSoftTags = []; _fallbackState.currentFetchCacheMode = null; @@ -544,6 +547,14 @@ function recordDynamicFetchObservation(input: string | URL | Request): void { _getState().dynamicFetchUrls.add(getFetchObservationUrl(input)); } +function recordCacheableFetchObservation(input: string | URL | Request): void { + _getState().cacheableFetchUrls.add(getFetchObservationUrl(input)); +} + +export function peekCacheableFetchObservations(): string[] { + return [..._getState().cacheableFetchUrls].sort(); +} + export function peekDynamicFetchObservations(): string[] { return [..._getState().dynamicFetchUrls].sort(); } @@ -913,6 +924,7 @@ function createPatchedFetch(): typeof globalThis.fetch { } throw err; } + recordCacheableFetchObservation(input); const handler = getCacheHandler(); // Collect tags for this render pass @@ -1145,6 +1157,7 @@ export async function runWithFetchCache(fn: () => Promise): Promise { _ensurePatchInstalled(); if (isInsideUnifiedScope()) { return await runWithUnifiedStateMutation((uCtx) => { + uCtx.cacheableFetchUrls = new Set(); uCtx.currentRequestTags = []; uCtx.currentFetchSoftTags = []; uCtx.dynamicFetchUrls = new Set(); @@ -1154,6 +1167,7 @@ export async function runWithFetchCache(fn: () => Promise): Promise { } return _als.run( { + cacheableFetchUrls: new Set(), currentRequestTags: [], currentFetchSoftTags: [], currentFetchCacheMode: null, diff --git a/packages/vinext/src/shims/thenable-params.ts b/packages/vinext/src/shims/thenable-params.ts index 1baba4d27..3955d7971 100644 --- a/packages/vinext/src/shims/thenable-params.ts +++ b/packages/vinext/src/shims/thenable-params.ts @@ -62,7 +62,42 @@ function isWellKnownProperty(prop: PropertyKey): boolean { export type ThenableParams> = Promise & Omit; -export function makeThenableParams>(obj: T): ThenableParams { +export type ThenableParamsObserver = Readonly<{ + observeParamAccess: (keys: readonly string[]) => void; +}>; + +function observeParamKeys( + observer: ThenableParamsObserver | undefined, + keys: readonly string[], +): void { + if (observer) { + observer.observeParamAccess(keys); + } +} + +function observeAllParamKeys>( + observer: ThenableParamsObserver | undefined, + plain: T, +): void { + observeParamKeys(observer, Object.keys(plain)); +} + +function observeReadableParamKeys>( + observer: ThenableParamsObserver | undefined, + plain: T, +): void { + const keys = Object.keys(plain).filter((key) => !isWellKnownProperty(key)); + observeParamKeys(observer, keys); +} + +function isPromiseContinuation(prop: PropertyKey): boolean { + return prop === "then" || prop === "catch" || prop === "finally"; +} + +export function makeThenableParams>( + obj: T, + observer?: ThenableParamsObserver, +): ThenableParams { const plain = { ...obj }; const promise = Promise.resolve(plain); @@ -72,6 +107,19 @@ export function makeThenableParams>(obj: T): T // the boundary so the handler above stays fully type-checked. return new Proxy(promise, { get(target, prop, receiver) { + 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); + }; + } + + if (typeof prop === "string" && !isWellKnownProperty(prop)) { + observeParamKeys(observer, [prop]); + } + if (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) { return Reflect.get(plain, prop); } @@ -80,6 +128,10 @@ export function makeThenableParams>(obj: T): T return typeof value === "function" ? value.bind(target) : value; }, getOwnPropertyDescriptor(target, prop) { + if (typeof prop === "string" && !isWellKnownProperty(prop)) { + observeParamKeys(observer, [prop]); + } + if (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) { return { configurable: true, @@ -92,11 +144,16 @@ export function makeThenableParams>(obj: T): T return Reflect.getOwnPropertyDescriptor(target, prop); }, has(target, prop) { + if (typeof prop === "string" && !isWellKnownProperty(prop)) { + observeParamKeys(observer, [prop]); + } + return ( Reflect.has(target, prop) || (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) ); }, ownKeys() { + observeReadableParamKeys(observer, plain); return Reflect.ownKeys(plain).filter((prop) => !isWellKnownProperty(prop)); }, }) as unknown as ThenableParams; diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 6032a3b77..572ea483a 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -96,8 +96,10 @@ export function createRequestContext(opts?: Partial): Uni serverContext: null, serverInsertedHTMLCallbacks: [], requestScopedCacheLife: null, + unstableCacheObservations: new Map(), unstableCacheRevalidation: "foreground", _privateCache: null, + cacheableFetchUrls: new Set(), currentRequestTags: [], currentFetchSoftTags: [], currentFetchCacheMode: null, @@ -160,7 +162,8 @@ export function runWithUnifiedStateMutation( const childCtx = { ...parentCtx }; // NOTE: This is a shallow clone. Array fields (pendingSetCookies, // serverInsertedHTMLCallbacks, currentRequestTags, ssrHeadChildren), Set - // fields (renderRequestApiUsage, dynamicFetchUrls), the _privateCache Map, + // fields (renderRequestApiUsage, cacheableFetchUrls, dynamicFetchUrls), + // Map fields (unstableCacheObservations, _privateCache), // requestCache WeakMap, and object fields (headersContext, // i18nContext, serverContext, ssrContext, executionContext, // requestScopedCacheLife) still share references with the parent until diff --git a/tests/app-layout-param-observation.test.ts b/tests/app-layout-param-observation.test.ts new file mode 100644 index 000000000..b0f57e638 --- /dev/null +++ b/tests/app-layout-param-observation.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vite-plus/test"; +import { createAppLayoutParamAccessTracker } from "../packages/vinext/src/server/app-layout-param-observation.js"; +import { + cacheLife, + MemoryCacheHandler, + setCacheHandler, + unstable_cache, +} from "../packages/vinext/src/shims/cache.js"; +import { + createRequestContext, + getRequestContext, + runWithRequestContext, +} from "../packages/vinext/src/shims/unified-request-context.js"; +import { markRenderRequestApiUsage } from "../packages/vinext/src/shims/headers.js"; + +describe("app layout param observation", () => { + it("isolates fetch and cacheLife observations to the current layout probe", async () => { + const tracker = createAppLayoutParamAccessTracker(); + + await runWithRequestContext(createRequestContext(), async () => { + await tracker.runLayoutProbe("layout:/dashboard/settings", () => { + const ctx = getRequestContext(); + ctx.currentRequestTags.push("shared-tag"); + ctx.cacheableFetchUrls.add("https://example.com/settings"); + ctx.dynamicFetchUrls.add("https://example.com/settings-dynamic"); + cacheLife("seconds"); + markRenderRequestApiUsage("headers"); + }); + + await tracker.runLayoutProbe("layout:/dashboard", () => null); + + await tracker.runLayoutProbe("layout:/dashboard/profile", () => { + const ctx = getRequestContext(); + ctx.currentRequestTags.push("shared-tag"); + ctx.cacheableFetchUrls.add("https://example.com/profile"); + markRenderRequestApiUsage("cookies"); + }); + }); + + expect(tracker.getLayoutObservation("layout:/dashboard/settings")).toMatchObject({ + cacheLifeObserved: true, + cacheTags: ["shared-tag"], + cacheableFetchCount: 1, + dynamicFetchCount: 1, + requestApis: ["headers"], + }); + expect(tracker.getLayoutObservation("layout:/dashboard")).toMatchObject({ + cacheLifeObserved: false, + cacheTags: [], + cacheableFetchCount: 0, + dynamicFetchCount: 0, + requestApis: [], + }); + expect(tracker.getLayoutObservation("layout:/dashboard/profile")).toMatchObject({ + cacheLifeObserved: false, + cacheTags: ["shared-tag"], + cacheableFetchCount: 1, + dynamicFetchCount: 0, + requestApis: ["cookies"], + }); + }); + + it("records unstable_cache dependencies on cache miss and hit", async () => { + setCacheHandler(new MemoryCacheHandler()); + const tracker = createAppLayoutParamAccessTracker(); + let calls = 0; + const cached = unstable_cache( + async () => { + calls += 1; + return `banner-${calls}`; + }, + ["layout-banner"], + { tags: ["banner"], revalidate: 60 }, + ); + + await runWithRequestContext(createRequestContext(), async () => { + await tracker.runLayoutProbe("layout:/miss", () => cached()); + await tracker.runLayoutProbe("layout:/hit", () => cached()); + }); + + expect(calls).toBe(1); + expect(tracker.getLayoutObservation("layout:/miss")).toMatchObject({ + unstableCaches: [ + { + kind: "unstable_cache", + revalidate: 60, + tagCount: 1, + }, + ], + }); + expect(tracker.getLayoutObservation("layout:/hit")).toMatchObject({ + unstableCaches: [ + { + kind: "unstable_cache", + revalidate: 60, + tagCount: 1, + }, + ], + }); + }); +}); diff --git a/tests/app-page-params.test.ts b/tests/app-page-params.test.ts index be550bd7c..7dda347c1 100644 --- a/tests/app-page-params.test.ts +++ b/tests/app-page-params.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveAppPageSegmentParams } from "../packages/vinext/src/server/app-page-params.js"; +import { + resolveAppPageSegmentParamScopeKeys, + resolveAppPageSegmentParams, +} from "../packages/vinext/src/server/app-page-params.js"; describe("app page params helpers", () => { it("passes only params that apply to each layout", () => { @@ -44,4 +47,11 @@ describe("app page params helpers", () => { params: ["something", "another"], }); }); + + it("keeps optional catch-all names in structural layout param scope", () => { + const routeSegments = ["docs", "[[...slug]]"]; + + expect(resolveAppPageSegmentParams(routeSegments, 2, { slug: [] })).toEqual({}); + expect(resolveAppPageSegmentParamScopeKeys(routeSegments, 2)).toEqual(["slug"]); + }); }); diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts index a9d776bd3..2f333d83c 100644 --- a/tests/app-page-probe.test.ts +++ b/tests/app-page-probe.test.ts @@ -1,7 +1,9 @@ +import React from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { probeAppPage, probeAppPageBeforeRender, + probeReactServerSubtree, } from "../packages/vinext/src/server/app-page-probe.js"; // Mirrors makeThenableParams() from app-rsc-entry.ts — the function that @@ -13,6 +15,113 @@ function makeThenableParams>(obj: T): Promise< } describe("app page probe helpers", () => { + it("probes server components returned below a layout result", async () => { + const calls: string[] = []; + + function Child() { + calls.push("child"); + return null; + } + + function Layout() { + calls.push("layout"); + return React.createElement("section", null, React.createElement(Child)); + } + + await probeReactServerSubtree(React.createElement(Layout)); + + expect(calls).toEqual(["layout", "child"]); + }); + + it("probes memo and forwardRef server components returned below a layout result", async () => { + const calls: string[] = []; + + const MemoChild = React.memo(function MemoChild() { + calls.push("memo"); + return null; + }); + const ForwardRefChild = React.forwardRef(function ForwardRefChild() { + calls.push("forwardRef"); + return null; + }); + const MemoForwardRefChild = React.memo( + React.forwardRef(function MemoForwardRefChild() { + calls.push("memoForwardRef"); + return null; + }), + ); + + function Layout() { + calls.push("layout"); + return React.createElement( + "section", + null, + React.createElement(MemoChild), + React.createElement(ForwardRefChild), + React.createElement(MemoForwardRefChild), + ); + } + + await probeReactServerSubtree(React.createElement(Layout)); + + expect(calls).toEqual(["layout", "memo", "forwardRef", "memoForwardRef"]); + }); + + it("probes lazy server components returned below a layout result", async () => { + const calls: string[] = []; + + const LazyChild = React.lazy(() => + Promise.resolve({ + default() { + calls.push("lazy"); + return null; + }, + }), + ); + + function Layout() { + calls.push("layout"); + return React.createElement("section", null, React.createElement(LazyChild)); + } + + await probeReactServerSubtree(React.createElement(Layout)); + + expect(calls).toEqual(["layout", "lazy"]); + }); + + it("enforces subtree depth limits for nested arrays", async () => { + await expect( + probeReactServerSubtree([[[React.createElement("span")]]], { maxDepth: 1 }), + ).rejects.toThrow("App page layout subtree probe exceeded max depth"); + }); + + it("enforces subtree node limits for large arrays", async () => { + await expect(probeReactServerSubtree([1, 2, 3], { maxNodes: 2 })).rejects.toThrow( + "App page layout subtree probe exceeded max nodes", + ); + }); + + it("does not consume single-use iterables while probing layout children", async () => { + function Child() { + return null; + } + + function* createChildren() { + yield React.createElement(Child); + } + + const sharedChildren = createChildren(); + + function Layout() { + return React.createElement("section", null, sharedChildren); + } + + await expect(probeReactServerSubtree(React.createElement(Layout))).rejects.toThrow( + "App page layout subtree probe cannot safely inspect iterable children", + ); + expect(sharedChildren.next().value).toMatchObject({ type: Child }); + }); + it("handles layout special errors before probing the page", async () => { const layoutError = new Error("layout failed"); const pageProbe = vi.fn(() => "page"); diff --git a/tests/thenable-params.test.ts b/tests/thenable-params.test.ts index 5bcceb372..23b1d1d96 100644 --- a/tests/thenable-params.test.ts +++ b/tests/thenable-params.test.ts @@ -102,4 +102,52 @@ describe("makeThenableParams", () => { expect(Object.keys(params)).toEqual([]); expect(await params).toEqual({}); }); + + it("reports direct param property access to an observer", () => { + const observedKeys: string[][] = []; + const params = makeThenableParams( + { slug: "post" }, + { + observeParamAccess(keys) { + observedKeys.push([...keys]); + }, + }, + ); + + expect(params.slug).toBe("post"); + expect(observedKeys).toEqual([["slug"]]); + }); + + it("reports awaited params as an all-keys access", async () => { + const observedKeys: string[][] = []; + const params = makeThenableParams( + { slug: "post", category: "news" }, + { + observeParamAccess(keys) { + observedKeys.push([...keys]); + }, + }, + ); + + await params; + + expect(observedKeys).toEqual([["slug", "category"]]); + }); + + it("reports destructured param property access to an observer", () => { + const observedKeys: string[][] = []; + const params = makeThenableParams( + { slug: "post" }, + { + observeParamAccess(keys) { + observedKeys.push([...keys]); + }, + }, + ); + + const { slug } = params; + + expect(slug).toBe("post"); + expect(observedKeys).toEqual([["slug"]]); + }); }); From d9981aefed89bb01728788e78d9d9455603dd611 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 29 May 2026 15:19:53 +1000 Subject: [PATCH 2/6] fix(skip): address review feedback on observation foundations - 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) --- .../src/server/app-layout-param-observation.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/server/app-layout-param-observation.ts b/packages/vinext/src/server/app-layout-param-observation.ts index 6ac85f7fc..196fdcd14 100644 --- a/packages/vinext/src/server/app-layout-param-observation.ts +++ b/packages/vinext/src/server/app-layout-param-observation.ts @@ -114,11 +114,6 @@ export function createAppLayoutParamAccessTracker(): AppLayoutParamAccessTracker ensureObservation(layoutId).probeComplete = true; }; - const stringifyCacheLifeSnapshot = (): string | null => { - const cacheLife = _peekRequestScopedCacheLife(); - return cacheLife === null ? null : JSON.stringify(cacheLife); - }; - const runWithIsolatedProbeDependencies = (probe: () => unknown): unknown => { if (!isInsideUnifiedScope()) { return probe(); @@ -126,6 +121,7 @@ export function createAppLayoutParamAccessTracker(): AppLayoutParamAccessTracker return runWithUnifiedStateMutation((ctx) => { ctx.cacheableFetchUrls = new Set(); ctx.currentRequestTags = []; + ctx.currentFetchSoftTags = []; ctx.dynamicFetchUrls = new Set(); ctx.dynamicUsageDetected = false; ctx.renderRequestApiUsage = new Set(); @@ -136,7 +132,7 @@ export function createAppLayoutParamAccessTracker(): AppLayoutParamAccessTracker const recordProbeDependencies = (layoutId: string) => { const observation = ensureObservation(layoutId); - if (stringifyCacheLifeSnapshot() !== null) { + if (_peekRequestScopedCacheLife() !== null) { observation.cacheLifeObserved = true; } for (const tag of getCollectedFetchTags()) { @@ -228,6 +224,11 @@ export function createAppLayoutParamAccessTracker(): AppLayoutParamAccessTracker return resolved; }, (error: unknown) => { + // Record whatever dependencies we observed before the failure + // so the layout's dependency snapshot is as complete as possible. + // Deliberately do NOT call markProbeComplete here: a failed probe + // leaves completeness as "unknown", which makes the planner fall + // back to render-and-send — the safe default for any probe error. recordProbeDependencies(layoutId); throw error; }, From c9de116ae6ff575434a548cdbe352023bfaa65d7 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 29 May 2026 17:28:22 +1000 Subject: [PATCH 3/6] fix(skip): record cacheable-fetch observation synchronously before first 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. --- packages/vinext/src/shims/fetch-cache.ts | 28 +++++++++------- tests/app-layout-param-observation.test.ts | 38 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index f74264171..b9cfdd574 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -904,7 +904,24 @@ function createPatchedFetch(): typeof globalThis.fetch { } } + // Record cacheable-fetch observation synchronously, before the first await, + // so that probe-based skip-eligibility checks (e.g. runLayoutProbe) can + // snapshot the dependency even when callers start a fetch but do not await + // its result before yielding to the probe harness. + // If key generation later fails and the fetch falls back to dynamic, + // recording both cacheable and dynamic is conservative — a false "unsafe" + // result costs performance, not correctness. + recordCacheableFetchObservation(input); + const reqTags = _getState().currentRequestTags; const tags = encodeCacheTags(nextOpts?.tags ?? []); + if (tags.length > 0) { + for (const tag of tags) { + if (!reqTags.includes(tag)) { + reqTags.push(tag); + } + } + } + const softTags = _getState().currentFetchSoftTags; let fetchInit = stripNextFromInit(init, cacheDirective); let cacheKey: string; @@ -924,19 +941,8 @@ function createPatchedFetch(): typeof globalThis.fetch { } throw err; } - recordCacheableFetchObservation(input); const handler = getCacheHandler(); - // Collect tags for this render pass - const reqTags = _getState().currentRequestTags; - if (tags.length > 0) { - for (const tag of tags) { - if (!reqTags.includes(tag)) { - reqTags.push(tag); - } - } - } - // Try cache first try { const cached = await handler.get(cacheKey, { kind: "FETCH", tags, softTags }); diff --git a/tests/app-layout-param-observation.test.ts b/tests/app-layout-param-observation.test.ts index b0f57e638..142713bb9 100644 --- a/tests/app-layout-param-observation.test.ts +++ b/tests/app-layout-param-observation.test.ts @@ -6,6 +6,7 @@ import { setCacheHandler, unstable_cache, } from "../packages/vinext/src/shims/cache.js"; +import { ensureFetchPatch, getOriginalFetch } from "../packages/vinext/src/shims/fetch-cache.js"; import { createRequestContext, getRequestContext, @@ -98,4 +99,41 @@ describe("app layout param observation", () => { ], }); }); + + it("observes cacheable fetch dependencies synchronously, before the fetch settles", () => { + ensureFetchPatch(); + setCacheHandler(new MemoryCacheHandler()); + + const original = getOriginalFetch(); + try { + const tracker = createAppLayoutParamAccessTracker(); + + // runLayoutProbe runs the probe synchronously, snapshots + // observations, and marks the probe complete — all before the + // fetch promise settles. If the cacheable-fetch observation is + // deferred past await buildFetchCacheKey(), this assertion fails + // because the probe will have already snapshotted. + void runWithRequestContext(createRequestContext(), () => { + // Use runLayoutProbe synchronously (probe returns non-promise) + // so the sync path executes recordProbeDependencies immediately. + tracker.runLayoutProbe("layout:/banner", () => { + void fetch("https://example.com/data", { + next: { revalidate: 60, tags: ["banner"] }, + }); + // Do not await the fetch — it must still be observable. + }); + }); + + // The observation must already include the cacheable fetch + // dependency, because recordCacheableFetchObservation runs + // synchronously before any await in the patched fetch branch. + expect(tracker.getLayoutObservation("layout:/banner")).toMatchObject({ + cacheableFetchCount: 1, + cacheTags: ["banner"], + completeness: "complete", + }); + } finally { + globalThis.fetch = original; + } + }); }); From 0024089603248b0a40b93a5ecde4cd4ff0394615 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 29 May 2026 17:36:42 +1000 Subject: [PATCH 4/6] fix(test): remove polluting fetch restore and catch background promise 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. --- tests/app-layout-param-observation.test.ts | 58 +++++++++++----------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/tests/app-layout-param-observation.test.ts b/tests/app-layout-param-observation.test.ts index 142713bb9..30da7c7ff 100644 --- a/tests/app-layout-param-observation.test.ts +++ b/tests/app-layout-param-observation.test.ts @@ -6,7 +6,7 @@ import { setCacheHandler, unstable_cache, } from "../packages/vinext/src/shims/cache.js"; -import { ensureFetchPatch, getOriginalFetch } from "../packages/vinext/src/shims/fetch-cache.js"; +import { ensureFetchPatch } from "../packages/vinext/src/shims/fetch-cache.js"; import { createRequestContext, getRequestContext, @@ -104,36 +104,36 @@ describe("app layout param observation", () => { ensureFetchPatch(); setCacheHandler(new MemoryCacheHandler()); - const original = getOriginalFetch(); - try { - const tracker = createAppLayoutParamAccessTracker(); + const tracker = createAppLayoutParamAccessTracker(); - // runLayoutProbe runs the probe synchronously, snapshots - // observations, and marks the probe complete — all before the - // fetch promise settles. If the cacheable-fetch observation is - // deferred past await buildFetchCacheKey(), this assertion fails - // because the probe will have already snapshotted. - void runWithRequestContext(createRequestContext(), () => { - // Use runLayoutProbe synchronously (probe returns non-promise) - // so the sync path executes recordProbeDependencies immediately. - tracker.runLayoutProbe("layout:/banner", () => { - void fetch("https://example.com/data", { - next: { revalidate: 60, tags: ["banner"] }, - }); - // Do not await the fetch — it must still be observable. - }); + // runLayoutProbe runs the probe synchronously, snapshots + // observations, and marks the probe complete — all before the + // fetch promise settles. If the cacheable-fetch observation is + // deferred past await buildFetchCacheKey(), this assertion fails + // because the probe will have already snapshotted. + void runWithRequestContext(createRequestContext(), () => { + // Use runLayoutProbe synchronously (probe returns non-promise) + // so the sync path executes recordProbeDependencies immediately. + tracker.runLayoutProbe("layout:/banner", () => { + // Do not await the fetch — it must still be observable. + // Catch the background promise so it cannot produce an + // unhandled rejection after the synchronous assertions + // complete. The patched fetch continues past observation + // into cache lookup and network fetch, which may fail in + // this test environment. + fetch("https://example.com/data", { + next: { revalidate: 60, tags: ["banner"] }, + }).catch(() => {}); }); + }); - // The observation must already include the cacheable fetch - // dependency, because recordCacheableFetchObservation runs - // synchronously before any await in the patched fetch branch. - expect(tracker.getLayoutObservation("layout:/banner")).toMatchObject({ - cacheableFetchCount: 1, - cacheTags: ["banner"], - completeness: "complete", - }); - } finally { - globalThis.fetch = original; - } + // The observation must already include the cacheable fetch + // dependency, because recordCacheableFetchObservation runs + // synchronously before any await in the patched fetch branch. + expect(tracker.getLayoutObservation("layout:/banner")).toMatchObject({ + cacheableFetchCount: 1, + cacheTags: ["banner"], + completeness: "complete", + }); }); }); From cae0cade789915fa90d8bb3b6b2ed393d055af20 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 29 May 2026 21:14:57 +1000 Subject: [PATCH 5/6] chore: empty commit to trigger CI From 159923c313da4adae70d894a6f1ea7ea9908b061 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 29 May 2026 21:15:06 +1000 Subject: [PATCH 6/6] chore: empty commit to trigger CI