@rangojs/router 0.25.0 → 0.26.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.
Files changed (72) 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/types.d.ts +8 -6
  6. package/dist/types/cache/vercel/vercel-cache-store.d.ts +1 -15
  7. package/dist/types/handles/deferred-resolution.d.ts +3 -2
  8. package/dist/types/handles/meta.d.ts +5 -0
  9. package/dist/types/prerender/build-shell-capture.d.ts +1 -2
  10. package/dist/types/prerender/writable-store.d.ts +2 -3
  11. package/dist/types/router/router-interfaces.d.ts +0 -5
  12. package/dist/types/router/router-options.d.ts +0 -20
  13. package/dist/types/router/segment-resolution/mask-nested.d.ts +5 -3
  14. package/dist/types/router-versions.d.ts +8 -6
  15. package/dist/types/rsc/shell-capture-constants.d.ts +4 -3
  16. package/dist/types/rsc/shell-capture.d.ts +16 -2
  17. package/dist/types/rsc/types.d.ts +0 -8
  18. package/dist/types/server/build-version-table.d.ts +10 -13
  19. package/dist/types/server/context.d.ts +26 -2
  20. package/dist/types/server/handle-store.d.ts +17 -6
  21. package/dist/types/server/request-context.d.ts +4 -3
  22. package/dist/types/testing/build-versions.d.ts +0 -3
  23. package/dist/types/testing/internal/seed-vars.d.ts +4 -4
  24. package/dist/types/testing/serve-shell-request.d.ts +1 -1
  25. package/dist/types/types/loader-types.d.ts +6 -1
  26. package/dist/vite/index.js +3 -8
  27. package/package.json +1 -1
  28. package/skills/cache-guide/SKILL.md +4 -8
  29. package/skills/caching/SKILL.md +3 -5
  30. package/skills/comparison/references/framework-comparison.md +1 -1
  31. package/skills/hooks/handle-and-actions.md +6 -6
  32. package/skills/host-router/SKILL.md +1 -1
  33. package/skills/loader/SKILL.md +10 -9
  34. package/skills/ppr/SKILL.md +60 -44
  35. package/skills/prerender/SKILL.md +3 -3
  36. package/skills/router-setup/SKILL.md +0 -2
  37. package/skills/testing/render-handler.md +17 -17
  38. package/skills/testing/serve-shell-request.md +1 -1
  39. package/skills/testing/server-actions.md +16 -16
  40. package/skills/vercel/SKILL.md +1 -1
  41. package/src/cache/cf/cf-cache-store.ts +4 -7
  42. package/src/cache/cf/cf-cache-types.ts +0 -17
  43. package/src/cache/handle-snapshot.ts +2 -2
  44. package/src/cache/types.ts +8 -6
  45. package/src/cache/vercel/vercel-cache-store.ts +2 -18
  46. package/src/handles/deferred-resolution.ts +3 -2
  47. package/src/handles/meta.ts +5 -0
  48. package/src/prerender/build-shell-capture.ts +1 -2
  49. package/src/prerender/writable-store.ts +2 -3
  50. package/src/route-definition/dsl-helpers.ts +14 -14
  51. package/src/router/manifest.ts +9 -2
  52. package/src/router/match-middleware/intercept-resolution.ts +2 -2
  53. package/src/router/router-interfaces.ts +0 -6
  54. package/src/router/router-options.ts +0 -21
  55. package/src/router/segment-resolution/loader-cache.ts +4 -3
  56. package/src/router/segment-resolution/mask-nested.ts +5 -3
  57. package/src/router-versions.ts +7 -7
  58. package/src/router.ts +3 -8
  59. package/src/rsc/handler.ts +3 -7
  60. package/src/rsc/shell-capture-constants.ts +4 -3
  61. package/src/rsc/shell-capture.ts +126 -51
  62. package/src/rsc/types.ts +0 -9
  63. package/src/server/build-version-table.ts +12 -24
  64. package/src/server/context.ts +64 -2
  65. package/src/server/handle-store.ts +77 -10
  66. package/src/server/request-context.ts +4 -3
  67. package/src/testing/build-versions.ts +0 -3
  68. package/src/testing/dispatch.ts +1 -3
  69. package/src/testing/internal/seed-vars.ts +4 -4
  70. package/src/testing/serve-shell-request.ts +2 -5
  71. package/src/types/loader-types.ts +6 -1
  72. package/src/vite/discovery/shell-prerender-phase.ts +1 -5
@@ -106,8 +106,7 @@ export interface BuildShellCaptureOptions {
106
106
  /** Build-time env bindings (rango plugin buildEnv), if configured. */
107
107
  buildEnv?: unknown;
108
108
  /**
109
- * The versions the SHIPPED build serves this router with (the build table,
110
- * or the router's consumer-set version) — NOT the temp server's own
109
+ * The versions the SHIPPED build serves this router with (the build table) — NOT the temp server's own
111
110
  * version-plugin stamp. The serve-side isValidShellHit gate compares
112
111
  * entry.buildVersion against the running worker's ctx.version, which is the
113
112
  * document version; stamping the temp server's would make every build entry
@@ -22,9 +22,8 @@ import { paramsEqual } from "../router/params-util.js";
22
22
  export interface PrerenderKey {
23
23
  routerId: string;
24
24
  /**
25
- * The owning router's cache version (its data version, or
26
- * `createRouter({ version })`). Opaque: never set or read by app code; a
27
- * store keys off it as given.
25
+ * The owning router's cache version (its data version). Opaque: never set
26
+ * or read by app code; a store keys off it as given.
28
27
  */
29
28
  version: string;
30
29
  routeName: string;
@@ -9,6 +9,8 @@ import type {
9
9
  TransitionConfig,
10
10
  } from "../types.js";
11
11
  import {
12
+ applyDocumentModeLoading,
13
+ documentModeLoader,
12
14
  getContext,
13
15
  getNamePrefix,
14
16
  getReverseScope,
@@ -928,17 +930,14 @@ const loader: RouteHelpers<any, any>["loader"] = (
928
930
 
929
931
  const name = `${ctx.namespace}.$${store.getNextIndex("loader")}`;
930
932
 
931
- // Create loader entry with empty revalidate array. awaitBeforeFlush is
932
- // resolved here, at DSL-evaluation time, for the same reason
933
- // loading({ ssr: false }) is (below) — per-isSSR entry caching makes the
934
- // flag request-mode-correct with no isSSR threading; see LoaderEntry.
935
- // `bake` is the lane, the same on both evaluations.
933
+ // Create loader entry with empty revalidate array. `bake` is the lane, the
934
+ // same on both evaluations; a document evaluation adds awaitBeforeFlush at
935
+ // the push (documentModeLoader) — per-isSSR entry caching makes the flag
936
+ // request-mode-correct with no isSSR threading; see LoaderEntry.
936
937
  const loaderEntry: LoaderEntry = {
937
938
  loader: loaderDef,
938
939
  revalidate: [] as ShouldRevalidateFn<any, any>[],
939
940
  ...(optionsGiven?.ssr === false && { bake: true as const }),
940
- ...(optionsGiven?.ssr === false &&
941
- ctx.isSSR && { awaitBeforeFlush: true as const }),
942
941
  ...(optionsGiven?.prefetch === false && { prefetch: false as const }),
943
942
  // The declaring layout's / parallel's / cache()'s scope. A route's (and
944
943
  // an intercept's) loaders have none: they resolve from the route's name,
@@ -978,7 +977,9 @@ const loader: RouteHelpers<any, any>["loader"] = (
978
977
  validateUseItems(result, name, "loader", "use");
979
978
  }
980
979
 
981
- ctx.parent.loader.push(loaderEntry);
980
+ ctx.parent.loader.push(
981
+ ctx.isSSR ? documentModeLoader(loaderEntry) : loaderEntry,
982
+ );
982
983
  return { name, type: "loader" } as LoaderItem;
983
984
  };
984
985
 
@@ -1006,12 +1007,11 @@ const loading: RouteHelpers<any, any>["loading"] = (component, options) => {
1006
1007
  const resolved =
1007
1008
  typeof component === "function" ? (component as () => any)() : component;
1008
1009
 
1009
- // If ssr: false and we're in SSR, set loading to false
1010
- if (options?.ssr === false && ctx.isSSR) {
1011
- parent.loading = false;
1012
- } else {
1013
- parent.loading = resolved;
1014
- }
1010
+ // `loadingSsr` records ssr: false on both evaluations; a document drops
1011
+ // the fallback (applyDocumentModeLoading).
1012
+ parent.loading = resolved;
1013
+ parent.loadingSsr = options?.ssr === false ? false : undefined;
1014
+ if (ctx.isSSR) applyDocumentModeLoading(parent);
1015
1015
 
1016
1016
  // The flag means "what this fallback covers": without a fallback to show
1017
1017
  // there is nothing to defer behind. Recorded from `resolved`, not
@@ -3,6 +3,7 @@ import { createRouteHelpers } from "../route-definition";
3
3
  import {
4
4
  getContext,
5
5
  runWithPrefixes,
6
+ getDocumentModeEntry,
6
7
  getIsolatedLazyParent,
7
8
  type EntryData,
8
9
  type MetricsStore,
@@ -120,12 +121,18 @@ export async function loadManifest(
120
121
  mountIndex !== undefined ? `#router.M${mountIndex}` : "#router";
121
122
 
122
123
  // For lazy entries, use the captured parent from include() context
123
- // This ensures routes are registered under the correct layout hierarchy
124
+ // This ensures routes are registered under the correct layout hierarchy.
125
+ // The captured chain comes from an evaluation without isSSR: a document
126
+ // request takes its document-mode copy (getDocumentModeEntry, #1179).
124
127
  const lazyContext =
125
128
  entry.lazy && entry.lazyPatterns ? entry.lazyContext : null;
129
+ const capturedParent =
130
+ (lazyContext?.parent as EntryData | null | undefined) ?? Store.parent;
126
131
  const parentForContext = lazyContext
127
132
  ? getIsolatedLazyParent(
128
- (lazyContext.parent as EntryData | null) ?? Store.parent,
133
+ isSSR && capturedParent
134
+ ? getDocumentModeEntry(capturedParent)
135
+ : capturedParent,
129
136
  )
130
137
  : Store.parent;
131
138
 
@@ -232,8 +232,8 @@ async function handleCacheHitIntercept<TEnv>(
232
232
  const cachedSlot = segments.filter(isInterceptSegment);
233
233
  // A record stored before 0.25.0 carries the loaders' aggregate on the slot;
234
234
  // the loader segments below replace it. Build versions hash the router's
235
- // code, so only a `version` pinned across the upgrade (createRouter's or
236
- // the store's) still reads one.
235
+ // code, so an upgrade does not read one; a custom store that ignores
236
+ // versions can.
237
237
  for (const slot of cachedSlot) {
238
238
  delete slot.loaderDataPromise;
239
239
  delete slot.loaderIds;
@@ -479,12 +479,6 @@ export interface RangoInternal<
479
479
  */
480
480
  readonly nonce?: NonceProvider<TEnv>;
481
481
 
482
- /**
483
- * The consumer-set version (createRouter({ version })), used for both of the
484
- * router's versions. Undefined when the router uses its build versions.
485
- */
486
- readonly version?: string;
487
-
488
482
  /**
489
483
  * URL patterns reference for build-time manifest generation
490
484
  */
@@ -531,27 +531,6 @@ export interface RangoOptions<TEnv = any> {
531
531
  */
532
532
  nonce?: NonceProvider<TEnv>;
533
533
 
534
- /**
535
- * Version override. When set, this exact value is used for both of the
536
- * router's versions: it keys the router's cached data (segments, `"use
537
- * cache"` values, loader data) and its stored HTML (PPR shells,
538
- * document-cache responses), is sent in payload metadata, and is what the
539
- * browser's `_rsc_v` is compared with (a mismatch reloads the tab).
540
- *
541
- * Leave it unset for the default: rango computes two versions per router at
542
- * build time, a data version from the router's server code and a document
543
- * version that also covers the client assets. The same code builds to the
544
- * same versions, so a deploy that does not change this router keeps its
545
- * cache. In dev both are a stamp bumped on every RSC module edit.
546
- *
547
- * Set it to clear this router's cache on demand (change the value), or to
548
- * tie the cache to your own release id (a per-deploy value clears the cache
549
- * on every deploy).
550
- *
551
- * @default the router's build versions
552
- */
553
- version?: string;
554
-
555
534
  /**
556
535
  * TTL (in seconds) for the client-side in-memory prefetch cache.
557
536
  *
@@ -378,9 +378,10 @@ function getLoaderStore(
378
378
  * push funnel reads the pushReplayed owner, shell-capture.ts), so a replay
379
379
  * of that shell that serves this loader from its pin restores them owned
380
380
  * (HandleStore.pushRestored), on a document HIT and on a navigation alike.
381
- * This replay still runs there: the store drops its settled values, which
382
- * the record already restored, and keeps the thenable ones the record could
383
- * not keep (a deferred push, the reason the loader's pin carries `runs`).
381
+ * This replay still runs there: the store drops its values that settle
382
+ * thenable-free, which the record already restored, and keeps the ones
383
+ * holding a promise the record could not keep (the reason the loader's pin
384
+ * carries `runs`).
384
385
  */
385
386
  async function replayLoaderHandles(
386
387
  encoded: string | undefined,
@@ -125,9 +125,11 @@ function someNestedThenable(
125
125
  /**
126
126
  * True when `value` is a thenable or holds one where the capture's loader
127
127
  * push funnel masks it (maskNestedContainerThenables): through plain data
128
- * objects, arrays and React element props. Such a push stays out of a shell's
129
- * record and reaches a HIT only from a run of its loader there, so a HIT
130
- * keeps it; a push without one is the record's (HandleStore.pushRestored).
128
+ * objects, arrays and React element props. A settled push that holds one
129
+ * stays out of a shell's record and reaches a HIT only from a run of its
130
+ * loader there, so a HIT keeps it; a push without one is the record's
131
+ * (HandleStore.pushRestored). A top-level thenable is judged by the value it
132
+ * settles to, on both sides (#1182).
131
133
  */
132
134
  export function holdsThenable(value: unknown): boolean {
133
135
  return someNestedThenable(value, () => true);
@@ -33,24 +33,24 @@ export type RouterVersionsTable = Readonly<
33
33
  /** Table key of the whole-build versions. Not a legal `$$id` (8 hex chars). */
34
34
  export const DEFAULT_ROUTER_VERSIONS_KEY = "*";
35
35
 
36
- /** One value for both versions: a consumer-set `version`, or the dev stamp. */
36
+ /**
37
+ * One value for both versions: the dev stamp, the version a build-time shell
38
+ * capture is asked for, or a test's seed.
39
+ */
37
40
  export function uniformVersions(version: string): RouterVersions {
38
41
  return { data: version, document: version };
39
42
  }
40
43
 
41
44
  /**
42
- * The versions a router serves with under `table`: a consumer-set `version`
43
- * for both; else the router's own entry; else the whole-build pair, for a
44
- * router the table does not list. `undefined` when there is no table (dev,
45
- * tests) or it has neither entry. One function for the request handler and
45
+ * The versions a router serves with under `table`: the router's own entry,
46
+ * else the whole-build pair, for a router the table does not list.
47
+ * `undefined` when there is no table (dev, tests) or it has neither entry. One function for the request handler and
46
48
  * the build's shell capture, which must stamp what the handler will check.
47
49
  */
48
50
  export function resolveVersionsFrom(
49
51
  table: RouterVersionsTable | undefined,
50
52
  routerId: string | undefined,
51
- override: string | undefined,
52
53
  ): RouterVersions | undefined {
53
- if (override !== undefined) return uniformVersions(override);
54
54
  if (!table) return undefined;
55
55
  const pair =
56
56
  (routerId !== undefined && Object.hasOwn(table, routerId)
package/src/router.ts CHANGED
@@ -170,7 +170,6 @@ export function createRouter<TEnv = any>(
170
170
  $$routeNames: staticRouteNames,
171
171
  $$sourceFile: injectedSourceFile,
172
172
  nonce,
173
- version,
174
173
  prefetchCacheTTL: prefetchCacheTTLOption,
175
174
  prefetchCacheSize: prefetchCacheSizeOption,
176
175
  prefetchConcurrency: prefetchConcurrencyOption,
@@ -771,7 +770,7 @@ export function createRouter<TEnv = any>(
771
770
  // handler's overlay read makes (resolvePrerenderVersion), so the keys agree.
772
771
  const prerenderTrigger = createPrerenderTrigger<TEnv, {}>({
773
772
  routerId,
774
- resolveVersion: () => resolvePrerenderVersion(routerId, version),
773
+ resolveVersion: () => resolvePrerenderVersion(routerId),
775
774
  isDev: isDevEnvironment,
776
775
  isViteDevServer,
777
776
  ensureManifest: () => ensureRouterManifest(routerId),
@@ -803,7 +802,7 @@ export function createRouter<TEnv = any>(
803
802
  {},
804
803
  {
805
804
  ...prerenderDeps,
806
- versions: resolveRouterVersions(routerId, version),
805
+ versions: resolveRouterVersions(routerId),
807
806
  },
808
807
  undefined,
809
808
  isPassthrough,
@@ -812,7 +811,7 @@ export function createRouter<TEnv = any>(
812
811
  true,
813
812
  ),
814
813
  // 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
814
+ // cache and nonce configure. An entry that passes them to
816
815
  // createRSCHandler itself is not what a warm runs.
817
816
  resolveCacheConfig: (env, ctx) =>
818
817
  typeof cache === "function" ? cache(env, ctx) : cache,
@@ -1222,9 +1221,6 @@ export function createRouter<TEnv = any>(
1222
1221
  // Expose nonce provider for fetch
1223
1222
  nonce,
1224
1223
 
1225
- // Expose version for fetch
1226
- version,
1227
-
1228
1224
  // Expose urlpatterns for runtime manifest generation
1229
1225
  get urlpatterns() {
1230
1226
  return storedUrlPatterns ?? undefined;
@@ -1292,7 +1288,6 @@ export function createRouter<TEnv = any>(
1292
1288
  router: router as any,
1293
1289
  cache,
1294
1290
  nonce,
1295
- version,
1296
1291
  }) as (
1297
1292
  request: Request,
1298
1293
  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
  }
@@ -208,9 +208,10 @@ 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
216
  * limit), `record-expired` (the route cache() record the capture wrote ran
216
217
  * out first), `invalidated` (a tag of the shell was invalidated after the
@@ -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 " +
@@ -1667,8 +1672,9 @@ export type CaptureRecordOutcome =
1667
1672
  * component showed one value in the prelude and another on every HIT.
1668
1673
  *
1669
1674
  * 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
1675
+ * top-level handle promises (a bake-lane loader's pushed promises among
1676
+ * them, #1182), the handler pushes' nested promises, and the bake-lane
1677
+ * loader containers settle first (the record encodes the handles
1672
1678
  * a HIT restores, and its handle encode has its own 5s timeout that must
1673
1679
  * never be the one that fires); then the capture's onResponse callbacks fire
1674
1680
  * with a synthetic 200 (the implicit doc scope's cacheRoute, an explicit
@@ -1711,7 +1717,33 @@ export async function settleCaptureRecord(
1711
1717
  if (!settled.done) {
1712
1718
  if (refuseOnCaptureGuard(capture.key, derivedCtx))
1713
1719
  return { kind: "refused", refusal: guardRefusal(derivedCtx) };
1714
- return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
1720
+ const pending = derivation.loaderPromisePushes.find(
1721
+ (p) => p.state === "pending",
1722
+ );
1723
+ return {
1724
+ kind: "timeout",
1725
+ reason: pending
1726
+ ? `${describeLoaderPromisePush(pending)} did not settle within ppr.captureTimeout`
1727
+ : HANDLER_OUTPUT_TIMEOUT_REASON,
1728
+ };
1729
+ }
1730
+ // A rejected value must not bake, and the record's encode would fail on
1731
+ // it anyway: refuse before the write starts. A guard trip in that promise
1732
+ // names the read instead.
1733
+ const rejected = derivation.loaderPromisePushes.find(
1734
+ (p) => p.state === "rejected",
1735
+ );
1736
+ if (rejected) {
1737
+ if (refuseOnCaptureGuard(capture.key, derivedCtx))
1738
+ return { kind: "refused", refusal: guardRefusal(derivedCtx) };
1739
+ warnCaptureRefusedOnce(
1740
+ capture.key,
1741
+ `${describeLoaderPromisePush(rejected)} rejected during capture; a ` +
1742
+ "failed value must not bake into the shared shell. Catch it and push " +
1743
+ "a fallback, or push it from a loader without ssr: false.",
1744
+ PPR_LANE_HINT,
1745
+ );
1746
+ return { kind: "refused", refusal: "loader" };
1715
1747
  }
1716
1748
 
1717
1749
  const callbacks = derivedCtx._onResponseCallbacks?.splice(0) ?? [];
@@ -2006,6 +2038,20 @@ export interface CaptureContextDerivation {
2006
2038
  * record encodes its handles, so that encode never races a pending value.
2007
2039
  */
2008
2040
  handlerPushesSettled: () => Promise<void>;
2041
+ /**
2042
+ * Every promise a bake-lane loader body pushed (#1182), in push order.
2043
+ * The record-first step waits for them (they are in the recorded cut) and
2044
+ * names the loader and the handle of one that rejects or misses the
2045
+ * deadline.
2046
+ */
2047
+ loaderPromisePushes: readonly LoaderPromisePush[];
2048
+ }
2049
+
2050
+ /** A promise a bake-lane loader pushed at capture, owned by that loader. */
2051
+ export interface LoaderPromisePush {
2052
+ loaderId: string;
2053
+ handleName: string;
2054
+ state: "pending" | "fulfilled" | "rejected";
2009
2055
  }
2010
2056
 
2011
2057
  /**
@@ -2041,30 +2087,35 @@ export function deriveShellCaptureContext(
2041
2087
  // masked), their pushes are in the captured HTML (<head> title/meta,
2042
2088
  // useHandle echoes), and a HIT does not run a promise-free bake-lane
2043
2089
  // 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
2090
+ // push from a loader body whose value, once settled, is thenable-free
2091
+ // keeps that loader as `owner` (HandleStore.push), the record stores it
2046
2092
  // (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
2093
+ // from its pin restores it through pushRestored (restoreHandles). A
2094
+ // pushed promise (push(promise), .defer()) is judged by its settled
2095
+ // value, as push(await promise) would be (#1182): the record-first step
2096
+ // waits for it under the capture deadline, and one that rejects or
2097
+ // misses the deadline refuses the capture. Those copies stand: a
2098
+ // promise-carrying bake-lane loader that runs on the replay reads the
2099
+ // store, so its pushes that settle thenable-free (and those replayed
2100
+ // inside its body) are dropped and only the ones holding a promise are
2101
+ // added. A loader the replay does not serve from a pin is a hole: one
2102
+ // the route also runs on the live lane, or one whose pin the entry
2103
+ // lost. Its run replaces its copies (pushPlaceholder, #936), those of
2104
+ // the dependencies it awaits included (they are credited to it). Nested
2105
+ // thenables stay masked (the promise shape is the liveness declaration,
2106
+ // mask-nested.ts). A push whose settled value holds a masked nested
2107
+ // promise, one credited to a live-lane body, or one no body or replay
2108
+ // names (a loader-cache replay names its loader: pushReplayed below)
2109
+ // stays tagged (HandleStore.push loaderPush) so the handle encode cannot
2110
+ // stall on a never-resolving mask. A tagged push is out of the record
2111
+ // (captureHandles) and out of the capture's own render
2112
+ // (resolvedHandleStream `recordedOnly`, issue #1035): the prelude is
2113
+ // rendered from what the record keeps, which is what a HIT hydrates
2114
+ // with. The loader records then carry `runs: 1` so a HIT runs those
2115
+ // bodies for them, and what they push reaches the client after
2066
2116
  // hydration (HandleStore.freezeDocumentSnapshot).
2067
2117
  const handlerPushSettles: Promise<void>[] = [];
2118
+ const loaderPromisePushes: LoaderPromisePush[] = [];
2068
2119
  // A replay (a loader-cache HIT, loader-cache.ts replayLoaderHandles, or a
2069
2120
  // route cache() record's owned values, restoreHandles) re-pushes a loader's
2070
2121
  // recorded values through pushReplayed or pushPlaceholder, which call
@@ -2118,39 +2169,62 @@ export function deriveShellCaptureContext(
2118
2169
  if (report.thenable) maskedNestedThenable = true;
2119
2170
  return masked;
2120
2171
  };
2172
+ // The loader the push is credited to (see the funnel comment above). A
2173
+ // push under a live-lane loader's body, at any depth, is that hole's
2174
+ // output: its live run replaces it on a HIT and a navigation. Otherwise
2175
+ // the first loader around the push that the route registers decides: a
2176
+ // bake-lane one keeps the push under the innermost body, and a
2177
+ // dependency on neither lane is walked past. A replay of a hole's push
2178
+ // is that hole's, unless a live-lane body around it takes it first.
2179
+ const live = liveLaneOwner();
2180
+ const bodyLoaderId = live ?? getCurrentLoaderBodyId() ?? replayOwner;
2121
2181
  let masked: unknown;
2122
- let loaderPush = true;
2123
2182
  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 {
2183
+ if (!isThenable(value)) {
2132
2184
  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
- }
2185
+ if (!maskedNestedThenable) owner = bodyLoaderId;
2186
+ } else if (live === undefined && bodyLoaderId !== undefined) {
2187
+ // Owned now, judged by its settled value (funnel comment above).
2188
+ // Promise.resolve first: a loader cache() entry replays a deferred
2189
+ // push as the thenable Flight decoded it to, whose then() returns
2190
+ // nothing (issue #1035). The retag runs inside the slot's own then(),
2191
+ // so it lands before the wait on it (settleCaptureRecord) resumes and
2192
+ // the record encodes. The state handler is attached now, which also
2193
+ // marks a rejection as handled.
2194
+ const slot: Promise<unknown> = Promise.resolve(value).then((settled) => {
2195
+ const judged = mask(settled);
2196
+ if (maskedNestedThenable) {
2197
+ freshHandleStore.unrecordPush(handleName, segmentId, slot);
2198
+ derivedCtx._shellCaptureUnrecordedLoaderPush = true;
2199
+ }
2200
+ return judged;
2201
+ });
2202
+ const push: LoaderPromisePush = {
2203
+ loaderId: bodyLoaderId,
2204
+ handleName,
2205
+ state: "pending",
2206
+ };
2207
+ slot.then(
2208
+ () => {
2209
+ push.state = "fulfilled";
2210
+ },
2211
+ () => {
2212
+ push.state = "rejected";
2213
+ },
2214
+ );
2215
+ loaderPromisePushes.push(push);
2216
+ masked = slot;
2217
+ owner = bodyLoaderId;
2218
+ } else {
2219
+ // A hole's promise, or one no body or replay names: tagged. Nothing
2220
+ // awaits the slot at capture, so its rejection is handled here.
2221
+ const tagged = Promise.resolve(value).then(mask);
2222
+ tagged.catch(() => {});
2223
+ masked = tagged;
2151
2224
  }
2152
2225
  // A push the record cannot keep reaches a HIT only from a live run of
2153
2226
  // its loader, so the loader records ask for one (runs: 1).
2227
+ const loaderPush = owner === undefined;
2154
2228
  if (loaderPush) derivedCtx._shellCaptureUnrecordedLoaderPush = true;
2155
2229
  rawCapturePush(handleName, segmentId, masked, loaderPush, owner);
2156
2230
  };
@@ -2271,6 +2345,7 @@ export function deriveShellCaptureContext(
2271
2345
  freshHandleStore,
2272
2346
  handlerPushesSettled: () =>
2273
2347
  Promise.all(handlerPushSettles).then(() => undefined),
2348
+ loaderPromisePushes,
2274
2349
  };
2275
2350
  }
2276
2351
 
package/src/rsc/types.ts CHANGED
@@ -369,15 +369,6 @@ export interface CreateRSCHandlerOptions<
369
369
  */
370
370
  cache?: HandlerCacheConfig | ((env: TEnv) => HandlerCacheConfig);
371
371
 
372
- /**
373
- * Version override for this handler. Same meaning as
374
- * `createRouter({ version })`, which it takes precedence over: this exact
375
- * value is used for both of the router's versions.
376
- *
377
- * @default createRouter({ version }), else the router's build versions
378
- */
379
- version?: string;
380
-
381
372
  /**
382
373
  * Nonce provider for Content Security Policy (CSP).
383
374
  *