@rangojs/router 0.0.0-experimental.140 → 0.0.0-experimental.141

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Integrated PPR shell serving (Axis 2, see docs/design/ppr-shell-resume.md).
3
+ *
4
+ * PPR is opt-in per PAGE ROUTE via the `ppr` path option
5
+ * (`path(pattern, Handler, { name, ppr: true | PartialPrerenderProps })`) and the
6
+ * serving logic is INTEGRAL to the render pipeline — there is no middleware to
7
+ * mount. This module owns the config/key/store plumbing the render layer
8
+ * (rsc-rendering.ts) uses at its COMMIT POINT, which sits after the WHOLE
9
+ * middleware chain (global `router.use()` chain AND route DSL `middleware()`,
10
+ * both of which wrap the render pass): any middleware rejection/redirect wins
11
+ * before a single shell byte is written.
12
+ *
13
+ * The shell store is the app-level `createRouter({ cache })` store
14
+ * (`requestCtx._cacheStore`). A store without the `getShell`/`putShell` family
15
+ * degrades a ppr route to axis 1 with a once-per-key warning (the declared
16
+ * intent cannot be honored — unlike an undeclared route, which is silent).
17
+ */
18
+
19
+ import React from "react";
20
+ import type { EntryData } from "../server/context.js";
21
+ import { sortedSearchString } from "../cache/cache-key-utils.js";
22
+ import type { ShellCacheEntry, SegmentCacheStore } from "../cache/types.js";
23
+
24
+ /** Debug/status header the browser (and e2e assertions) can read: HIT | MISS. */
25
+ export const SHELL_STATUS_HEADER = "x-rango-shell";
26
+
27
+ /**
28
+ * Default shell ttl (seconds) for `ppr: true` and for a PartialPrerenderProps
29
+ * that omits `ttl`.
30
+ */
31
+ export const DEFAULT_PPR_TTL_SECONDS = 300;
32
+
33
+ /** The route's ppr option normalized to a concrete policy. */
34
+ export interface ResolvedPprConfig {
35
+ ttl: number;
36
+ swr?: number;
37
+ tags?: string[];
38
+ }
39
+
40
+ /**
41
+ * Normalize the matched page route's `ppr` path option. Returns null when the
42
+ * route does not declare `ppr` (or declares `ppr: false`) — the caller then does
43
+ * NOTHING: no store read, no capture, no logs. Pure axis 1, zero cost.
44
+ *
45
+ * PPR is a DOCUMENT-level property of the page route; there is no subtree
46
+ * inheritance (declaring it on a layout is not supported — a follow-up).
47
+ */
48
+ export function resolvePprConfig(
49
+ entry: EntryData | undefined | null,
50
+ ): ResolvedPprConfig | null {
51
+ if (!entry || entry.type !== "route") return null;
52
+ const ppr = entry.ppr;
53
+ if (ppr === undefined || ppr === false) return null;
54
+ if (ppr === true) return { ttl: DEFAULT_PPR_TTL_SECONDS };
55
+ return {
56
+ ttl: ppr.ttl ?? DEFAULT_PPR_TTL_SECONDS,
57
+ swr: ppr.swr,
58
+ tags: ppr.tags,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Shell cache key: host + pathname + sorted search + a `:shell` namespace suffix
64
+ * (so it can never collide with a document-cache key; the store further isolates
65
+ * the shell family internally).
66
+ *
67
+ * The key includes the request HOST: in a multi-tenant host-router deployment
68
+ * (one worker, one shared KV/runtime-cache store) a host-less key would serve
69
+ * tenant A's captured shell to tenant B's users.
70
+ */
71
+ export function buildShellKey(url: URL): string {
72
+ const sorted = sortedSearchString(url.searchParams);
73
+ const searchSuffix = sorted ? `?${sorted}` : "";
74
+ return `${url.host}${url.pathname}${searchSuffix}:shell`;
75
+ }
76
+
77
+ /**
78
+ * React version captured at prerender time is the invalidation gate: a stored
79
+ * shell whose reactVersion differs from the running React cannot be resumed (the
80
+ * postponed blob is build-coupled), so it is treated as a miss — the recapture
81
+ * overwrites the same key and the entry otherwise ages out via TTL.
82
+ */
83
+ export function isValidShellHit(entry: ShellCacheEntry): boolean {
84
+ return entry.reactVersion === React.version;
85
+ }
86
+
87
+ /** Decode a base64 prelude back into bytes for stream composition. */
88
+ export function base64ToBytes(b64: string): Uint8Array {
89
+ const binary = atob(b64);
90
+ const bytes = new Uint8Array(binary.length);
91
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
92
+ return bytes;
93
+ }
94
+
95
+ /** True when the store implements the shell entry family. */
96
+ export function hasShellFamily(
97
+ store: SegmentCacheStore | undefined,
98
+ ): store is SegmentCacheStore & {
99
+ getShell: NonNullable<SegmentCacheStore["getShell"]>;
100
+ putShell: NonNullable<SegmentCacheStore["putShell"]>;
101
+ } {
102
+ return !!store?.getShell && !!store?.putShell;
103
+ }
104
+
105
+ /** Keys already warned about a missing shell store family (once per key). */
106
+ const warnedMissingStore = new Set<string>();
107
+
108
+ /**
109
+ * Warn once per key that a route declared `ppr` but the app-level cache store
110
+ * does not implement the shell family (getShell/putShell), so the route stays on
111
+ * axis 1. Unlike an undeclared route (silent), a declared route that cannot be
112
+ * honored deserves a diagnostic.
113
+ */
114
+ export function warnShellStoreMissingOnce(key: string): void {
115
+ if (warnedMissingStore.has(key)) return;
116
+ warnedMissingStore.add(key);
117
+ console.warn(
118
+ `[rango] Route for "${key}" declares the ppr path option, but the app-level ` +
119
+ "cache store does not implement the shell family (getShell/putShell), so " +
120
+ "the route is served on axis 1 without a shell. Use MemorySegmentCacheStore, " +
121
+ "CFCacheStore, or VercelCacheStore (or add the family to your custom store) " +
122
+ "via createRouter({ cache }).",
123
+ );
124
+ }
@@ -233,6 +233,13 @@ export type EntryData =
233
233
  staticHandlerId?: string;
234
234
  /** Response type for non-RSC routes (json, text, image, any) */
235
235
  responseType?: string;
236
+ /**
237
+ * PPR (partial pre-rendering) opt-in from the path() `ppr` option. A
238
+ * document-level property of the page route: `true` uses the default
239
+ * shell policy, an object carries ttl/swr/tags. Read by the integrated
240
+ * PPR serve path (rsc/shell-serve.ts resolvePprConfig).
241
+ */
242
+ ppr?: boolean | import("../urls/pattern-types.js").PartialPrerenderProps;
236
243
  } & EntryPropCommon &
237
244
  EntryPropDatas &
238
245
  EntryPropSegments &
@@ -50,7 +50,6 @@ import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
50
50
  import type { ExecutionContext, RequestScope } from "../types/request-scope.js";
51
51
  import type { TransitionWhenFn } from "../types/segments.js";
52
52
  import type { ResolvedTracing } from "../router/tracing.js";
53
- import { fireAndForgetWaitUntil } from "../types/request-scope.js";
54
53
  import {
55
54
  THEME_COOKIE,
56
55
  isValidTheme,
@@ -174,49 +173,17 @@ export interface RequestContext<
174
173
  /** @internal Cache store for segment caching (optional, used by CacheScope) */
175
174
  _cacheStore?: SegmentCacheStore;
176
175
 
177
- /**
178
- * @internal PPR shell-resume signal. Set by the shell-cache middleware on a
179
- * validated shell HIT before it awaits next(); read in the render orchestration
180
- * (rsc-rendering) to call the resume strategy instead of a full fizz render. The
181
- * middleware sets it optimistically — the render layer is the final authority
182
- * (nonce/formState/allReady bypass) and marks the response only when it actually
183
- * resumed. Single-request; the middleware clears it in a finally.
184
- */
185
- _shellResume?: { postponed: string | null };
186
-
187
- /**
188
- * @internal PPR shell-capture DESCRIPTOR ("a capture of this document is
189
- * wanted"). Set by the shell-cache middleware BEFORE its single foreground
190
- * next() on a MISS or SWR stale hit, and read by the render orchestration
191
- * (rsc-rendering) AFTER the response is built to schedule a background capture
192
- * task. Its mere presence must NOT change the foreground render — loader
193
- * masking and the cookie/header capture guard key off `_shellCaptureRun`, not
194
- * this descriptor. `store` carries the SAME store the middleware resolved for
195
- * its getShell read (options.store ?? _cacheStore), so a store-attached
196
- * middleware writes captures where it reads them; without it an explicit
197
- * options.store distinct from the app-level _cacheStore would read one store
198
- * and write another and the shell would never HIT. `tags` is left unset here —
199
- * the background capture collects the shell's own (non-loader) request tags
200
- * from its derived render. Single-request; the middleware clears it in a
201
- * finally after next() settles.
202
- */
203
- _shellCapture?: {
204
- key: string;
205
- ttl?: number;
206
- swr?: number;
207
- tags?: string[];
208
- store?: SegmentCacheStore<any>;
209
- };
210
-
211
176
  /**
212
177
  * @internal PPR shell-capture ACTIVE marker. True ONLY inside the background
213
178
  * capture task's derived request context (built by shell-capture.ts). This is
214
179
  * the switch every capture-specific behavior reads: loader masking
215
180
  * (loader-mask.ts isShellCaptureActive / fresh.ts emitStreaming) and the
216
181
  * cookies()/headers() capture guard (cookie-store.ts
217
- * assertNotInsideShellCapture). The foreground render never sets it — even when
218
- * `_shellCapture` (the "capture wanted" descriptor) is present — so the served
219
- * response is byte-identical to axis 1.
182
+ * assertNotInsideShellCapture). The foreground render never sets it, so the
183
+ * served response is byte-identical to axis 1. The capture descriptor itself
184
+ * (key/ttl/swr/tags/store) is NOT threaded through the request context — the
185
+ * integrated PPR serve path (rsc/shell-serve.ts + rsc-rendering.ts) builds it
186
+ * locally and passes it to scheduleShellCapture directly.
220
187
  */
221
188
  _shellCaptureRun?: boolean;
222
189
 
@@ -267,6 +234,19 @@ export interface RequestContext<
267
234
  /** @internal Registered onResponse callbacks */
268
235
  _onResponseCallbacks: Array<(response: Response) => Response>;
269
236
 
237
+ /**
238
+ * @internal Promises of the background tasks scheduled via this context's
239
+ * waitUntil (deferred cache writes, revalidations, consumer tasks). The PPR
240
+ * shell capture drains this list BEFORE its match/render as an ORDERING EDGE:
241
+ * every foreground deferred cache write is scheduled here before the capture
242
+ * task is, so settling the list first guarantees the capture's cache reads
243
+ * observe the foreground's generation instead of racing it (see
244
+ * shell-capture.ts). Tasks whose scheduling fn carries
245
+ * UNTRACKED_BACKGROUND_TASK are not tracked (the capture task itself —
246
+ * tracking it would make that drain await its own promise).
247
+ */
248
+ _pendingBackgroundTasks?: Array<Promise<unknown>>;
249
+
270
250
  /**
271
251
  * Current theme setting (only available when theme is enabled in router config)
272
252
  *
@@ -495,8 +475,6 @@ export type PublicRequestContext<
495
475
  | "_handleStore"
496
476
  | "_transitionWhen"
497
477
  | "_cacheStore"
498
- | "_shellResume"
499
- | "_shellCapture"
500
478
  | "_shellCaptureRun"
501
479
  | "_explicitTaggedStores"
502
480
  | "_requestTags"
@@ -535,6 +513,17 @@ export type PublicRequestContext<
535
513
  | "res"
536
514
  >;
537
515
 
516
+ /**
517
+ * Marker for a waitUntil-scheduled fn whose task promise must NOT enter
518
+ * _pendingBackgroundTasks. Used by the PPR shell capture for its own task:
519
+ * the capture's pre-render write barrier settles that list, so tracking the
520
+ * capture itself would make the drain wait on its own (still-running) promise.
521
+ * @internal
522
+ */
523
+ export const UNTRACKED_BACKGROUND_TASK: unique symbol = Symbol.for(
524
+ "rango.untrackedBackgroundTask",
525
+ );
526
+
538
527
  // AsyncLocalStorage instance for request context
539
528
  const requestContextStorage = new AsyncLocalStorage<RequestContext<any>>();
540
529
 
@@ -946,20 +935,37 @@ export function createRequestContext<TEnv>(
946
935
  _cacheProfiles: cacheProfiles,
947
936
 
948
937
  waitUntil(fn: () => Promise<void>): void {
938
+ // Wrap in Promise.resolve().then(fn) so a SYNCHRONOUS throw in a
939
+ // non-async callback becomes a rejected promise handed to the host's
940
+ // waitUntil (logged as a background failure), instead of escaping into
941
+ // the request flow. Mirrors fireAndForgetWaitUntil's deferral.
942
+ const task = Promise.resolve().then(fn);
943
+ // Track the task promise so the PPR shell capture can settle the
944
+ // foreground's deferred cache writes before its own match/render (the
945
+ // ordering edge; see _pendingBackgroundTasks). The capture task itself
946
+ // opts out via the marker — the drain must never await its own promise.
947
+ if (
948
+ !(fn as { [UNTRACKED_BACKGROUND_TASK]?: boolean })[
949
+ UNTRACKED_BACKGROUND_TASK
950
+ ]
951
+ ) {
952
+ ctx._pendingBackgroundTasks?.push(task);
953
+ }
949
954
  if (executionContext?.waitUntil) {
950
- // Wrap in Promise.resolve().then(fn) so a SYNCHRONOUS throw in a
951
- // non-async callback becomes a rejected promise handed to the host's
952
- // waitUntil (logged as a background failure), instead of escaping into
953
- // the request flow. Mirrors fireAndForgetWaitUntil's deferral.
954
- executionContext.waitUntil(Promise.resolve().then(fn));
955
+ executionContext.waitUntil(task);
955
956
  } else {
956
- fireAndForgetWaitUntil(fn);
957
+ // Node/dev fallback: fire-and-forget with error logging (the same
958
+ // policy fireAndForgetWaitUntil applies).
959
+ task.catch((err) =>
960
+ console.error("[waitUntil] Background task failed:", err),
961
+ );
957
962
  }
958
963
  },
959
964
 
960
965
  executionContext,
961
966
 
962
967
  _onResponseCallbacks: [],
968
+ _pendingBackgroundTasks: [],
963
969
 
964
970
  onResponse(callback: (response: Response) => Response): void {
965
971
  assertNotInsideCacheExec(ctx, "onResponse");
package/src/ssr/index.tsx CHANGED
@@ -173,13 +173,25 @@ export interface SSRDependencies<TEnv = unknown> {
173
173
  const DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS = 5000;
174
174
 
175
175
  /**
176
- * Fixed number of macrotask hops between `quiesce` resolving and the abort. By
177
- * the time `quiesce` resolves the Flight input is byte-quiet and frozen, so
178
- * these hops are deterministic: they only give React's fizz worker turns to
179
- * flush the settled shell and mark still-pending boundaries as POSTPONED (rather
180
- * than errored) before controller.abort() lands. Not a wall-clock wait.
176
+ * Fixed number of macrotask hops between `quiesce` resolving and the abort. These
177
+ * give React's fizz worker turns to flush the settled shell into the prelude and
178
+ * mark still-pending boundaries as POSTPONED (rather than errored) before
179
+ * controller.abort() lands. Not a wall-clock wait.
180
+ *
181
+ * Why 16 and not the original 2: under the REPLAY-ONLY capture model
182
+ * (docs/design/ppr-shell-resume.md), the capture Flight render serializes ring-3
183
+ * cached segments that are ALREADY serialized, so it emits the whole shell payload
184
+ * in the first tick and the gate declares quiesce almost immediately (~a few ms).
185
+ * On the old fresh-execution path the Flight dribbled out as handlers ran, so
186
+ * Flight-quiet effectively meant "the shell has rendered" and 2 hops sufficed. Under
187
+ * replay, Flight-quiet fires BEFORE the fizz side has consumed the instant payload
188
+ * and rendered the shell to `<body>`, so the fizz needs a real buffer of turns after
189
+ * quiesce — otherwise the abort lands on an unrendered tree (empty prelude, root
190
+ * postpone) and the sanity gate refuses. Still task-based (masked loaders never
191
+ * emit, so more hops never lets a hole settle); a cold worker whose first
192
+ * attempt still under-renders heals on the in-place retry. Bounded by maxWaitMs.
181
193
  */
182
- const POST_QUIESCE_TASK_HOPS = 2;
194
+ const POST_QUIESCE_TASK_HOPS = 16;
183
195
 
184
196
  /**
185
197
  * Route an SSR error through the deps.onError notification callback with the
@@ -398,6 +410,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
398
410
  ) {
399
411
  const { createFromReadableStream, loadBootstrapScriptContent, prerender } =
400
412
  deps;
413
+ const onError = deps.onError;
401
414
 
402
415
  if (!prerender) {
403
416
  throw new Error(
@@ -437,6 +450,25 @@ export function createShellCaptureHandler<TEnv = unknown>(
437
450
  const prerenderPromise = prerender(<SsrRoot />, {
438
451
  signal: controller.signal,
439
452
  bootstrapScriptContent,
453
+ // Abort is how capture WORKS: once the shell is quiet we abort() to freeze
454
+ // the prelude and let the still-pending holes postpone. React reports the
455
+ // abort reason for each pending boundary through onError. Without an onError
456
+ // here React falls back to console.error, so every capture that still has a
457
+ // live hole at abort time (the normal case, and every cold-module capture
458
+ // where the shell is not yet done) dumps a DOMException [AbortError] stack —
459
+ // once per pending boundary. That is EXPECTED degradation, not a failure, so
460
+ // swallow the abort here. Genuine shell render errors (a component throwing)
461
+ // are NOT the abort and still surface through the deps.onError channel, the
462
+ // same one renderHTML uses. See docs/design/ppr-shell-resume.md.
463
+ onError: (error: unknown) => {
464
+ if (
465
+ controller.signal.aborted &&
466
+ (error as { name?: string } | null)?.name === "AbortError"
467
+ ) {
468
+ return;
469
+ }
470
+ reportRenderError(onError, error);
471
+ },
440
472
  });
441
473
 
442
474
  // Wait for the caller's quiesce signal. By the time it resolves the Flight
@@ -109,27 +109,6 @@ function applyThemeToDocument(theme: Theme, config: ResolvedThemeConfig): void {
109
109
  }
110
110
  }
111
111
 
112
- function getStoredTheme(config: ResolvedThemeConfig): Theme {
113
- const { storageKey, themes, defaultTheme, enableSystem } = config;
114
-
115
- let stored = readThemeFromCookie(storageKey);
116
-
117
- if (!stored) {
118
- stored = readThemeFromStorage(storageKey);
119
- }
120
-
121
- if (stored) {
122
- if (stored === "system" && enableSystem) {
123
- return "system";
124
- }
125
- if (themes.includes(stored)) {
126
- return stored as Theme;
127
- }
128
- }
129
-
130
- return defaultTheme;
131
- }
132
-
133
112
  export function ThemeProvider({
134
113
  config,
135
114
  initialTheme,
@@ -137,17 +116,48 @@ export function ThemeProvider({
137
116
  }: ThemeProviderProps): React.ReactNode {
138
117
  const [mounted, setMounted] = useState(false);
139
118
 
140
- const [theme, setThemeState] = useState<Theme>(() => {
141
- if (initialTheme) return initialTheme;
142
- if (typeof window === "undefined") return config.defaultTheme;
143
- return getStoredTheme(config);
144
- });
119
+ // HYDRATION PARITY: this initializer is the server (SSR/resume) render AND
120
+ // the client's hydration render — both must produce the same value. It must
121
+ // NEVER read cookie/localStorage: whenever the payload's initialTheme
122
+ // differs from the visitor's stored theme (a PPR shell HIT deliberately
123
+ // replays the CAPTURE's initialTheme), a storage-reading initializer makes
124
+ // the client's first render diverge from the server tree. Any raw-theme text
125
+ // (a toggle label) then fails hydration, React regenerates the tree, and the
126
+ // FOUC-applied class is wiped from <html>. The visitor's stored theme is
127
+ // applied by the post-mount re-sync effect below instead.
128
+ const [theme, setThemeState] = useState<Theme>(
129
+ () => initialTheme ?? config.defaultTheme,
130
+ );
145
131
 
146
132
  const [systemTheme, setSystemTheme] = useState<ResolvedTheme>("light");
147
133
 
148
134
  useEffect(() => {
149
135
  setMounted(true);
150
136
  setSystemTheme(getSystemTheme());
137
+ // Re-sync state from an EXPLICITLY stored theme after mount. initialTheme
138
+ // comes from the payload and can legitimately differ from the visitor's
139
+ // stored theme — on a PPR shell HIT it is deliberately the CAPTURE's theme
140
+ // (the resume tree must match the frozen prelude; see
141
+ // ShellCacheEntry.initialTheme). The FOUC script already applied the stored
142
+ // theme to the document pre-paint; this brings the provider state (toggles,
143
+ // useTheme readers) in line with it, and is the ONLY place the provider
144
+ // reads storage (the initializer must not — see the parity note above).
145
+ // Only an explicit cookie/localStorage value re-syncs — a defaultTheme
146
+ // fallback must not override a server-provided initialTheme when the
147
+ // visitor never chose a theme.
148
+ // First VALID candidate wins — an empty/garbage cookie value (e.g. a
149
+ // deleted cookie leaving "theme=") must not shadow a valid localStorage
150
+ // value behind a bare null-coalesce.
151
+ const explicit =
152
+ [
153
+ readThemeFromCookie(config.storageKey),
154
+ readThemeFromStorage(config.storageKey),
155
+ ].find((v) => v !== null && isValidTheme(v, config)) ?? null;
156
+ if (explicit !== null && explicit !== theme) {
157
+ setThemeState(explicit as Theme);
158
+ applyThemeToDocument(explicit as Theme, config);
159
+ }
160
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
161
  }, []);
152
162
 
153
163
  const setTheme = useCallback(
package/src/urls/index.ts CHANGED
@@ -11,6 +11,7 @@ export type {
11
11
  UnnamedRoute,
12
12
  LocalOnlyInclude,
13
13
  PathOptions,
14
+ PartialPrerenderProps,
14
15
  UrlPatterns,
15
16
  IncludeOptions,
16
17
  } from "./pattern-types.js";
@@ -175,6 +175,11 @@ export function createPathHelper<TEnv>(): PathFn<TEnv> {
175
175
  ...(resolveResponseType(options)
176
176
  ? { responseType: resolveResponseType(options) }
177
177
  : {}),
178
+ // PPR shell-caching opt-in (document-level). Stored raw; the integrated
179
+ // serve path normalizes it via resolvePprConfig (rsc/shell-serve.ts).
180
+ ...(options?.ppr !== undefined && options.ppr !== false
181
+ ? { ppr: options.ppr }
182
+ : {}),
178
183
  };
179
184
 
180
185
  if (isStaticHandler(handler) && handler.$$id && ctx.namePrefix) {
@@ -35,12 +35,48 @@ export type LocalOnlyInclude = string & { [LOCAL_ONLY_BRAND]: void };
35
35
  /**
36
36
  * Options for path() function
37
37
  */
38
+ /**
39
+ * Options for the `ppr` path option (PPR shell caching — Axis 2, see
40
+ * docs/design/ppr-shell-resume.md and the /ppr skill). Declaring
41
+ * `ppr: true | PartialPrerenderProps` on a page route opts that DOCUMENT into
42
+ * shell capture: the rendered HTML shell (everything that is not a live hole) is
43
+ * cached and, on a later GET, flushed immediately while fizz resumes only the
44
+ * holes. Serving is integral to the router — there is no middleware to mount;
45
+ * the shell store is the app-level `createRouter({ cache })` store (which must
46
+ * implement the `getShell`/`putShell` family).
47
+ */
48
+ export interface PartialPrerenderProps {
49
+ /**
50
+ * Shell time-to-live in seconds. Defaults to 300 (`ppr: true` uses the same
51
+ * default).
52
+ */
53
+ ttl?: number;
54
+ /**
55
+ * Stale-while-revalidate window in seconds: a stale shell is still served
56
+ * while a background recapture refreshes it.
57
+ */
58
+ swr?: number;
59
+ /**
60
+ * Operational tags attached to the captured shell entry for
61
+ * `updateTag()`/`revalidateTag()`-driven eviction. UNIONED with the tags the
62
+ * capture render auto-collects (the shell's own non-loader request tags).
63
+ */
64
+ tags?: string[];
65
+ }
66
+
38
67
  export interface PathOptions<
39
68
  TName extends string = string,
40
69
  TSearch extends SearchSchema = {},
41
70
  > {
42
71
  /** Route name for href() lookups */
43
72
  name?: TName;
73
+ /**
74
+ * PPR shell caching opt-in for this page route (document-level). `true` uses
75
+ * the default policy (ttl 300); an object sets ttl/swr/tags. See
76
+ * {@link PartialPrerenderProps}. Routes without this option are pure axis 1 —
77
+ * no capture, no store reads, no logs.
78
+ */
79
+ ppr?: boolean | PartialPrerenderProps;
44
80
  /** Search param schema for typed query parameters */
45
81
  search?: TSearch;
46
82
  /** Trailing slash behavior: "never" (redirect /path/ to /path), "always" (redirect /path to /path/), "ignore" (match both) */