diff --git a/knip.ts b/knip.ts index 988904f30..7ac712193 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", // #726-SKIP static layout reuse proof model. Consumed by render in a // later slice; standalone planner + helpers here. "src/server/skip-cache-proof.ts", 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..196fdcd14 --- /dev/null +++ b/packages/vinext/src/server/app-layout-param-observation.ts @@ -0,0 +1,239 @@ +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 runWithIsolatedProbeDependencies = (probe: () => unknown): unknown => { + if (!isInsideUnifiedScope()) { + return probe(); + } + return runWithUnifiedStateMutation((ctx) => { + ctx.cacheableFetchUrls = new Set(); + ctx.currentRequestTags = []; + ctx.currentFetchSoftTags = []; + 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 (_peekRequestScopedCacheLife() !== 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) => { + // 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; + }, + ); + }); + }, + }; +} 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..b9cfdd574 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(); } @@ -893,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; @@ -915,16 +943,6 @@ function createPatchedFetch(): typeof globalThis.fetch { } 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 }); @@ -1145,6 +1163,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 +1173,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..30da7c7ff --- /dev/null +++ b/tests/app-layout-param-observation.test.ts @@ -0,0 +1,139 @@ +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 { ensureFetchPatch } from "../packages/vinext/src/shims/fetch-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, + }, + ], + }); + }); + + it("observes cacheable fetch dependencies synchronously, before the fetch settles", () => { + ensureFetchPatch(); + setCacheHandler(new MemoryCacheHandler()); + + 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", () => { + // 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", + }); + }); +}); 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"]]); + }); });