/** * Router Revalidation Logic * * Evaluates whether segments should revalidate based on params, actions, and custom functions. */ import type { ResolvedSegment, HandlerContext, ActionRef } from "../types"; import type { ActionContext } from "./types"; import { debugLog, pushRevalidationTraceEntry, isTraceActive, } from "./logging.js"; import type { RevalidationTraceEntry } from "./logging.js"; import { _getRequestContext } from "../server/request-context.js"; import { isAutoGeneratedRouteName } from "../route-name.js"; /** * Resolve a server-action reference's stable id, mirroring how the action * boundary derives `actionContext.actionId` in `rsc/server-action.ts` * (`$id ?? $$id`): the file-path `$id` set by the expose-action-id plugin in a * production RSC build when present, otherwise React's `$$id`. Resolving both * the incoming `actionId` and the reference with the same precedence makes * `isAction()` form-agnostic across dev and production. */ function resolveActionRefId(ref: unknown): string | undefined { if (ref == null) return undefined; const r = ref as { $id?: unknown; $$id?: unknown }; if (typeof r.$id === "string") return r.$id; if (typeof r.$$id === "string") return r.$$id; return undefined; } /** * Build the `isAction()` helper bound to the current action's id. Matches a * single imported action reference, several (variadic), or any export of a * namespace import (`import * as Mod`). Returns `false` when there is no action * (plain navigation) or nothing matches. */ function makeIsAction( currentActionId: string | undefined, ): (...actions: ActionRef[]) => boolean { return (...actions: ActionRef[]): boolean => { if (!currentActionId) return false; for (const action of actions) { if (typeof action === "function") { if (resolveActionRefId(action) === currentActionId) return true; } else if (action && typeof action === "object") { // Namespace import: match any export of the module. for (const value of Object.values(action)) { if (resolveActionRefId(value) === currentActionId) return true; } } } return false; }; } function paramsEqual( a: Record, b: Record, ): boolean { if (a === b) return true; const keysA = Object.keys(a); if (keysA.length !== Object.keys(b).length) return false; for (const key of keysA) { if (a[key] !== b[key]) return false; } return true; } /** * Options for revalidation evaluation */ interface EvaluateRevalidationOptions { /** Current segment to evaluate */ segment: ResolvedSegment; /** Previous route params (from route match, not segment) */ prevParams: Record; /** Lazy function to get previous segment if needed */ getPrevSegment: (() => Promise) | null; /** Current request */ request: Request; /** Previous URL */ prevUrl: URL; /** Next URL */ nextUrl: URL; /** Custom revalidation functions */ revalidations: Array<{ name: string; fn: any }>; /** Current route key */ routeKey: string; /** Handler context */ context: HandlerContext; /** Action context if triggered by action */ actionContext?: ActionContext; /** If true, this is a stale cache revalidation request */ stale?: boolean; /** Trace source hint for the revalidation trace */ traceSource?: RevalidationTraceEntry["source"]; /** * Override the segment-type-derived default. When set, the value is used as * the seed `defaultShouldRevalidate` passed to user revalidate fns and the * reason flows into the trace. Callers use this when client-knowledge * (e.g. parallel slot not in clientSegmentIds) should dictate the seed * instead of the params/method-based heuristic. */ defaultOverride?: { value: boolean; reason: string }; } /** * Evaluate if a segment should revalidate using soft/hard decision pattern * Optimized to use prevParams directly and avoid building previous segments */ export async function evaluateRevalidation( options: EvaluateRevalidationOptions, ): Promise { const { segment, prevParams, getPrevSegment, request, prevUrl, nextUrl, revalidations, routeKey, context, actionContext, stale, traceSource, defaultOverride, } = options; const nextParams = segment.params || {}; const paramsChanged = !paramsEqual(nextParams, prevParams); const searchChanged = prevUrl.search !== nextUrl.search; // Trace helper: push a structured entry to the request-scoped trace buffer. // Guarded by isTraceActive() so object construction is skipped in production. function pushTrace( defaultVal: boolean, finalVal: boolean, reason: string, ): void { if (!isTraceActive()) return; pushRevalidationTraceEntry({ segmentId: segment.id, segmentType: segment.type, belongsToRoute: segment.belongsToRoute ?? false, source: traceSource ?? "segment-resolution", defaultShouldRevalidate: defaultVal, finalShouldRevalidate: finalVal, reason, customRevalidators: revalidations.length || undefined, }); } // Calculate default revalidation based on segment type and request method let defaultShouldRevalidate: boolean; let defaultReason: string; if (defaultOverride) { // Caller injected the seed (e.g. parallel slot not in clientSegmentIds). // Skip the type-derived heuristic — caller knows better in this context. defaultShouldRevalidate = defaultOverride.value; defaultReason = defaultOverride.reason; } else if (request.method === "POST") { // Actions: revalidate segments that belong to the route, skip parent chain if (segment.type === "route") { // Route segment always revalidates on actions defaultShouldRevalidate = true; defaultReason = "action:route-segment"; } else if (segment.type === "loader") { // Loaders always revalidate on actions - they often contain action-sensitive data // (e.g., cart count after add-to-cart action) defaultShouldRevalidate = true; defaultReason = "action:loader-segment"; } else if (segment.belongsToRoute) { // Segment belongs to route (orphan layouts/parallels) - revalidate defaultShouldRevalidate = true; defaultReason = "action:belongs-to-route"; } else { // Parent chain segment (shared layouts/parallels) - don't revalidate defaultShouldRevalidate = false; defaultReason = "action:parent-chain-skip"; } } else { // Navigation (GET): Conservative defaults to minimize unnecessary revalidations // Only the route segment revalidates by default - all others require explicit opt-in if (segment.type === "route") { // Route segments revalidate when path params OR search params change. // Search params (e.g., ?page=2&sort=price) are server-parsed via ctx.search, // so the handler must re-execute to produce updated content. const routeChanged = paramsChanged || searchChanged; defaultShouldRevalidate = routeChanged; defaultReason = paramsChanged ? "nav:params-changed" : searchChanged ? "nav:search-changed" : "nav:params-unchanged"; if (routeChanged) { debugLog("revalidation", "route revalidating", { segmentId: segment.id, paramsChanged, searchChanged, }); } } else if (segment.belongsToRoute && (paramsChanged || searchChanged)) { // Children of the route path (loaders, orphan layouts/parallels) // revalidate when path params or search params change defaultShouldRevalidate = true; defaultReason = paramsChanged ? "nav:route-child-params-changed" : "nav:route-child-search-changed"; debugLog("revalidation", "route child revalidating", { segmentId: segment.id, segmentType: segment.type, paramsChanged, searchChanged, }); } else { // Parent layouts and parallels default to no revalidation // Cannot assume these segments depend on params without explicit declaration // Use custom revalidation functions to opt-in when needed defaultShouldRevalidate = false; defaultReason = "nav:non-route-skip"; debugLog("revalidation", "non-route segment skipped by default", { segmentId: segment.id, segmentType: segment.type, }); } } // No custom revalidations defined - return default behavior without prev segment if (revalidations.length === 0) { if (defaultShouldRevalidate) { debugLog("revalidation", "default revalidate=true", { segmentId: segment.id, prevParams, nextParams, }); } else { debugLog("revalidation", "default revalidate=false", { segmentId: segment.id, }); } pushTrace(defaultShouldRevalidate, defaultShouldRevalidate, defaultReason); return defaultShouldRevalidate; } // Custom revalidations exist - may need full prev segment // Lazy load prev segment only if getPrevSegment provided const prevSegment = getPrevSegment ? await getPrevSegment() : null; // Execute revalidation functions with soft/hard decision pattern let currentSuggestion = defaultShouldRevalidate; // Compute public route names (filtered: undefined for auto-generated routes) const toRouteName = routeKey && !isAutoGeneratedRouteName(routeKey) ? routeKey : undefined; const reqCtx = _getRequestContext(); const prevRouteKey = reqCtx?._prevRouteKey; const fromRouteName = prevRouteKey && !isAutoGeneratedRouteName(prevRouteKey) ? prevRouteKey : undefined; for (const { name, fn } of revalidations) { const result = fn({ currentParams: prevSegment?.params || prevParams, // Use segment params if available, else route params currentUrl: prevUrl, nextParams, nextUrl, defaultShouldRevalidate: currentSuggestion, context, // Segment metadata (which segment is being evaluated) segmentType: segment.type, layoutName: segment.layoutName, slotName: segment.slot, // Action context (only populated when triggered by server action) actionId: actionContext?.actionId, isAction: makeIsAction(actionContext?.actionId), actionUrl: actionContext?.actionUrl, actionResult: actionContext?.actionResult, formData: actionContext?.formData, method: request.method, // GET for navigation, POST for actions routeName: toRouteName, // Navigation target route name (filtered) fromRouteName, // Navigation source route name (filtered) toRouteName, // Navigation target route name (filtered) // Stale cache context (only true for background revalidation after stale cache render) stale, }); // Check return type: // - boolean: hard decision, short-circuit immediately // - { defaultShouldRevalidate: boolean }: soft decision, update suggestion and continue // - null/undefined: use default behavior (equivalent to returning { defaultShouldRevalidate }) if (typeof result === "boolean") { // Hard decision - short-circuit debugLog("revalidation", "hard decision", { segmentId: segment.id, revalidator: name, revalidate: result, }); pushTrace(defaultShouldRevalidate, result, `hard:${name}`); return result; } else if ( result && typeof result === "object" && "defaultShouldRevalidate" in result ) { // Soft decision - update suggestion and continue currentSuggestion = result.defaultShouldRevalidate; debugLog("revalidation", "soft decision", { segmentId: segment.id, revalidator: name, revalidate: currentSuggestion, }); } else if (result === null || result === undefined) { // Defer to default - equivalent to { defaultShouldRevalidate: currentSuggestion } // This means "I don't care, use whatever the default is" debugLog("revalidation", "deferred to current default", { segmentId: segment.id, revalidator: name, revalidate: currentSuggestion, }); // currentSuggestion stays the same, continue to next function } } // All revalidators completed - use final suggestion debugLog("revalidation", "final decision", { segmentId: segment.id, revalidate: currentSuggestion, }); const softNames = revalidations.map((r) => r.name).join(","); pushTrace( defaultShouldRevalidate, currentSuggestion, `soft-chain:${softNames}`, ); return currentSuggestion; }