@rangojs/router 0.26.0 → 0.26.1

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.
package/dist/bin/rango.js CHANGED
@@ -1644,7 +1644,7 @@ var init_package = __esm({
1644
1644
  "package.json"() {
1645
1645
  package_default = {
1646
1646
  name: "@rangojs/router",
1647
- version: "0.26.0",
1647
+ version: "0.26.1",
1648
1648
  description: "Django-inspired RSC router with composable URL patterns",
1649
1649
  keywords: [
1650
1650
  "react",
@@ -199,11 +199,13 @@ export interface ShellLoaderSeedEntry {
199
199
  * by loader. A key that does not parse pins nothing.
200
200
  *
201
201
  * Deserializations run in parallel; a record that fails to decode is skipped
202
- * (that loader drifts — the pre-snapshot behavior — instead of failing the
203
- * HIT). Returns undefined when the snapshot carries no loader records, without
204
- * touching the Flight codec (kept lazy for cold paths and non-RSC configs).
202
+ * and reported to `onUndecodable`: its loader runs instead, which a document
203
+ * HIT has already committed a prelude against, so serveShellHit retires the
204
+ * entry (dc-t89). Returns undefined when the snapshot carries no loader
205
+ * records, without touching the Flight codec (kept lazy for cold paths and
206
+ * non-RSC configs).
205
207
  */
206
- export declare function buildShellLoaderSeed(snapshot: ShellSnapshotRecord[]): Promise<Map<string, ShellLoaderSeedEntry> | undefined>;
208
+ export declare function buildShellLoaderSeed(snapshot: ShellSnapshotRecord[], onUndecodable?: (key: string) => void): Promise<Map<string, ShellLoaderSeedEntry> | undefined>;
207
209
  /** The key of a snapshot's one `handles` record (ShellSnapshotHandlesValue). */
208
210
  export declare const SHELL_HANDLES_RECORD_KEY = "handles";
209
211
  /**
@@ -0,0 +1,26 @@
1
+ import type { RangoInternal } from "./router-interfaces.js";
2
+ /** The `import.meta.hot` subset this reads. */
3
+ interface RunnerReloadEvents {
4
+ on(event: "vite:beforeFullReload", listener: () => void): void;
5
+ }
6
+ /**
7
+ * Mark this module copy dropped on the runner's next full reload.
8
+ * @internal
9
+ */
10
+ export declare function watchRunnerReloads(events: RunnerReloadEvents | undefined): void;
11
+ /**
12
+ * Whether a runner full reload dropped this module copy.
13
+ * @internal
14
+ */
15
+ export declare function droppedByRunnerReload(): boolean;
16
+ /**
17
+ * The router the reload re-created for `routerId`, or undefined when none
18
+ * registered within DEV_RECREATED_ROUTER_WAIT_MS. Importing `sourceFile`
19
+ * evaluates it when nothing else is. While the reload's re-import is still
20
+ * evaluating it, the runner returns the unfinished module (it is in this
21
+ * copy's import call stack, `cachedRequest` in vite's module-runner), so this
22
+ * waits for the fresh registry to receive the router.
23
+ * @internal
24
+ */
25
+ export declare function routerAfterRunnerReload(routerId: string, sourceFile: string | undefined): Promise<RangoInternal<any, any> | undefined>;
26
+ export {};
@@ -153,9 +153,12 @@ export declare const SHELL_CAPTURE_TASK_HARD_CAP_MS = 25000;
153
153
  * `no-record` (no doc record: the route's cache() refused the write, or the
154
154
  * handler output failed), `handles` (a pushed
155
155
  * handle value did not finish encoding), `size` (over the store's value
156
- * limit), `record-expired` (the route cache() record the capture wrote ran
157
- * out first), `invalidated` (a tag of the shell was invalidated after the
158
- * capture started), `uncacheable` (the store cannot hold the entry).
156
+ * limit, or `ssr: false` loader pins over `ppr.maxSnapshotBytes`: a
157
+ * navigation-only entry was stored in place of the document shell),
158
+ * `record-expired` (the route cache() record
159
+ * the capture wrote ran out first), `invalidated` (a tag of the shell was
160
+ * invalidated after the capture started), `uncacheable` (the store cannot
161
+ * hold the entry).
159
162
  */
160
163
  export type ShellCaptureRefusal = "identity" | "dynamic" | "loader" | "no-record" | "handles" | "size" | "record-expired" | "invalidated" | "uncacheable";
161
164
  /**
@@ -105,9 +105,8 @@ export declare const DEFAULT_SHELL_ENTRY_MAX_BYTES: number;
105
105
  * while still fitting any sane pinned-ring payload. Applied ONLY in
106
106
  * captureAndStoreShell (the single defaulting site — resolvePprConfig passes
107
107
  * the option through undefaulted), so every producer and direct caller gets
108
- * the same policy. The doc record is exempt; over the cap the other records
109
- * are dropped (shell still stored and replayed; the pins' loaders read the
110
- * live store — see PartialPrerenderProps.maxSnapshotBytes).
108
+ * the same policy. The doc record is exempt; over the cap only the
109
+ * navigation half is stored (see PartialPrerenderProps.maxSnapshotBytes).
111
110
  */
112
111
  export declare const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number;
113
112
  /**
@@ -165,7 +164,11 @@ export interface ShellCaptureDebugEvent {
165
164
  preludeBytes?: number;
166
165
  /** Serialized snapshot size in UTF-8 bytes. Absent when nothing was recorded. */
167
166
  snapshotBytes?: number;
168
- /** True when the snapshot exceeded maxSnapshotBytes and was dropped. */
167
+ /**
168
+ * True when the loader pins exceeded maxSnapshotBytes: no document shell
169
+ * was stored, only a navigation-only entry (the doc record, or a tombstone
170
+ * for a prerender-served capture). The outcome is `refused`, refusal `size`.
171
+ */
169
172
  snapshotSkipped?: boolean;
170
173
  /** A bake-lane loader settled into a shell that uses TTL/SWR-only invalidation. */
171
174
  untaggedBake?: true;
@@ -315,8 +318,8 @@ export interface ShellCaptureDescriptor {
315
318
  captureTimeout?: number;
316
319
  store?: SegmentCacheStore<any>;
317
320
  /**
318
- * Cap (serialized UTF-8 bytes) on the entry's capture data snapshot; over it
319
- * the snapshot is skipped and the shell stored without it (reported once per
321
+ * Cap (serialized UTF-8 bytes) on the entry's loader pins; over it only the
322
+ * navigation half is stored and the attempt refuses (reported once per
320
323
  * key). Absent = DEFAULT_PPR_MAX_SNAPSHOT_BYTES, applied in
321
324
  * captureAndStoreShell — the single defaulting site.
322
325
  */
@@ -60,12 +60,13 @@ export interface PartialPrerenderProps {
60
60
  /**
61
61
  * Upper bound (serialized UTF-8 bytes) on the loader pins in the capture
62
62
  * data snapshot: the bake-lane loader records. The doc record (the handler
63
- * layer every HIT replays) is exempt. Over the cap the pins are dropped: the
64
- * shell is still stored and every HIT still replays the handler layer, but the
65
- * pinned loaders read the live store, so drifted cached content can
66
- * hydration-mismatch and be repaired client-side. Reported once per key.
67
- * Defaults to 8 MiB. The whole entry is bounded separately by the store's
68
- * value limit (Cloudflare KV: 25 MiB); an entry over it is refused.
63
+ * layer every HIT replays) is exempt. A document HIT is served only from an
64
+ * entry that holds a pin for every `ssr: false` loader its HTML baked, so
65
+ * over the cap no document shell is stored: documents render in full
66
+ * (MISS), and client navigations still replay the handler layer and run
67
+ * those loaders fresh. Reported once per key. Defaults to 8 MiB. The whole
68
+ * entry is bounded separately by the store's value limit (Cloudflare KV:
69
+ * 25 MiB); an entry over it is refused.
69
70
  */
70
71
  maxSnapshotBytes?: number;
71
72
  /**
@@ -7077,7 +7077,7 @@ function resolveVersionsFrom(table, routerId2) {
7077
7077
  // package.json
7078
7078
  var package_default = {
7079
7079
  name: "@rangojs/router",
7080
- version: "0.26.0",
7080
+ version: "0.26.1",
7081
7081
  description: "Django-inspired RSC router with composable URL patterns",
7082
7082
  keywords: [
7083
7083
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -372,7 +372,8 @@ replay its eligible snapshot. In both cases `matchPartial()`:
372
372
  - runs live DSL loaders fresh with their normal `loading()` streaming
373
373
  behavior, and serves bake-lane loaders from the pins of a shell captured by a
374
374
  document request; a snapshot captured by a navigation alone carries no pins,
375
- and its replay runs them fresh (see below);
375
+ nor does the navigation-only entry a capture over `ppr.maxSnapshotBytes`
376
+ stores, and their replay runs them fresh (see below);
376
377
  - keeps the existing prefetch key, source scope, and in-flight lock unchanged.
377
378
 
378
379
  This is deliberately invisible to the browser: the response is the same
@@ -382,8 +383,9 @@ snapshot is a shell a document request captured, as on a document HIT: a
382
383
  promise-free `ssr: false` loader is served from its pin without running
383
384
  (unless the capture marked it to run: see "On a shell HIT"), and one whose
384
385
  return holds promises runs with its baked parts overlaid. A snapshot captured
385
- by a navigation alone carries no pins, and its replay runs them fresh. Loaders
386
- without `ssr: false` stay live.
386
+ by a navigation alone carries no pins, nor does an entry over
387
+ `ppr.maxSnapshotBytes`, and its replay runs them fresh. Loaders without
388
+ `ssr: false` stay live.
387
389
 
388
390
  A route's own `cache()` scope — including one inherited from an ancestor, the
389
391
  common app-wide storefront shape — COMPOSES with replay instead of disabling
@@ -924,7 +926,9 @@ by a navigation alone carries no pins, and its replay runs these loaders fresh.
924
926
  fill the holes — and the recorded baked paths are overlaid onto the fresh
925
927
  result, so the payload matches the prelude while the nested promises stream
926
928
  fresh. A fresh rejection skips the overlay and goes to the loader's error
927
- boundary. Give it its own cache,
929
+ boundary: on a document HIT the payload then carries the error where the
930
+ HTML has the baked value, and React renders that part again on the client
931
+ (one hydration mismatch, the one known residual of a HIT). Give it its own cache,
928
932
  `loader(Def, { ssr: false }, () => [cache({ ttl })])`, so that run reads
929
933
  through the loader cache.
930
934
 
@@ -985,9 +989,12 @@ loader running on the replay awaits it. On a document HIT the page hydrates
985
989
  with the shell's copy and the hole's value takes its place after hydration
986
990
  ("Handles on a shell HIT" below). An
987
991
  `ssr: false` loader is served from the shell only while the entry holds its
988
- pin: a snapshot captured by a navigation alone carries none, and a shell over
989
- `ppr.maxSnapshotBytes` drops them. There the loader runs fresh, and its
990
- pushes are that run's, like its data.
992
+ pin: a snapshot captured by a navigation alone carries none, and a capture
993
+ over `ppr.maxSnapshotBytes` stores the navigation half only, without them.
994
+ There a navigation runs the loader fresh, and its pushes are that run's, like
995
+ its data. A document HIT is served only from an entry that holds a pin for
996
+ every `ssr: false` loader its HTML baked, so a document never hydrates one
997
+ loader's run over HTML rendered from the capture's value.
991
998
 
992
999
  A `Prerender` + `ppr` page follows the same table. Its handler layer comes
993
1000
  from the prerendered payload, which never holds a loader push (loaders do not
@@ -997,8 +1004,9 @@ on its own, and a HIT restores them as above (#1057).
997
1004
  A dependency the route does not register, which an `ssr: false` loader
998
1005
  awaited at capture outside any live loader, is on neither lane: its settled
999
1006
  pushes stay as the prelude rendered them, even where a live loader also
1000
- awaits it, while its data is fresh. (They follow the run instead once the
1001
- entry has lost a pin, because the loader that awaits it then runs.) To keep
1007
+ awaits it, while its data is fresh. (They follow the run instead when the
1008
+ replayed entry has no pin for the loader that awaits it, because that loader
1009
+ then runs.) To keep
1002
1010
  its pushes live, declare it as its own `loader()` on the route. A push the
1003
1011
  capture cannot record (one whose value, once settled, holds a promise) marks
1004
1012
  every loader record of the page to run: those bodies then still run on each
@@ -1035,12 +1043,11 @@ Four things follow, in this order of scope:
1035
1043
  inside the `loading()` or `<Suspense>` of a loader that pushes after an
1036
1044
  `await` hydrates clean.
1037
1045
 
1038
- | On the HIT's request | In the HIT's HTML | The client |
1039
- | ------------------------------------------------------------------------------------------ | ---------------------- | ------------------------------------------------- |
1040
- | a handler's push, a pinned `ssr: false` loader's push (`push(promise)` and `.defer()` too) | yes (the shell has it) | hydrates with it |
1041
- | a push by an `ssr: false` loader whose value holds a promise | no | gets it right after hydration |
1042
- | a live-lane loader's push | no | gets it right after hydration |
1043
- | the run of an `ssr: false` loader the shell has no pin for | the capture's push | hydrates with the capture's, then shows the run's |
1046
+ | On the HIT's request | In the HIT's HTML | The client |
1047
+ | ------------------------------------------------------------------------------------------ | ---------------------- | ----------------------------- |
1048
+ | a handler's push, a pinned `ssr: false` loader's push (`push(promise)` and `.defer()` too) | yes (the shell has it) | hydrates with it |
1049
+ | a push by an `ssr: false` loader whose value holds a promise | no | gets it right after hydration |
1050
+ | a live-lane loader's push | no | gets it right after hydration |
1044
1051
 
1045
1052
  So a title an `ssr: false` loader pushes to `Meta`, promise or not, is in
1046
1053
  every HIT's `<head>`: a crawler and a link preview see it. It bakes with the
@@ -1408,13 +1415,13 @@ path(
1408
1415
  );
1409
1416
  ```
1410
1417
 
1411
- | Field | Default | Notes |
1412
- | ------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1413
- | `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
1414
- | `swr` | — | stale window: serve the stale shell + background recapture |
1415
- | `tags` | — | operational tags UNIONED with the tags the capture collects — see "Invalidation" below |
1416
- | `maxSnapshotBytes` | 8 MiB | cap on the loader pins in the entry's snapshot (the bake-lane loader records). The recorded handler layer is exempt. Over the cap the pins are dropped and the shell is stored with its handler layer (warned once per key); bake-lane loaders then run on every replay and read the live store, their handle pushes come from that run like their data, and drift from the prelude is repaired client-side |
1417
- | `captureTimeout` | 15000ms | ONE capture deadline: the handler layer settling (promises it passes or pushes, async server components, loaders it awaits), bake-lane loaders, and the prerender; a capture that misses it stores nothing rather than a partial shell |
1418
+ | Field | Default | Notes |
1419
+ | ------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1420
+ | `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
1421
+ | `swr` | — | stale window: serve the stale shell + background recapture |
1422
+ | `tags` | — | operational tags UNIONED with the tags the capture collects — see "Invalidation" below |
1423
+ | `maxSnapshotBytes` | 8 MiB | cap on the loader pins in the entry's snapshot (the bake-lane loader records). The recorded handler layer is exempt. A document HIT is served only from an entry that holds a pin for every `ssr: false` loader its HTML baked, so over the cap no document shell is stored: the entry keeps the handler layer only, documents are a MISS (full SSR, no hydration error, no remount), client navigations still replay the handler layer and run the bake-lane loaders fresh, and the URL backs off like a refused capture. Warned once per key: "the document shell was not stored; raise maxSnapshotBytes or shrink the ssr: false data" |
1424
+ | `captureTimeout` | 15000ms | ONE capture deadline: the handler layer settling (promises it passes or pushes, async server components, loaders it awaits), bake-lane loaders, and the prerender; a capture that misses it stores nothing rather than a partial shell |
1418
1425
 
1419
1426
  A shell never outlives the route `cache()` entry it was captured from: under
1420
1427
  a route `cache()`, the shell is fresh no longer than that entry is (at most
@@ -405,12 +405,15 @@ const PIN_KEY_PREFIX = /^[^D.]*D\d+\./;
405
405
  * by loader. A key that does not parse pins nothing.
406
406
  *
407
407
  * Deserializations run in parallel; a record that fails to decode is skipped
408
- * (that loader drifts — the pre-snapshot behavior — instead of failing the
409
- * HIT). Returns undefined when the snapshot carries no loader records, without
410
- * touching the Flight codec (kept lazy for cold paths and non-RSC configs).
408
+ * and reported to `onUndecodable`: its loader runs instead, which a document
409
+ * HIT has already committed a prelude against, so serveShellHit retires the
410
+ * entry (dc-t89). Returns undefined when the snapshot carries no loader
411
+ * records, without touching the Flight codec (kept lazy for cold paths and
412
+ * non-RSC configs).
411
413
  */
412
414
  export async function buildShellLoaderSeed(
413
415
  snapshot: ShellSnapshotRecord[],
416
+ onUndecodable?: (key: string) => void,
414
417
  ): Promise<Map<string, ShellLoaderSeedEntry> | undefined> {
415
418
  const loaderRecords: ShellSnapshotRecord[] = [];
416
419
  for (const rec of snapshot) {
@@ -441,6 +444,7 @@ export async function buildShellLoaderSeed(
441
444
  },
442
445
  ];
443
446
  } catch {
447
+ onUndecodable?.(rec.key);
444
448
  return null;
445
449
  }
446
450
  },
@@ -0,0 +1,73 @@
1
+ import type { RangoInternal } from "./router-interfaces.js";
2
+
3
+ /**
4
+ * Dev only (#1186). A runner full reload clears its evaluated modules while a
5
+ * request may still be loading them, splitting it across two router copies
6
+ * whose registries disagree. A router of a dropped copy hands its requests to
7
+ * the router the reload re-created. docs/manifests.md, "A request the reload
8
+ * split".
9
+ */
10
+ let runnerReloaded = false;
11
+
12
+ /**
13
+ * Bound on the wait for the re-created router. cloudflare-basic's entry took
14
+ * about 5 s to re-evaluate after the optimizer's `invalidateAll`.
15
+ */
16
+ const DEV_RECREATED_ROUTER_WAIT_MS = 10_000;
17
+ const DEV_RECREATED_ROUTER_POLL_MS = 20;
18
+
19
+ /** The `import.meta.hot` subset this reads. */
20
+ interface RunnerReloadEvents {
21
+ on(event: "vite:beforeFullReload", listener: () => void): void;
22
+ }
23
+
24
+ /**
25
+ * Mark this module copy dropped on the runner's next full reload.
26
+ * @internal
27
+ */
28
+ export function watchRunnerReloads(
29
+ events: RunnerReloadEvents | undefined,
30
+ ): void {
31
+ events?.on("vite:beforeFullReload", () => {
32
+ runnerReloaded = true;
33
+ });
34
+ }
35
+
36
+ if (process.env.NODE_ENV !== "production") {
37
+ watchRunnerReloads(import.meta.hot);
38
+ }
39
+
40
+ /**
41
+ * Whether a runner full reload dropped this module copy.
42
+ * @internal
43
+ */
44
+ export function droppedByRunnerReload(): boolean {
45
+ return runnerReloaded;
46
+ }
47
+
48
+ /**
49
+ * The router the reload re-created for `routerId`, or undefined when none
50
+ * registered within DEV_RECREATED_ROUTER_WAIT_MS. Importing `sourceFile`
51
+ * evaluates it when nothing else is. While the reload's re-import is still
52
+ * evaluating it, the runner returns the unfinished module (it is in this
53
+ * copy's import call stack, `cachedRequest` in vite's module-runner), so this
54
+ * waits for the fresh registry to receive the router.
55
+ * @internal
56
+ */
57
+ export async function routerAfterRunnerReload(
58
+ routerId: string,
59
+ sourceFile: string | undefined,
60
+ ): Promise<RangoInternal<any, any> | undefined> {
61
+ if (!sourceFile) return undefined;
62
+ await import(/* @vite-ignore */ sourceFile);
63
+ const { RouterRegistry } = await import("./router-registry.js");
64
+ const deadline = Date.now() + DEV_RECREATED_ROUTER_WAIT_MS;
65
+ let router = RouterRegistry.get(routerId);
66
+ while (!router && Date.now() < deadline) {
67
+ await new Promise((resolve) =>
68
+ setTimeout(resolve, DEV_RECREATED_ROUTER_POLL_MS),
69
+ );
70
+ router = RouterRegistry.get(routerId);
71
+ }
72
+ return router;
73
+ }
package/src/router.ts CHANGED
@@ -132,6 +132,10 @@ import {
132
132
  resolvePrefetchCacheSize,
133
133
  resolvePrefetchConcurrency,
134
134
  } from "./router/prefetch-limits.js";
135
+ import {
136
+ droppedByRunnerReload,
137
+ routerAfterRunnerReload,
138
+ } from "./router/dev-reload-handoff.js";
135
139
 
136
140
  // Re-export public types and values from extracted modules
137
141
  export { RSC_ROUTER_BRAND, RouterRegistry } from "./router/router-registry.js";
@@ -1274,6 +1278,18 @@ export function createRouter<TEnv = any>(
1274
1278
  });
1275
1279
  }
1276
1280
 
1281
+ // Dev: a runner full reload dropped this router's module copy
1282
+ // (router/dev-reload-handoff.ts, #1186).
1283
+ if (process.env.NODE_ENV !== "production" && droppedByRunnerReload()) {
1284
+ const reloaded = await routerAfterRunnerReload(
1285
+ routerId,
1286
+ __sourceFile,
1287
+ );
1288
+ if (reloaded && reloaded !== router) {
1289
+ return reloaded.fetch(request, input);
1290
+ }
1291
+ }
1292
+
1277
1293
  // Trigger lazy import of per-router manifest data before route matching.
1278
1294
  // No-op if data is already loaded or no loader is registered.
1279
1295
  await ensureRouterManifest(routerId);
@@ -56,6 +56,7 @@ import { buildFullPayload, payloadLocation } from "./full-payload.js";
56
56
  import {
57
57
  capShellWindow,
58
58
  lastStoredCaptureSeq,
59
+ markCaptureBackoff,
59
60
  scheduleShellCapture,
60
61
  resolveShellCaptureDebugSink,
61
62
  takeCaptureDebugEventForTiming,
@@ -1804,13 +1805,14 @@ async function isSettled(value: unknown): Promise<boolean> {
1804
1805
  }
1805
1806
 
1806
1807
  /**
1807
- * Evict a shell whose doc record cannot be replayed. The store has no shell
1808
- * delete, so the entry is overwritten with a tombstone: a navigationOnly
1809
- * entry with no document half and no snapshot, which document serving
1810
- * treats as a MISS (shellServePlan, the build shell included) and partial
1811
- * replay as `no-segment-snapshot`. The recapture overwrites it with a sound
1812
- * entry. Only for a broken entry: a snapshot read that was merely slow
1813
- * (`snapshotFailure: "unavailable"`) leaves the entry alone.
1808
+ * Evict a shell whose doc record cannot be replayed, or one with a loader pin
1809
+ * that failed to decode. The store has no shell delete, so the entry is
1810
+ * overwritten with a tombstone: a navigationOnly entry with no document half
1811
+ * and no snapshot, which document serving treats as a MISS (shellServePlan,
1812
+ * the build shell included) and partial replay as `no-segment-snapshot`. The
1813
+ * recapture overwrites it with a sound entry. Only for a broken entry: a
1814
+ * snapshot read that was merely slow (`snapshotFailure: "unavailable"`)
1815
+ * leaves the entry alone.
1814
1816
  */
1815
1817
  async function degradeUnreplayableShell(
1816
1818
  descriptor: ShellCaptureDescriptor,
@@ -1878,6 +1880,21 @@ function serveShellHit(
1878
1880
  const tailT0 = tailTiming ? performance.now() : 0;
1879
1881
  const scheduleRecapture = (): void =>
1880
1882
  scheduleShellCapture(ctx, request, env, url, reqCtx, ssrModule, descriptor);
1883
+ /** Report a broken entry, then tombstone and recapture it. Never throws. */
1884
+ const retireEntry = async (reason: string): Promise<void> => {
1885
+ reportCacheError(
1886
+ new Error(`shell entry for "${descriptor.key}": ${reason}`),
1887
+ "cache-read",
1888
+ "[ShellServe] retire",
1889
+ );
1890
+ try {
1891
+ await degradeUnreplayableShell(descriptor, entry, scheduleRecapture);
1892
+ } catch (error) {
1893
+ reportCacheError(error, "cache-write", "[ShellServe] retire");
1894
+ }
1895
+ };
1896
+ /** The first loader pin of the entry that failed to decode (dc-t89). */
1897
+ let undecodablePin: string | undefined;
1881
1898
 
1882
1899
  const createTailContext = (): RequestContext<any> => {
1883
1900
  const tailCtx: RequestContext<any> = Object.create(reqCtx);
@@ -2016,7 +2033,11 @@ function serveShellHit(
2016
2033
  const seedStart =
2017
2034
  INTERNAL_RANGO_DEBUG || tailTiming ? performance.now() : 0;
2018
2035
  const loaderSeed =
2019
- records.length > 0 ? await buildShellLoaderSeed(records) : undefined;
2036
+ records.length > 0
2037
+ ? await buildShellLoaderSeed(records, (key) => {
2038
+ undecodablePin ??= key;
2039
+ })
2040
+ : undefined;
2020
2041
  if (tailTiming) {
2021
2042
  const seededAt = performance.now();
2022
2043
  tailTiming.seedMs = Math.round(seededAt - tailT0);
@@ -2179,10 +2200,8 @@ function serveShellHit(
2179
2200
  // snapshot read was only slow stays, and the next request reads it
2180
2201
  // again.
2181
2202
  if ((await document.snapshotFailure) !== "unavailable") {
2182
- await degradeUnreplayableShell(
2183
- descriptor,
2184
- entry,
2185
- scheduleRecapture,
2203
+ await retireEntry(
2204
+ "the doc record could not supply the handler layer",
2186
2205
  );
2187
2206
  }
2188
2207
  controller.enqueue(new TextEncoder().encode(shellReloadScript()));
@@ -2207,6 +2226,19 @@ function serveShellHit(
2207
2226
  }
2208
2227
  publishTailTiming(tailTiming, metricsStore, request, url);
2209
2228
  controller.error(error);
2229
+ return;
2230
+ }
2231
+ // A pin that failed to decode left its loader to this request's run:
2232
+ // this response hydrated once over a prelude rendered from the
2233
+ // capture's value (dc-t89). Retire the entry once the response is
2234
+ // closed, and back the key off so a pin that keeps failing costs one
2235
+ // mismatch per window. No reload: that would cost a second page load.
2236
+ if (undecodablePin !== undefined) {
2237
+ const pin = undecodablePin;
2238
+ markCaptureBackoff(descriptor.key);
2239
+ reqCtx.waitUntil(() =>
2240
+ retireEntry(`the loader pin "${pin}" failed to decode`),
2241
+ );
2210
2242
  }
2211
2243
  },
2212
2244
  });
@@ -213,9 +213,12 @@ export const SHELL_CAPTURE_TASK_HARD_CAP_MS = 25_000;
213
213
  * `no-record` (no doc record: the route's cache() refused the write, or the
214
214
  * handler output failed), `handles` (a pushed
215
215
  * handle value did not finish encoding), `size` (over the store's value
216
- * limit), `record-expired` (the route cache() record the capture wrote ran
217
- * out first), `invalidated` (a tag of the shell was invalidated after the
218
- * capture started), `uncacheable` (the store cannot hold the entry).
216
+ * limit, or `ssr: false` loader pins over `ppr.maxSnapshotBytes`: a
217
+ * navigation-only entry was stored in place of the document shell),
218
+ * `record-expired` (the route cache() record
219
+ * the capture wrote ran out first), `invalidated` (a tag of the shell was
220
+ * invalidated after the capture started), `uncacheable` (the store cannot
221
+ * hold the entry).
219
222
  */
220
223
  export type ShellCaptureRefusal =
221
224
  | "identity"
@@ -542,9 +542,8 @@ export const DEFAULT_SHELL_ENTRY_MAX_BYTES: number = 25 * 1024 * 1024;
542
542
  * while still fitting any sane pinned-ring payload. Applied ONLY in
543
543
  * captureAndStoreShell (the single defaulting site — resolvePprConfig passes
544
544
  * the option through undefaulted), so every producer and direct caller gets
545
- * the same policy. The doc record is exempt; over the cap the other records
546
- * are dropped (shell still stored and replayed; the pins' loaders read the
547
- * live store — see PartialPrerenderProps.maxSnapshotBytes).
545
+ * the same policy. The doc record is exempt; over the cap only the
546
+ * navigation half is stored (see PartialPrerenderProps.maxSnapshotBytes).
548
547
  */
549
548
  export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
550
549
 
@@ -553,12 +552,8 @@ const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
553
552
 
554
553
  /**
555
554
  * Warn once per key that the capture's loader pins exceeded the route's
556
- * `maxSnapshotBytes` cap and were dropped. The shell entry is still stored
557
- * with its doc record and every HIT replays the handler layer; only the
558
- * pinned loaders lose their captured values, so their cached data can drift
559
- * from the frozen prelude and hydration repairs it client-side. Once per
560
- * key: the same page recaptures on every TTL roll and would otherwise
561
- * re-warn forever.
555
+ * `maxSnapshotBytes` cap, so no document shell was stored (dc-t89). Once per
556
+ * key: the route recaptures every backoff window.
562
557
  */
563
558
  function warnSnapshotOverCapOnce(
564
559
  key: string,
@@ -570,14 +565,10 @@ function warnSnapshotOverCapOnce(
570
565
  key,
571
566
  () =>
572
567
  `[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
573
- `loader pins, over the ${capBytes}-byte cap — the pins were dropped and ` +
574
- "the shell was stored with its doc record only. HITs still replay the " +
575
- "handler layer, but ssr: false loaders now read the live store: if " +
576
- "their data drifts before the shell's TTL, hydration repairs the " +
577
- "mismatch client-side. Raise the cap via the route's ppr option " +
578
- "({ maxSnapshotBytes }) if the entry still fits your store's value limit " +
579
- "(Cloudflare KV: 25 MiB per value), or shrink the cache()'d data the " +
580
- "loaders read.",
568
+ `ssr: false loader pins, over the ${capBytes}-byte cap: the document ` +
569
+ "shell was not stored; raise maxSnapshotBytes or shrink the ssr: false " +
570
+ "data. Documents of this route are a MISS until then; client " +
571
+ "navigations still replay its handler layer.",
581
572
  );
582
573
  }
583
574
 
@@ -683,7 +674,11 @@ export interface ShellCaptureDebugEvent {
683
674
  preludeBytes?: number;
684
675
  /** Serialized snapshot size in UTF-8 bytes. Absent when nothing was recorded. */
685
676
  snapshotBytes?: number;
686
- /** True when the snapshot exceeded maxSnapshotBytes and was dropped. */
677
+ /**
678
+ * True when the loader pins exceeded maxSnapshotBytes: no document shell
679
+ * was stored, only a navigation-only entry (the doc record, or a tombstone
680
+ * for a prerender-served capture). The outcome is `refused`, refusal `size`.
681
+ */
687
682
  snapshotSkipped?: boolean;
688
683
  /** A bake-lane loader settled into a shell that uses TTL/SWR-only invalidation. */
689
684
  untaggedBake?: true;
@@ -1053,8 +1048,8 @@ export interface ShellCaptureDescriptor {
1053
1048
  captureTimeout?: number;
1054
1049
  store?: SegmentCacheStore<any>;
1055
1050
  /**
1056
- * Cap (serialized UTF-8 bytes) on the entry's capture data snapshot; over it
1057
- * the snapshot is skipped and the shell stored without it (reported once per
1051
+ * Cap (serialized UTF-8 bytes) on the entry's loader pins; over it only the
1052
+ * navigation half is stored and the attempt refuses (reported once per
1058
1053
  * key). Absent = DEFAULT_PPR_MAX_SNAPSHOT_BYTES, applied in
1059
1054
  * captureAndStoreShell — the single defaulting site.
1060
1055
  */
@@ -2556,10 +2551,13 @@ async function captureAndStoreShell(
2556
2551
  runs,
2557
2552
  },
2558
2553
  });
2554
+ } else {
2555
+ throw new Error(`[rango] no shell pin for "${segmentKey}"`);
2559
2556
  }
2560
- } catch {
2561
- // Codec import failed: leave it unpinned (it drifts on a HIT, the
2562
- // pre-snapshot behavior) rather than failing the capture.
2557
+ } catch (error) {
2558
+ // Unpinned, the loader would run on every HIT over this prelude
2559
+ // (dc-t89): refuse like an encode error.
2560
+ reqCtx._renderErrors?.push(error);
2563
2561
  }
2564
2562
  }
2565
2563
  }
@@ -2603,12 +2601,11 @@ async function captureAndStoreShell(
2603
2601
  snapshot = kept;
2604
2602
  }
2605
2603
 
2606
- // Snapshot size guard (issue #651): `maxSnapshotBytes` bounds the loader
2607
- // pins; over it they are dropped and the entry keeps its doc record, so
2608
- // every HIT still replays the handler layer (the pins' loaders then run
2609
- // on the HIT: documented drift, repaired client-side). The doc record is
2610
- // exempt: without it a HIT could not serve at all. The whole entry is
2611
- // bounded separately below, by the store's value limit.
2604
+ // Snapshot size guard: `maxSnapshotBytes` bounds the loader pins, and a
2605
+ // document HIT needs every pin its prelude baked (dc-t89). Over the cap
2606
+ // the entry keeps the navigation half only (the doc record, or nothing:
2607
+ // a tombstone that also shadows a build shell) and the attempt refuses.
2608
+ // The whole entry is bounded separately below, by the store's limit.
2612
2609
  if (snapshot.length > 0) {
2613
2610
  const docRecord =
2614
2611
  docKey !== undefined
@@ -2629,9 +2626,10 @@ async function captureAndStoreShell(
2629
2626
  stats.snapshotSkipped = true;
2630
2627
  }
2631
2628
  }
2629
+ const navigationOnly = capture.navigationOnly || stats.snapshotSkipped;
2632
2630
  // Exempt from maxSnapshotBytes: the HIT needs it for its HTML and
2633
2631
  // hydration data to match.
2634
- if (prerenderHandles) {
2632
+ if (prerenderHandles && !navigationOnly) {
2635
2633
  snapshot.push({
2636
2634
  family: "handles",
2637
2635
  key: SHELL_HANDLES_RECORD_KEY,
@@ -2680,8 +2678,8 @@ async function captureAndStoreShell(
2680
2678
  // JSON head as an escaped string.
2681
2679
  const entryBytes = estimateShellEntryBytes({
2682
2680
  // A navigation-only entry stores no document half.
2683
- preludeBytes: capture.navigationOnly ? 0 : result.prelude.length,
2684
- postponed: capture.navigationOnly ? undefined : result.postponed,
2681
+ preludeBytes: navigationOnly ? 0 : result.prelude.length,
2682
+ postponed: navigationOnly ? undefined : result.postponed,
2685
2683
  snapshot,
2686
2684
  tags: shellTags,
2687
2685
  docKey: storedDocKey,
@@ -2717,7 +2715,7 @@ async function captureAndStoreShell(
2717
2715
  // consumes only snapshot/docKey), so storing it would ride every KV
2718
2716
  // write/read as dead weight — the prerender still ran above as the
2719
2717
  // completeness arbiter and sanity gate, its output is dropped here.
2720
- ...(capture.navigationOnly
2718
+ ...(navigationOnly
2721
2719
  ? {}
2722
2720
  : {
2723
2721
  // bytesToBase64 encodes the view's own region, so a prelude
@@ -2740,7 +2738,7 @@ async function captureAndStoreShell(
2740
2738
  // looks the record up by this key (serveShellHit fixedDocKey);
2741
2739
  // undefined only for a prerender-served entry.
2742
2740
  docKey: storedDocKey,
2743
- navigationOnly: capture.navigationOnly,
2741
+ navigationOnly: navigationOnly || undefined,
2744
2742
  createdAt: captureStartedAt,
2745
2743
  };
2746
2744
  // Capped to the route record the capture used: the policy the
@@ -2828,8 +2826,9 @@ async function captureAndStoreShell(
2828
2826
  }
2829
2827
  // A shell was captured (ordinary store I/O failure is reported, not retried).
2830
2828
  // An acknowledged invalidation rejection returned `refused` above so the
2831
- // scheduler backs the key off instead of recapturing on every request.
2832
- return "stored";
2829
+ // scheduler backs the key off instead of recapturing on every request, as
2830
+ // does an over-cap entry: no document shell was stored.
2831
+ return stats.snapshotSkipped ? refuse(stats, "size") : "stored";
2833
2832
  } finally {
2834
2833
  // Stop the hop loop for the pathological never-quiets path (quiesce never
2835
2834
  // fired, capture returned via maxWaitMs). On the normal path the loop already
@@ -2849,7 +2848,8 @@ export {
2849
2848
  };
2850
2849
 
2851
2850
  // Exported for unit tests that pin the refused-capture backoff policy directly
2852
- // (dev cap vs production exponential growth, stored-clears, cold-start re-probe).
2851
+ // (dev cap vs production exponential growth, stored-clears, cold-start re-probe),
2852
+ // and markCaptureBackoff for serveShellHit (a pin that failed to decode, dc-t89).
2853
2853
  // These are the same module-level functions the schedule path uses; a test that
2854
2854
  // drove them through a real capture round-trip could not assert the exact window
2855
2855
  // arithmetic without a full cold render.
@@ -66,12 +66,13 @@ export interface PartialPrerenderProps {
66
66
  /**
67
67
  * Upper bound (serialized UTF-8 bytes) on the loader pins in the capture
68
68
  * data snapshot: the bake-lane loader records. The doc record (the handler
69
- * layer every HIT replays) is exempt. Over the cap the pins are dropped: the
70
- * shell is still stored and every HIT still replays the handler layer, but the
71
- * pinned loaders read the live store, so drifted cached content can
72
- * hydration-mismatch and be repaired client-side. Reported once per key.
73
- * Defaults to 8 MiB. The whole entry is bounded separately by the store's
74
- * value limit (Cloudflare KV: 25 MiB); an entry over it is refused.
69
+ * layer every HIT replays) is exempt. A document HIT is served only from an
70
+ * entry that holds a pin for every `ssr: false` loader its HTML baked, so
71
+ * over the cap no document shell is stored: documents render in full
72
+ * (MISS), and client navigations still replay the handler layer and run
73
+ * those loaders fresh. Reported once per key. Defaults to 8 MiB. The whole
74
+ * entry is bounded separately by the store's value limit (Cloudflare KV:
75
+ * 25 MiB); an entry over it is refused.
75
76
  */
76
77
  maxSnapshotBytes?: number;
77
78
  /**