@rangojs/router 0.6.0 → 0.8.0

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.
@@ -7,7 +7,11 @@ import {
7
7
  type InterceptSelectorContext,
8
8
  } from "../server/context.js";
9
9
  import type { LoaderDefinition, TrailingSlashMode } from "../types.js";
10
- import type { PathOptions, UrlPatterns } from "../urls/pattern-types.js";
10
+ import type {
11
+ PartialPrerenderProps,
12
+ PathOptions,
13
+ UrlPatterns,
14
+ } from "../urls/pattern-types.js";
11
15
  import type { PathHelpers } from "../urls/path-helper-types.js";
12
16
  import type { AllUseItems } from "../route-types.js";
13
17
  import { urls } from "../urls/urls-function.js";
@@ -45,6 +49,14 @@ const PROJECTION_OPTIONS = new Set<PropertyKey>([
45
49
  "name",
46
50
  "search",
47
51
  "trailingSlash",
52
+ "ppr",
53
+ ]);
54
+
55
+ const PPR_NUMBER_KEYS = new Set<PropertyKey>([
56
+ "ttl",
57
+ "swr",
58
+ "captureTimeout",
59
+ "maxSnapshotBytes",
48
60
  ]);
49
61
 
50
62
  const clientUrlProjections = new Map<string, ClientUrlProjection>();
@@ -59,6 +71,15 @@ export type ClientUrlDefinitionSource = ClientUrlReference | ClientUrlPatterns;
59
71
  export interface ClientUrlProjectionOptions {
60
72
  readonly search?: Readonly<Record<string, SearchSchemaValue>>;
61
73
  readonly trailingSlash?: TrailingSlashMode;
74
+ /**
75
+ * The `ppr` path option, JSON-projected. Materialization passes it onto
76
+ * the group route's server `path()` verbatim, so the manifest entry
77
+ * carries it and the runtime shell capture/serve lanes engage exactly as
78
+ * for a hand-written server route (search is part of shell identity —
79
+ * the capture render seeds the key's own search, so group static parts
80
+ * may read useSearchParams).
81
+ */
82
+ readonly ppr?: true | Readonly<PartialPrerenderProps>;
62
83
  }
63
84
 
64
85
  export interface ClientUrlProjectionRoute {
@@ -138,9 +159,6 @@ function serializeOptions(
138
159
  if (!source) return Object.freeze({});
139
160
 
140
161
  for (const key of Reflect.ownKeys(source)) {
141
- if (key === "ppr") {
142
- throw projectionError(route, 'path option "ppr" is not supported');
143
- }
144
162
  if (!PROJECTION_OPTIONS.has(key)) {
145
163
  throw projectionError(
146
164
  route,
@@ -152,6 +170,7 @@ function serializeOptions(
152
170
  const options: {
153
171
  search?: Readonly<Record<string, SearchSchemaValue>>;
154
172
  trailingSlash?: TrailingSlashMode;
173
+ ppr?: true | Readonly<PartialPrerenderProps>;
155
174
  } = {};
156
175
  if (source.search !== undefined) {
157
176
  options.search = serializeSearchSchema(route, source.search);
@@ -159,9 +178,72 @@ function serializeOptions(
159
178
  if (source.trailingSlash !== undefined) {
160
179
  options.trailingSlash = source.trailingSlash;
161
180
  }
181
+ if (source.ppr !== undefined) {
182
+ options.ppr = serializePpr(route, source.ppr);
183
+ }
162
184
  return Object.freeze(options);
163
185
  }
164
186
 
187
+ /**
188
+ * Serialize the `ppr` path option for the projection. The DSL already
189
+ * validated shape in the authoring module (validateClientPpr in
190
+ * client-urls.ts); this re-validates independently and re-copies
191
+ * defensively — the projection must carry only JSON, and the copy keeps a
192
+ * later mutation of the consumer's options object from leaking into the
193
+ * frozen projection.
194
+ */
195
+ function serializePpr(
196
+ route: ClientUrlRouteRecord,
197
+ source: unknown,
198
+ ): true | Readonly<PartialPrerenderProps> {
199
+ if (source === true) return true;
200
+ if (typeof source !== "object" || source === null || Array.isArray(source)) {
201
+ throw projectionError(
202
+ route,
203
+ 'path option "ppr" must be true or a PartialPrerenderProps object',
204
+ );
205
+ }
206
+ const out: {
207
+ ttl?: number;
208
+ swr?: number;
209
+ captureTimeout?: number;
210
+ maxSnapshotBytes?: number;
211
+ tags?: readonly string[];
212
+ } = {};
213
+ for (const key of Reflect.ownKeys(source)) {
214
+ if (PPR_NUMBER_KEYS.has(key)) {
215
+ const value = (source as Record<PropertyKey, unknown>)[key];
216
+ if (typeof value !== "number" || !Number.isFinite(value)) {
217
+ throw projectionError(
218
+ route,
219
+ `path option "ppr.${String(key)}" must be a finite number`,
220
+ );
221
+ }
222
+ out[key as "ttl" | "swr" | "captureTimeout" | "maxSnapshotBytes"] = value;
223
+ continue;
224
+ }
225
+ if (key === "tags") {
226
+ const tags = (source as { tags: unknown }).tags;
227
+ if (
228
+ !Array.isArray(tags) ||
229
+ tags.some((tag) => typeof tag !== "string" || tag === "")
230
+ ) {
231
+ throw projectionError(
232
+ route,
233
+ 'path option "ppr.tags" must be an array of non-empty strings',
234
+ );
235
+ }
236
+ out.tags = Object.freeze([...tags]);
237
+ continue;
238
+ }
239
+ throw projectionError(
240
+ route,
241
+ `path option "ppr.${String(key)}" is not supported`,
242
+ );
243
+ }
244
+ return Object.freeze(out) as Readonly<PartialPrerenderProps>;
245
+ }
246
+
165
247
  function serializeTransition(
166
248
  route: ClientUrlRouteRecord,
167
249
  ): Readonly<ClientTransitionConfig> | undefined {
@@ -393,6 +475,15 @@ function materializedPathOptions(route: ClientUrlProjectionRoute): PathOptions {
393
475
  ...(route.options.trailingSlash
394
476
  ? { trailingSlash: route.options.trailingSlash }
395
477
  : {}),
478
+ // ppr rides onto the materialized server path() verbatim: path-helper
479
+ // stamps it on the manifest entry, so resolvePprConfig classifies the
480
+ // group route exactly like a hand-written ppr page. Shallow-copy the
481
+ // object form — path-helper receives a mutable PathOptions.
482
+ ...(route.options.ppr !== undefined
483
+ ? {
484
+ ppr: route.options.ppr === true ? true : { ...route.options.ppr },
485
+ }
486
+ : {}),
396
487
  };
397
488
  }
398
489
 
@@ -28,7 +28,10 @@ export type ClientUrlUse = () => ClientUrlItems;
28
28
  export type ClientPathOptions<
29
29
  TName extends string = string,
30
30
  TSearch extends SearchSchema = SearchSchema,
31
- > = Pick<PathOptions<TName, TSearch>, "name" | "search" | "trailingSlash">;
31
+ > = Pick<
32
+ PathOptions<TName, TSearch>,
33
+ "name" | "search" | "trailingSlash" | "ppr"
34
+ >;
32
35
 
33
36
  export type ClientPathFn = <
34
37
  const TPattern extends string,
@@ -141,7 +144,10 @@ export interface ClientUrlHelpers {
141
144
  * Pass `{ stream: "navigation" }` to await this loader before first flush
142
145
  * on DOCUMENT requests (see {@link LoaderOptions}) — the opt-in for loaders
143
146
  * whose data, handle pushes, or thrown notFound()/redirect() must be in the
144
- * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming.
147
+ * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming. Under a
148
+ * `ppr` group route the flag BAKES: the loader executes at shell capture
149
+ * and its settled return freezes into the shell (nested promises stay
150
+ * live holes).
145
151
  */
146
152
  readonly loader: <TData>(
147
153
  definition: LoaderDefinition<TData>,
@@ -148,7 +148,13 @@ export async function resolveLoaders<TEnv>(
148
148
  loaderEntry,
149
149
  ctx,
150
150
  ctx.pathname,
151
- bakeLane ? segmentId : null,
151
+ // The bake key rides for flagged loaders too: stream:"navigation"
152
+ // bakes at capture regardless of the entry's loading() lane
153
+ // (loader-cache.ts capture branch) and its HIT-tail seed
154
+ // overlay needs the same key.
155
+ bakeLane || loaderEntry.awaitBeforeFlush === true
156
+ ? segmentId
157
+ : null,
152
158
  ),
153
159
  ),
154
160
  entry,
@@ -191,7 +197,7 @@ export async function resolveLoaders<TEnv>(
191
197
  loaderEntry,
192
198
  ctx,
193
199
  ctx.pathname,
194
- bakeLane ? segmentId : null,
200
+ bakeLane || loaderEntry.awaitBeforeFlush === true ? segmentId : null,
195
201
  ),
196
202
  ),
197
203
  entry,
@@ -157,19 +157,40 @@ export function resolveLoaderData<TEnv>(
157
157
  // shell HIT the recorded container is overlaid onto the fresh run so the
158
158
  // payload matches the frozen prelude byte-for-byte.
159
159
  if (isShellCaptureActive(reqCtx)) {
160
- // EXPERIMENT (streaming useLoader, feat/useloader-suspense): route
161
- // loaders are LIVE at capture UNCONDITIONALLY — the loading()-keyed lane
162
- // trigger is suspended on this branch. Read-site suspension postpones
163
- // each masked stream at the consumer's own Suspense boundary, so holes
164
- // no longer require a route-level loading(); baking route data into a
165
- // shell is expressed via cache()/"use cache", not by omitting loading().
166
- // The former bake-at-capture path (execute + nested-thenable mask +
167
- // snapshot pinning via _shellCaptureLoaderRecords, see git history) is
168
- // bypassed: with no records registered the serve-side seed overlay is a
169
- // no-op and every HIT runs loaders fresh. A masked reader with NO
170
- // Suspense above it root-postpones and the capture's <body> sanity gate
171
- // refuses the shell (runtime render + eternal-MISS warning), which is
172
- // the intended degrade for boundary-less ppr routes.
160
+ // Capture lane, per LOADER (not per entry):
161
+ //
162
+ // - `stream: "navigation"` (awaitBeforeFlush) the BAKE lane. The flag's
163
+ // document promise is "this loader's data is in the HTML before first
164
+ // flush"; under ppr the pre-flush HTML IS the frozen prelude, so the
165
+ // loader executes at capture and its SETTLED return bakes into the
166
+ // shell. Nested-promise SHAPE stays the liveness declaration: nested
167
+ // thenables are masked so their subtrees postpone as holes (per-request
168
+ // material must be promise-shaped the #692 cross-session scar). The
169
+ // masked container registers on _shellCaptureLoaderRecords: it holds
170
+ // the capture gate (bounded by ppr.captureTimeout) and pins into the
171
+ // shell snapshot, so a HIT tail replays the baked value and the
172
+ // hydration payload matches the frozen prelude byte-for-byte.
173
+ // - Every other loader — LIVE unconditionally: masked with a
174
+ // never-resolving promise, postponed at its boundary (loading() or an
175
+ // inline Suspense), streamed fresh per request. A masked reader with NO
176
+ // boundary above it root-postpones and the <body> sanity gate refuses
177
+ // the shell (eternal-MISS warning) — the degrade for boundary-less ppr
178
+ // routes. A route whose loaders are ALL flagged therefore needs no
179
+ // loading() at all: nothing masks, the shell captures complete.
180
+ if (loaderEntry.awaitBeforeFlush && bakeSegmentKey) {
181
+ const containerPromise = executeLoaderData(loaderEntry, ctx, pathname);
182
+ // Pre-attach a no-op catch: a bake-lane rejection during capture must
183
+ // surface through the drain's refusal (and the wrapper's error
184
+ // boundary), never as an unhandled rejection that can kill the worker
185
+ // before the drain probes this record.
186
+ containerPromise.catch(() => {});
187
+ const maskedPromise = containerPromise.then((container: unknown) =>
188
+ maskNestedContainerThenables(container),
189
+ );
190
+ maskedPromise.catch(() => {});
191
+ reqCtx?._shellCaptureLoaderRecords?.set(bakeSegmentKey, maskedPromise);
192
+ return maskedPromise;
193
+ }
173
194
  return createMaskedLoaderPromise();
174
195
  }
175
196
 
@@ -42,15 +42,15 @@ export function isShellCaptureActive(
42
42
  export { createMaskedLoaderPromise } from "./mask-nested.js";
43
43
 
44
44
  /**
45
- * Lane decision for an entry's loaders under PPR (the loading() value decides;
46
- * docs/design/loader-container-bake.md):
47
- *
48
- * - RENDERABLE loading() (the LoaderBoundary Suspense fallback) — the LIVE
49
- * lane: masked at capture, guaranteed fresh on every serve. Returns true.
50
- * - No loading() (absent, or explicitly `false`, incl. `loading(x, { ssr:
51
- * false })` under the SSR manifest) the BAKE lane: the loader executes
52
- * during capture, its settled container bakes, nested pending promises hole
53
- * at the consumer's own Suspense. Returns false.
45
+ * Entry-level lane input for an entry's loaders under PPR (the loading()
46
+ * value; docs/design/loader-container-bake.md). The CAPTURE decision itself
47
+ * is per LOADER in loader-cache.ts: a `stream: "navigation"`
48
+ * (awaitBeforeFlush) loader BAKES at capture regardless of this value — the
49
+ * flag's document promise ("data in the HTML before first flush") maps to
50
+ * the frozen prelude while every other loader is LIVE (masked at capture,
51
+ * fresh on every serve), postponing at loading() or an inline Suspense.
52
+ * This helper still gates which entries pass a bake segment key for the
53
+ * legacy loading()-less shape and the HIT-tail seed overlay.
54
54
  *
55
55
  * Mirrors segment-system's isRenderableLoading so the mask decision and the
56
56
  * tree's boundary placement can never disagree.
@@ -240,7 +240,9 @@ export async function resolveLoadersWithRevalidation<TEnv>(
240
240
  loaderEntry,
241
241
  ctx,
242
242
  ctx.pathname,
243
- bakeLane ? segmentId : null,
243
+ bakeLane || loaderEntry.awaitBeforeFlush === true
244
+ ? segmentId
245
+ : null,
244
246
  ),
245
247
  ),
246
248
  entry,
@@ -306,20 +306,27 @@ export async function lookupBuildShell(
306
306
  const entry = record.entry;
307
307
  if (record.tags && record.tags.length > 0) {
308
308
  const check = store.isTagsInvalidatedSince;
309
- if (typeof check !== "function") {
309
+ if (typeof check !== "function" || store.tagHistoryInert) {
310
310
  // A tagged build entry on a store that cannot answer "was this tag
311
311
  // invalidated since the build" must not serve: updateTag() could
312
- // never evict it. Declared intent that cannot be honored deserves a
312
+ // never evict it. That covers a store missing the method AND one
313
+ // whose answers have no durable history behind them
314
+ // (SegmentCacheStore.tagHistoryInert — a KV-less CFCacheStore's
315
+ // memo-only answers would let the immutable asset resurrect on the
316
+ // next request). Declared intent that cannot be honored deserves a
313
317
  // diagnostic; the route keeps runtime-capture semantics.
314
318
  const key = buildShellManifestKey(url.pathname);
315
319
  if (!warnedTagCheckUnsupported.has(key)) {
316
320
  warnedTagCheckUnsupported.add(key);
317
321
  console.warn(
318
322
  `[rango] Build-time shell for "${url.pathname}" carries cache tags, but ` +
319
- "the app cache store does not implement isTagsInvalidatedSince(), so " +
320
- "updateTag() could not evict it. The entry is not served; the route " +
321
- "keeps runtime shell capture. Use MemorySegmentCacheStore, CFCacheStore, " +
322
- "or VercelCacheStore (or add the method to your custom store).",
323
+ "the app cache store cannot answer tag-invalidation history durably " +
324
+ (typeof check !== "function"
325
+ ? "(isTagsInvalidatedSince() is not implemented), "
326
+ : "(no durable tag history a CFCacheStore without a KV namespace), ") +
327
+ "so updateTag() could not evict it. The entry is not served; the route " +
328
+ "keeps runtime shell capture. Use MemorySegmentCacheStore, a KV-backed " +
329
+ "CFCacheStore, or VercelCacheStore (or add the method to your custom store).",
323
330
  );
324
331
  }
325
332
  return null;
@@ -509,9 +509,10 @@ export interface ShellCaptureDebugEvent {
509
509
  * the capture was not attempted
510
510
  * - skip-capacity: the isolate capture queue is full; a later request may retry
511
511
  * - skip-inert-store: the resolved store's shell family is missing or
512
- * declared inert (SegmentCacheStore.shellFamilyInert — a CFCacheStore
513
- * without a KV namespace); nothing could store the result, so the
514
- * background render was not scheduled at all
512
+ * declared inert (SegmentCacheStore.shellFamilyInert — a custom store
513
+ * whose backing tier is unavailable; built-in stores never declare it);
514
+ * nothing could store the result, so the background render was not
515
+ * scheduled at all
515
516
  * - skip-queue-timeout: the capture waited past CAPTURE_QUEUE_WAIT_BUDGET_MS
516
517
  * behind other captures and was dropped unrun (no backoff — the route is
517
518
  * not doomed, the isolate was busy; a later request re-probes). Carries
@@ -554,7 +555,7 @@ export interface ShellCaptureDebugEvent {
554
555
  /** A bake-lane loader settled into a shell that uses TTL/SWR-only invalidation. */
555
556
  untaggedBake?: true;
556
557
  /** Outcome reported by a store that supports shell-write acknowledgements. */
557
- storeWrite?: "stored" | "invalidated";
558
+ storeWrite?: "stored" | "invalidated" | "uncacheable";
558
559
  /** Consecutive failure count in the key's backoff entry, when one exists. */
559
560
  backoffFailures?: number;
560
561
  /** Ms remaining in the key's backoff window, when one exists. */
@@ -1005,7 +1006,8 @@ export function scheduleShellCapture(
1005
1006
  // serialized queue (a promise-heavy route bakes for seconds per MISS),
1006
1007
  // starving captures that CAN store. Skip scheduling entirely when the
1007
1008
  // resolved store (same defaulting as captureAndStoreShell) has no shell
1008
- // family or declared it inert (CFCacheStore without a KV namespace).
1009
+ // family or declared it inert (a custom-store escape hatch; built-in
1010
+ // stores never declare it — a KV-less CFCacheStore stores L1-only).
1009
1011
  const scheduledStore = descriptor.store ?? reqCtx._cacheStore;
1010
1012
  if (!scheduledStore?.putShell || scheduledStore.shellFamilyInert) {
1011
1013
  publishCaptureDebugEvent(descriptor, { key, outcome: "skip-inert-store" });
@@ -2028,6 +2030,21 @@ async function captureAndStoreShell(
2028
2030
  );
2029
2031
  return "refused";
2030
2032
  }
2033
+ if (storeWrite === "uncacheable") {
2034
+ // The store declared the entry unstorable under its current
2035
+ // configuration — every retry would refuse identically, so back
2036
+ // the key off like any refused capture instead of burning a full
2037
+ // serialized render per MISS. Store-neutral on purpose: the
2038
+ // acknowledging store emits its own diagnostic naming the specific
2039
+ // limit (CFCacheStore warns about the over-limit Cache-Tag set).
2040
+ warnCaptureRefusedOnce(
2041
+ capture.key,
2042
+ "the store cannot cache this shell under its current configuration and acknowledged " +
2043
+ "the write as permanently refusable, so the key is backed off. See the cache store's " +
2044
+ "own warning for the specific limit and remedy.",
2045
+ );
2046
+ return "refused";
2047
+ }
2031
2048
  } catch (error) {
2032
2049
  // Best-effort: a failed put must never throw out of the background task.
2033
2050
  reportCacheError(
@@ -350,7 +350,10 @@ export type PathHelpers<TEnv> = {
350
350
  * Pass `{ stream: "navigation" }` to await this loader before first flush
351
351
  * on DOCUMENT requests (see {@link LoaderOptions}) — the opt-in for loaders
352
352
  * whose data, handle pushes, or thrown notFound()/redirect() must be in the
353
- * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming.
353
+ * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming. Under a
354
+ * `ppr` route the flag BAKES: the loader executes at shell capture and
355
+ * its settled return freezes into the shell (nested promises stay live
356
+ * holes) — the pre-flush promise applied to the prelude.
354
357
  */
355
358
  loader: <TData>(
356
359
  loaderDef: LoaderDefinition<TData>,