Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
239 changes: 239 additions & 0 deletions packages/vinext/src/server/app-layout-param-observation.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
cacheableFetches: Set<string>;
dynamicFetches: Set<string>;
finiteRevalidateSeconds: number | null;
keys: Set<string>;
observed: boolean;
paramScopeKeys: Set<string>;
probeComplete: boolean;
requestApis: Set<RenderRequestApiKind>;
unstableCaches: Map<string, UnstableCacheObservation>;
};

function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return Boolean(
value &&
(typeof value === "object" || typeof value === "function") &&
"then" in value &&
typeof value.then === "function",
);
}

export function createAppLayoutParamAccessTracker(): AppLayoutParamAccessTracker {
const observations = new Map<string, MutableLayoutParamAccessObservation>();

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

};

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

},
);
});
},
};
}
22 changes: 22 additions & 0 deletions packages/vinext/src/server/app-page-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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,
Expand Down
Loading
Loading