@rangojs/router 0.25.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.
Files changed (79) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/cache/cf/cf-cache-store.d.ts +0 -2
  3. package/dist/types/cache/cf/cf-cache-types.d.ts +0 -16
  4. package/dist/types/cache/handle-snapshot.d.ts +2 -2
  5. package/dist/types/cache/shell-snapshot.d.ts +6 -4
  6. package/dist/types/cache/types.d.ts +8 -6
  7. package/dist/types/cache/vercel/vercel-cache-store.d.ts +1 -15
  8. package/dist/types/handles/deferred-resolution.d.ts +3 -2
  9. package/dist/types/handles/meta.d.ts +5 -0
  10. package/dist/types/prerender/build-shell-capture.d.ts +1 -2
  11. package/dist/types/prerender/writable-store.d.ts +2 -3
  12. package/dist/types/router/dev-reload-handoff.d.ts +26 -0
  13. package/dist/types/router/router-interfaces.d.ts +0 -5
  14. package/dist/types/router/router-options.d.ts +0 -20
  15. package/dist/types/router/segment-resolution/mask-nested.d.ts +5 -3
  16. package/dist/types/router-versions.d.ts +8 -6
  17. package/dist/types/rsc/shell-capture-constants.d.ts +10 -6
  18. package/dist/types/rsc/shell-capture.d.ts +25 -8
  19. package/dist/types/rsc/types.d.ts +0 -8
  20. package/dist/types/server/build-version-table.d.ts +10 -13
  21. package/dist/types/server/context.d.ts +26 -2
  22. package/dist/types/server/handle-store.d.ts +17 -6
  23. package/dist/types/server/request-context.d.ts +4 -3
  24. package/dist/types/testing/build-versions.d.ts +0 -3
  25. package/dist/types/testing/internal/seed-vars.d.ts +4 -4
  26. package/dist/types/testing/serve-shell-request.d.ts +1 -1
  27. package/dist/types/types/loader-types.d.ts +6 -1
  28. package/dist/types/urls/pattern-types.d.ts +7 -6
  29. package/dist/vite/index.js +3 -8
  30. package/package.json +1 -1
  31. package/skills/cache-guide/SKILL.md +4 -8
  32. package/skills/caching/SKILL.md +3 -5
  33. package/skills/comparison/references/framework-comparison.md +1 -1
  34. package/skills/hooks/handle-and-actions.md +6 -6
  35. package/skills/host-router/SKILL.md +1 -1
  36. package/skills/loader/SKILL.md +10 -9
  37. package/skills/ppr/SKILL.md +83 -60
  38. package/skills/prerender/SKILL.md +3 -3
  39. package/skills/router-setup/SKILL.md +0 -2
  40. package/skills/testing/render-handler.md +17 -17
  41. package/skills/testing/serve-shell-request.md +1 -1
  42. package/skills/testing/server-actions.md +16 -16
  43. package/skills/vercel/SKILL.md +1 -1
  44. package/src/cache/cf/cf-cache-store.ts +4 -7
  45. package/src/cache/cf/cf-cache-types.ts +0 -17
  46. package/src/cache/handle-snapshot.ts +2 -2
  47. package/src/cache/shell-snapshot.ts +7 -3
  48. package/src/cache/types.ts +8 -6
  49. package/src/cache/vercel/vercel-cache-store.ts +2 -18
  50. package/src/handles/deferred-resolution.ts +3 -2
  51. package/src/handles/meta.ts +5 -0
  52. package/src/prerender/build-shell-capture.ts +1 -2
  53. package/src/prerender/writable-store.ts +2 -3
  54. package/src/route-definition/dsl-helpers.ts +14 -14
  55. package/src/router/dev-reload-handoff.ts +73 -0
  56. package/src/router/manifest.ts +9 -2
  57. package/src/router/match-middleware/intercept-resolution.ts +2 -2
  58. package/src/router/router-interfaces.ts +0 -6
  59. package/src/router/router-options.ts +0 -21
  60. package/src/router/segment-resolution/loader-cache.ts +4 -3
  61. package/src/router/segment-resolution/mask-nested.ts +5 -3
  62. package/src/router-versions.ts +7 -7
  63. package/src/router.ts +19 -8
  64. package/src/rsc/handler.ts +3 -7
  65. package/src/rsc/rsc-rendering.ts +44 -12
  66. package/src/rsc/shell-capture-constants.ts +10 -6
  67. package/src/rsc/shell-capture.ts +163 -88
  68. package/src/rsc/types.ts +0 -9
  69. package/src/server/build-version-table.ts +12 -24
  70. package/src/server/context.ts +64 -2
  71. package/src/server/handle-store.ts +77 -10
  72. package/src/server/request-context.ts +4 -3
  73. package/src/testing/build-versions.ts +0 -3
  74. package/src/testing/dispatch.ts +1 -3
  75. package/src/testing/internal/seed-vars.ts +4 -4
  76. package/src/testing/serve-shell-request.ts +2 -5
  77. package/src/types/loader-types.ts +6 -1
  78. package/src/urls/pattern-types.ts +7 -6
  79. package/src/vite/discovery/shell-prerender-phase.ts +1 -5
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";
@@ -170,7 +174,6 @@ export function createRouter<TEnv = any>(
170
174
  $$routeNames: staticRouteNames,
171
175
  $$sourceFile: injectedSourceFile,
172
176
  nonce,
173
- version,
174
177
  prefetchCacheTTL: prefetchCacheTTLOption,
175
178
  prefetchCacheSize: prefetchCacheSizeOption,
176
179
  prefetchConcurrency: prefetchConcurrencyOption,
@@ -771,7 +774,7 @@ export function createRouter<TEnv = any>(
771
774
  // handler's overlay read makes (resolvePrerenderVersion), so the keys agree.
772
775
  const prerenderTrigger = createPrerenderTrigger<TEnv, {}>({
773
776
  routerId,
774
- resolveVersion: () => resolvePrerenderVersion(routerId, version),
777
+ resolveVersion: () => resolvePrerenderVersion(routerId),
775
778
  isDev: isDevEnvironment,
776
779
  isViteDevServer,
777
780
  ensureManifest: () => ensureRouterManifest(routerId),
@@ -803,7 +806,7 @@ export function createRouter<TEnv = any>(
803
806
  {},
804
807
  {
805
808
  ...prerenderDeps,
806
- versions: resolveRouterVersions(routerId, version),
809
+ versions: resolveRouterVersions(routerId),
807
810
  },
808
811
  undefined,
809
812
  isPassthrough,
@@ -812,7 +815,7 @@ export function createRouter<TEnv = any>(
812
815
  true,
813
816
  ),
814
817
  // A warm goes through router.fetch's handler: the one createRouter's own
815
- // cache, nonce and version configure. An entry that passes them to
818
+ // cache and nonce configure. An entry that passes them to
816
819
  // createRSCHandler itself is not what a warm runs.
817
820
  resolveCacheConfig: (env, ctx) =>
818
821
  typeof cache === "function" ? cache(env, ctx) : cache,
@@ -1222,9 +1225,6 @@ export function createRouter<TEnv = any>(
1222
1225
  // Expose nonce provider for fetch
1223
1226
  nonce,
1224
1227
 
1225
- // Expose version for fetch
1226
- version,
1227
-
1228
1228
  // Expose urlpatterns for runtime manifest generation
1229
1229
  get urlpatterns() {
1230
1230
  return storedUrlPatterns ?? undefined;
@@ -1278,6 +1278,18 @@ export function createRouter<TEnv = any>(
1278
1278
  });
1279
1279
  }
1280
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
+
1281
1293
  // Trigger lazy import of per-router manifest data before route matching.
1282
1294
  // No-op if data is already loaded or no loader is registered.
1283
1295
  await ensureRouterManifest(routerId);
@@ -1292,7 +1304,6 @@ export function createRouter<TEnv = any>(
1292
1304
  router: router as any,
1293
1305
  cache,
1294
1306
  nonce,
1295
- version,
1296
1307
  }) as (
1297
1308
  request: Request,
1298
1309
  input: RouterRequestInput<TEnv>,
@@ -165,12 +165,8 @@ export function createRSCHandler<
165
165
  TRoutes extends Record<string, string> = Record<string, string>,
166
166
  >(options: CreateRSCHandlerOptions<TEnv, TRoutes>) {
167
167
  const { router, nonce: nonceProvider } = options;
168
- // A consumer-set version (this option, else createRouter({ version })) is
169
- // used for both; otherwise the router's own pair from the build.
170
- const versions = resolveRouterVersions(
171
- router.id,
172
- options.version ?? router.version,
173
- );
168
+ // The router's own pair from the build.
169
+ const versions = resolveRouterVersions(router.id);
174
170
  // Everything the handler itself versions is tied to the HTML a tab holds:
175
171
  // payload metadata (what the browser echoes as `_rsc_v`), the reload check,
176
172
  // shell stamping and gating, the rango state value. The data version is only
@@ -548,7 +544,7 @@ export function createRSCHandler<
548
544
  resolvedPrerender = {
549
545
  config: prerenderConfig,
550
546
  routerId: router.id,
551
- version: resolvePrerenderVersion(router.id, router.version),
547
+ version: resolvePrerenderVersion(router.id),
552
548
  };
553
549
  }
554
550
  }
@@ -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
  });
@@ -208,13 +208,17 @@ export const SHELL_CAPTURE_TASK_HARD_CAP_MS = 25_000;
208
208
  * (ShellCaptureDebugEvent.refusal, `caches.refusal` on a `router.prerender()`
209
209
  * result): `identity` (a request-scoped read: cookies(), headers(), the
210
210
  * theme, a `{ cache: false }` variable), `dynamic` (`ctx.dynamic()`),
211
- * `loader` (a `{ ssr: false }` loader rejected, or settled with
212
- * redirect()/notFound()), `no-record` (no doc record: the route's cache()
213
- * refused the write, or the handler output failed), `handles` (a pushed
211
+ * `loader` (a `{ ssr: false }` loader rejected, settled with
212
+ * redirect()/notFound(), or a promise it pushed rejected),
213
+ * `no-record` (no doc record: the route's cache() refused the write, or the
214
+ * handler output failed), `handles` (a pushed
214
215
  * handle value did not finish encoding), `size` (over the store's value
215
- * limit), `record-expired` (the route cache() record the capture wrote ran
216
- * out first), `invalidated` (a tag of the shell was invalidated after the
217
- * 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).
218
222
  */
219
223
  export type ShellCaptureRefusal =
220
224
  | "identity"
@@ -508,6 +508,11 @@ function refuse(
508
508
  return "refused";
509
509
  }
510
510
 
511
+ /** A bake-lane loader's promise push, as a refusal names it. */
512
+ function describeLoaderPromisePush(push: LoaderPromisePush): string {
513
+ return `the promise the loader "${push.loaderId}" pushed to the handle "${push.handleName}"`;
514
+ }
515
+
511
516
  /** The no-shell cause for handler output that missed the capture deadline. */
512
517
  const HANDLER_OUTPUT_TIMEOUT_REASON: string =
513
518
  "the handler output (a promise it passes or pushes, or an async server " +
@@ -537,9 +542,8 @@ export const DEFAULT_SHELL_ENTRY_MAX_BYTES: number = 25 * 1024 * 1024;
537
542
  * while still fitting any sane pinned-ring payload. Applied ONLY in
538
543
  * captureAndStoreShell (the single defaulting site — resolvePprConfig passes
539
544
  * the option through undefaulted), so every producer and direct caller gets
540
- * the same policy. The doc record is exempt; over the cap the other records
541
- * are dropped (shell still stored and replayed; the pins' loaders read the
542
- * 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).
543
547
  */
544
548
  export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
545
549
 
@@ -548,12 +552,8 @@ const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
548
552
 
549
553
  /**
550
554
  * Warn once per key that the capture's loader pins exceeded the route's
551
- * `maxSnapshotBytes` cap and were dropped. The shell entry is still stored
552
- * with its doc record and every HIT replays the handler layer; only the
553
- * pinned loaders lose their captured values, so their cached data can drift
554
- * from the frozen prelude and hydration repairs it client-side. Once per
555
- * key: the same page recaptures on every TTL roll and would otherwise
556
- * re-warn forever.
555
+ * `maxSnapshotBytes` cap, so no document shell was stored (dc-t89). Once per
556
+ * key: the route recaptures every backoff window.
557
557
  */
558
558
  function warnSnapshotOverCapOnce(
559
559
  key: string,
@@ -565,14 +565,10 @@ function warnSnapshotOverCapOnce(
565
565
  key,
566
566
  () =>
567
567
  `[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
568
- `loader pins, over the ${capBytes}-byte cap — the pins were dropped and ` +
569
- "the shell was stored with its doc record only. HITs still replay the " +
570
- "handler layer, but ssr: false loaders now read the live store: if " +
571
- "their data drifts before the shell's TTL, hydration repairs the " +
572
- "mismatch client-side. Raise the cap via the route's ppr option " +
573
- "({ maxSnapshotBytes }) if the entry still fits your store's value limit " +
574
- "(Cloudflare KV: 25 MiB per value), or shrink the cache()'d data the " +
575
- "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.",
576
572
  );
577
573
  }
578
574
 
@@ -678,7 +674,11 @@ export interface ShellCaptureDebugEvent {
678
674
  preludeBytes?: number;
679
675
  /** Serialized snapshot size in UTF-8 bytes. Absent when nothing was recorded. */
680
676
  snapshotBytes?: number;
681
- /** 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
+ */
682
682
  snapshotSkipped?: boolean;
683
683
  /** A bake-lane loader settled into a shell that uses TTL/SWR-only invalidation. */
684
684
  untaggedBake?: true;
@@ -1048,8 +1048,8 @@ export interface ShellCaptureDescriptor {
1048
1048
  captureTimeout?: number;
1049
1049
  store?: SegmentCacheStore<any>;
1050
1050
  /**
1051
- * Cap (serialized UTF-8 bytes) on the entry's capture data snapshot; over it
1052
- * 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
1053
1053
  * key). Absent = DEFAULT_PPR_MAX_SNAPSHOT_BYTES, applied in
1054
1054
  * captureAndStoreShell — the single defaulting site.
1055
1055
  */
@@ -1667,8 +1667,9 @@ export type CaptureRecordOutcome =
1667
1667
  * component showed one value in the prelude and another on every HIT.
1668
1668
  *
1669
1669
  * Order, all bounded by the one capture deadline (`ppr.captureTimeout`):
1670
- * top-level handle promises, the handler pushes' nested promises, and the
1671
- * bake-lane loader containers settle first (the record encodes the handles
1670
+ * top-level handle promises (a bake-lane loader's pushed promises among
1671
+ * them, #1182), the handler pushes' nested promises, and the bake-lane
1672
+ * loader containers settle first (the record encodes the handles
1672
1673
  * a HIT restores, and its handle encode has its own 5s timeout that must
1673
1674
  * never be the one that fires); then the capture's onResponse callbacks fire
1674
1675
  * with a synthetic 200 (the implicit doc scope's cacheRoute, an explicit
@@ -1711,7 +1712,33 @@ export async function settleCaptureRecord(
1711
1712
  if (!settled.done) {
1712
1713
  if (refuseOnCaptureGuard(capture.key, derivedCtx))
1713
1714
  return { kind: "refused", refusal: guardRefusal(derivedCtx) };
1714
- return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
1715
+ const pending = derivation.loaderPromisePushes.find(
1716
+ (p) => p.state === "pending",
1717
+ );
1718
+ return {
1719
+ kind: "timeout",
1720
+ reason: pending
1721
+ ? `${describeLoaderPromisePush(pending)} did not settle within ppr.captureTimeout`
1722
+ : HANDLER_OUTPUT_TIMEOUT_REASON,
1723
+ };
1724
+ }
1725
+ // A rejected value must not bake, and the record's encode would fail on
1726
+ // it anyway: refuse before the write starts. A guard trip in that promise
1727
+ // names the read instead.
1728
+ const rejected = derivation.loaderPromisePushes.find(
1729
+ (p) => p.state === "rejected",
1730
+ );
1731
+ if (rejected) {
1732
+ if (refuseOnCaptureGuard(capture.key, derivedCtx))
1733
+ return { kind: "refused", refusal: guardRefusal(derivedCtx) };
1734
+ warnCaptureRefusedOnce(
1735
+ capture.key,
1736
+ `${describeLoaderPromisePush(rejected)} rejected during capture; a ` +
1737
+ "failed value must not bake into the shared shell. Catch it and push " +
1738
+ "a fallback, or push it from a loader without ssr: false.",
1739
+ PPR_LANE_HINT,
1740
+ );
1741
+ return { kind: "refused", refusal: "loader" };
1715
1742
  }
1716
1743
 
1717
1744
  const callbacks = derivedCtx._onResponseCallbacks?.splice(0) ?? [];
@@ -2006,6 +2033,20 @@ export interface CaptureContextDerivation {
2006
2033
  * record encodes its handles, so that encode never races a pending value.
2007
2034
  */
2008
2035
  handlerPushesSettled: () => Promise<void>;
2036
+ /**
2037
+ * Every promise a bake-lane loader body pushed (#1182), in push order.
2038
+ * The record-first step waits for them (they are in the recorded cut) and
2039
+ * names the loader and the handle of one that rejects or misses the
2040
+ * deadline.
2041
+ */
2042
+ loaderPromisePushes: readonly LoaderPromisePush[];
2043
+ }
2044
+
2045
+ /** A promise a bake-lane loader pushed at capture, owned by that loader. */
2046
+ export interface LoaderPromisePush {
2047
+ loaderId: string;
2048
+ handleName: string;
2049
+ state: "pending" | "fulfilled" | "rejected";
2009
2050
  }
2010
2051
 
2011
2052
  /**
@@ -2041,30 +2082,35 @@ export function deriveShellCaptureContext(
2041
2082
  // masked), their pushes are in the captured HTML (<head> title/meta,
2042
2083
  // useHandle echoes), and a HIT does not run a promise-free bake-lane
2043
2084
  // loader (loader-cache.ts), so the stored record carries them: each
2044
- // settled, thenable-free push from a loader body keeps that loader as
2045
- // `owner` (HandleStore.push), the record stores it
2085
+ // push from a loader body whose value, once settled, is thenable-free
2086
+ // keeps that loader as `owner` (HandleStore.push), the record stores it
2046
2087
  // (CachedEntryData.handleOwners), and a replay that serves the loader
2047
- // from its pin restores it through pushRestored (restoreHandles). Those
2048
- // copies stand: a promise-carrying bake-lane loader that runs on the
2049
- // replay reads the store, so its settled pushes (and those replayed
2050
- // inside its body) are dropped and only its thenable ones are added. A
2051
- // loader the replay does not serve from a pin is a hole: one the route
2052
- // also runs on the live lane, or one whose pin the entry lost. Its run
2053
- // replaces its copies (pushPlaceholder, #936), those of the
2054
- // dependencies it awaits included (they are credited to it). Nested
2055
- // thenables stay masked (the promise shape is the liveness
2056
- // declaration, mask-nested.ts). A deferred (thenable) push,
2057
- // one with masked nested promises, or one outside a loader body (a
2058
- // loader-cache replay names its loader: pushReplayed below) stays
2059
- // tagged (HandleStore.push loaderPush) so the
2060
- // handle encode cannot stall on a never-resolving mask. A tagged push
2061
- // is out of the record (captureHandles) and out of the capture's own
2062
- // render (resolvedHandleStream `recordedOnly`, issue #1035): the
2063
- // prelude is rendered from what the record keeps, which is what a HIT
2064
- // hydrates with. The loader records then carry `runs: 1` so a HIT runs
2065
- // those bodies for them, and what they push reaches the client after
2088
+ // from its pin restores it through pushRestored (restoreHandles). A
2089
+ // pushed promise (push(promise), .defer()) is judged by its settled
2090
+ // value, as push(await promise) would be (#1182): the record-first step
2091
+ // waits for it under the capture deadline, and one that rejects or
2092
+ // misses the deadline refuses the capture. Those copies stand: a
2093
+ // promise-carrying bake-lane loader that runs on the replay reads the
2094
+ // store, so its pushes that settle thenable-free (and those replayed
2095
+ // inside its body) are dropped and only the ones holding a promise are
2096
+ // added. A loader the replay does not serve from a pin is a hole: one
2097
+ // the route also runs on the live lane, or one whose pin the entry
2098
+ // lost. Its run replaces its copies (pushPlaceholder, #936), those of
2099
+ // the dependencies it awaits included (they are credited to it). Nested
2100
+ // thenables stay masked (the promise shape is the liveness declaration,
2101
+ // mask-nested.ts). A push whose settled value holds a masked nested
2102
+ // promise, one credited to a live-lane body, or one no body or replay
2103
+ // names (a loader-cache replay names its loader: pushReplayed below)
2104
+ // stays tagged (HandleStore.push loaderPush) so the handle encode cannot
2105
+ // stall on a never-resolving mask. A tagged push is out of the record
2106
+ // (captureHandles) and out of the capture's own render
2107
+ // (resolvedHandleStream `recordedOnly`, issue #1035): the prelude is
2108
+ // rendered from what the record keeps, which is what a HIT hydrates
2109
+ // with. The loader records then carry `runs: 1` so a HIT runs those
2110
+ // bodies for them, and what they push reaches the client after
2066
2111
  // hydration (HandleStore.freezeDocumentSnapshot).
2067
2112
  const handlerPushSettles: Promise<void>[] = [];
2113
+ const loaderPromisePushes: LoaderPromisePush[] = [];
2068
2114
  // A replay (a loader-cache HIT, loader-cache.ts replayLoaderHandles, or a
2069
2115
  // route cache() record's owned values, restoreHandles) re-pushes a loader's
2070
2116
  // recorded values through pushReplayed or pushPlaceholder, which call
@@ -2118,39 +2164,62 @@ export function deriveShellCaptureContext(
2118
2164
  if (report.thenable) maskedNestedThenable = true;
2119
2165
  return masked;
2120
2166
  };
2167
+ // The loader the push is credited to (see the funnel comment above). A
2168
+ // push under a live-lane loader's body, at any depth, is that hole's
2169
+ // output: its live run replaces it on a HIT and a navigation. Otherwise
2170
+ // the first loader around the push that the route registers decides: a
2171
+ // bake-lane one keeps the push under the innermost body, and a
2172
+ // dependency on neither lane is walked past. A replay of a hole's push
2173
+ // is that hole's, unless a live-lane body around it takes it first.
2174
+ const live = liveLaneOwner();
2175
+ const bodyLoaderId = live ?? getCurrentLoaderBodyId() ?? replayOwner;
2121
2176
  let masked: unknown;
2122
- let loaderPush = true;
2123
2177
  let owner: string | undefined;
2124
- if (isThenable(value)) {
2125
- // Deferred (thenable) loader pushes always keep the tag — the
2126
- // carve-out below is for settled values only. Promise.resolve first: a
2127
- // loader cache() entry replays a deferred push as the thenable Flight
2128
- // decoded it to, whose then() returns nothing, and the slot was
2129
- // `undefined` (the empty element of issue #1035).
2130
- masked = Promise.resolve(value).then(mask);
2131
- } else {
2178
+ if (!isThenable(value)) {
2132
2179
  masked = mask(value);
2133
- if (!maskedNestedThenable) {
2134
- // A settled, thenable-free push from a loader body is shell
2135
- // material (see the funnel comment above): only bake-lane loaders
2136
- // and the loaders they await run at capture, and a HIT does not run
2137
- // a promise-free bake-lane loader (loader-cache.ts), so the record
2138
- // keeps the push under the loader that made it. A push under a
2139
- // live-lane loader's body, at any depth, is that hole's output: it
2140
- // is credited to the hole, whose live run replaces it on a HIT and
2141
- // a navigation. The first loader around the push that the route
2142
- // registers decides: a bake-lane one keeps the push under the
2143
- // innermost body, and a dependency on neither lane is walked past. A
2144
- // replay of a hole's push is that hole's, unless a live-lane body
2145
- // around it takes it first.
2146
- const bodyLoaderId =
2147
- liveLaneOwner() ?? getCurrentLoaderBodyId() ?? replayOwner;
2148
- loaderPush = bodyLoaderId === undefined;
2149
- if (!loaderPush) owner = bodyLoaderId;
2150
- }
2180
+ if (!maskedNestedThenable) owner = bodyLoaderId;
2181
+ } else if (live === undefined && bodyLoaderId !== undefined) {
2182
+ // Owned now, judged by its settled value (funnel comment above).
2183
+ // Promise.resolve first: a loader cache() entry replays a deferred
2184
+ // push as the thenable Flight decoded it to, whose then() returns
2185
+ // nothing (issue #1035). The retag runs inside the slot's own then(),
2186
+ // so it lands before the wait on it (settleCaptureRecord) resumes and
2187
+ // the record encodes. The state handler is attached now, which also
2188
+ // marks a rejection as handled.
2189
+ const slot: Promise<unknown> = Promise.resolve(value).then((settled) => {
2190
+ const judged = mask(settled);
2191
+ if (maskedNestedThenable) {
2192
+ freshHandleStore.unrecordPush(handleName, segmentId, slot);
2193
+ derivedCtx._shellCaptureUnrecordedLoaderPush = true;
2194
+ }
2195
+ return judged;
2196
+ });
2197
+ const push: LoaderPromisePush = {
2198
+ loaderId: bodyLoaderId,
2199
+ handleName,
2200
+ state: "pending",
2201
+ };
2202
+ slot.then(
2203
+ () => {
2204
+ push.state = "fulfilled";
2205
+ },
2206
+ () => {
2207
+ push.state = "rejected";
2208
+ },
2209
+ );
2210
+ loaderPromisePushes.push(push);
2211
+ masked = slot;
2212
+ owner = bodyLoaderId;
2213
+ } else {
2214
+ // A hole's promise, or one no body or replay names: tagged. Nothing
2215
+ // awaits the slot at capture, so its rejection is handled here.
2216
+ const tagged = Promise.resolve(value).then(mask);
2217
+ tagged.catch(() => {});
2218
+ masked = tagged;
2151
2219
  }
2152
2220
  // A push the record cannot keep reaches a HIT only from a live run of
2153
2221
  // its loader, so the loader records ask for one (runs: 1).
2222
+ const loaderPush = owner === undefined;
2154
2223
  if (loaderPush) derivedCtx._shellCaptureUnrecordedLoaderPush = true;
2155
2224
  rawCapturePush(handleName, segmentId, masked, loaderPush, owner);
2156
2225
  };
@@ -2271,6 +2340,7 @@ export function deriveShellCaptureContext(
2271
2340
  freshHandleStore,
2272
2341
  handlerPushesSettled: () =>
2273
2342
  Promise.all(handlerPushSettles).then(() => undefined),
2343
+ loaderPromisePushes,
2274
2344
  };
2275
2345
  }
2276
2346
 
@@ -2481,10 +2551,13 @@ async function captureAndStoreShell(
2481
2551
  runs,
2482
2552
  },
2483
2553
  });
2554
+ } else {
2555
+ throw new Error(`[rango] no shell pin for "${segmentKey}"`);
2484
2556
  }
2485
- } catch {
2486
- // Codec import failed: leave it unpinned (it drifts on a HIT, the
2487
- // 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);
2488
2561
  }
2489
2562
  }
2490
2563
  }
@@ -2528,12 +2601,11 @@ async function captureAndStoreShell(
2528
2601
  snapshot = kept;
2529
2602
  }
2530
2603
 
2531
- // Snapshot size guard (issue #651): `maxSnapshotBytes` bounds the loader
2532
- // pins; over it they are dropped and the entry keeps its doc record, so
2533
- // every HIT still replays the handler layer (the pins' loaders then run
2534
- // on the HIT: documented drift, repaired client-side). The doc record is
2535
- // exempt: without it a HIT could not serve at all. The whole entry is
2536
- // 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.
2537
2609
  if (snapshot.length > 0) {
2538
2610
  const docRecord =
2539
2611
  docKey !== undefined
@@ -2554,9 +2626,10 @@ async function captureAndStoreShell(
2554
2626
  stats.snapshotSkipped = true;
2555
2627
  }
2556
2628
  }
2629
+ const navigationOnly = capture.navigationOnly || stats.snapshotSkipped;
2557
2630
  // Exempt from maxSnapshotBytes: the HIT needs it for its HTML and
2558
2631
  // hydration data to match.
2559
- if (prerenderHandles) {
2632
+ if (prerenderHandles && !navigationOnly) {
2560
2633
  snapshot.push({
2561
2634
  family: "handles",
2562
2635
  key: SHELL_HANDLES_RECORD_KEY,
@@ -2605,8 +2678,8 @@ async function captureAndStoreShell(
2605
2678
  // JSON head as an escaped string.
2606
2679
  const entryBytes = estimateShellEntryBytes({
2607
2680
  // A navigation-only entry stores no document half.
2608
- preludeBytes: capture.navigationOnly ? 0 : result.prelude.length,
2609
- postponed: capture.navigationOnly ? undefined : result.postponed,
2681
+ preludeBytes: navigationOnly ? 0 : result.prelude.length,
2682
+ postponed: navigationOnly ? undefined : result.postponed,
2610
2683
  snapshot,
2611
2684
  tags: shellTags,
2612
2685
  docKey: storedDocKey,
@@ -2642,7 +2715,7 @@ async function captureAndStoreShell(
2642
2715
  // consumes only snapshot/docKey), so storing it would ride every KV
2643
2716
  // write/read as dead weight — the prerender still ran above as the
2644
2717
  // completeness arbiter and sanity gate, its output is dropped here.
2645
- ...(capture.navigationOnly
2718
+ ...(navigationOnly
2646
2719
  ? {}
2647
2720
  : {
2648
2721
  // bytesToBase64 encodes the view's own region, so a prelude
@@ -2665,7 +2738,7 @@ async function captureAndStoreShell(
2665
2738
  // looks the record up by this key (serveShellHit fixedDocKey);
2666
2739
  // undefined only for a prerender-served entry.
2667
2740
  docKey: storedDocKey,
2668
- navigationOnly: capture.navigationOnly,
2741
+ navigationOnly: navigationOnly || undefined,
2669
2742
  createdAt: captureStartedAt,
2670
2743
  };
2671
2744
  // Capped to the route record the capture used: the policy the
@@ -2753,8 +2826,9 @@ async function captureAndStoreShell(
2753
2826
  }
2754
2827
  // A shell was captured (ordinary store I/O failure is reported, not retried).
2755
2828
  // An acknowledged invalidation rejection returned `refused` above so the
2756
- // scheduler backs the key off instead of recapturing on every request.
2757
- 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";
2758
2832
  } finally {
2759
2833
  // Stop the hop loop for the pathological never-quiets path (quiesce never
2760
2834
  // fired, capture returned via maxWaitMs). On the normal path the loop already
@@ -2774,7 +2848,8 @@ export {
2774
2848
  };
2775
2849
 
2776
2850
  // Exported for unit tests that pin the refused-capture backoff policy directly
2777
- // (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).
2778
2853
  // These are the same module-level functions the schedule path uses; a test that
2779
2854
  // drove them through a real capture round-trip could not assert the exact window
2780
2855
  // arithmetic without a full cold render.