@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -101,8 +101,5 @@ export { useHref } from "./browser/react/use-href.js";
101
101
  export { useReverse } from "./browser/react/use-reverse.js";
102
102
 
103
103
  export { useHandle } from "./browser/react/use-handle.js";
104
- // Type a deferred-aware consumer narrows: an accumulated entry may be a Promise
105
- // (a `ctx.use(Handle).defer()` slot) until it resolves.
106
- export type { DeferredHandleEntry } from "./defer.js";
107
104
 
108
105
  export { useLocationState } from "./browser/react/location-state.js";
package/src/client.tsx CHANGED
@@ -389,9 +389,6 @@ export {
389
389
 
390
390
  export { type Handle } from "./handle.js";
391
391
  export { useHandle } from "./browser/react/use-handle.js";
392
- // Type a deferred-aware consumer narrows: an accumulated entry may be a Promise
393
- // (a `ctx.use(Handle).defer()` slot) until it resolves.
394
- export type { DeferredHandleEntry } from "./defer.js";
395
392
 
396
393
  export { Meta } from "./handles/meta.js";
397
394
  export { MetaTags } from "./handles/MetaTags.js";
@@ -39,7 +39,7 @@ import { _getRequestContext } from "../server/request-context.js";
39
39
  import {
40
40
  type RouterTracingConfig,
41
41
  type SpanRunner,
42
- type TracePhaseToggles,
42
+ type TracingToggleOptions,
43
43
  NOOP_TRACE_SPAN,
44
44
  } from "../router/tracing.js";
45
45
 
@@ -57,13 +57,12 @@ interface CloudflareTracing {
57
57
  enterSpan<T>(name: string, callback: (span: CloudflareSpan) => T): T;
58
58
  }
59
59
 
60
- /** Options for createCloudflareTracing. */
61
- export interface CloudflareTracingOptions {
62
- /** Master switch. Defaults to true. */
63
- enabled?: boolean;
64
- /** Per-phase span toggles. Omitted phases default to enabled. */
65
- spans?: TracePhaseToggles;
66
- }
60
+ /**
61
+ * Options for createCloudflareTracing. Alias of the shared
62
+ * {@link TracingToggleOptions} (`enabled` master switch + per-phase `spans`
63
+ * toggles); the name is public API.
64
+ */
65
+ export type CloudflareTracingOptions = TracingToggleOptions;
67
66
 
68
67
  /**
69
68
  * Resolve the per-request Cloudflare tracer from the active execution context.
package/src/defer.ts CHANGED
@@ -14,14 +14,14 @@
14
14
  * // deep async component, far from ctx:
15
15
  * resolve({ label, href, content }); // identical call, just deferred
16
16
  *
17
- * Under the hood the reserved slot is a Promise the renderer `use()`s; RSC Flight
18
- * streams it as a late row, so a deferred-aware consumer reading the handle
19
- * (`useHandle`) sees that entry as a `Promise` until it resolves (see
20
- * {@link DeferredHandleEntry}). The hazard that guards against bugs: a deferred
21
- * slot whose resolver is never called would keep the Flight stream and the HTTP
22
- * response — open forever. So a deferred auto-resolves to `else` after `timeoutMs`
23
- * (default {@link DEFAULT_DEFER_TIMEOUT_MS}) if the resolver is never called,
24
- * degrading gracefully (and warning in dev) instead of hanging the request.
17
+ * Under the hood the reserved slot is a Promise. Handle values are resolved
18
+ * before any consumer sees them (resolve-by-default: the full render resolves
19
+ * server-side, navigation resolves client-side before apply), so `useHandle`
20
+ * receives the resolved value, never the Promise. The hazard that guards against
21
+ * bugs: a deferred slot whose resolver is never called would keep the render
22
+ * and the HTTP response — waiting forever. So a deferred auto-resolves to `else`
23
+ * after `timeoutMs` (default {@link DEFAULT_DEFER_TIMEOUT_MS}) if the resolver is
24
+ * never called, degrading gracefully (and warning in dev) instead of hanging.
25
25
  */
26
26
 
27
27
  /** Default auto-resolve window. Long enough for genuine deep async work, short
@@ -74,24 +74,13 @@ export type HandlePush<TData> = HandlePushFn<TData> & {
74
74
  * re-enter the deadlock-guard push-callback scope a direct push thunk gets,
75
75
  * because a deferred resolver fires after the handler phase has closed.
76
76
  *
77
- * The reserved slot appears in the accumulated handle data as a pending
78
- * `Promise` until it resolves (see {@link DeferredHandleEntry}); a
79
- * deferred-aware consumer narrows thenable entries (`use()`/`await` + null
80
- * check) before dereferencing.
77
+ * The reserved slot is resolved before any consumer reads it
78
+ * (resolve-by-default), so `useHandle` receives the resolved value (or the
79
+ * `else` fallback on timeout), never a Promise.
81
80
  */
82
81
  defer(options?: DeferOptions<TData>): HandlePushFn<TData>;
83
82
  };
84
83
 
85
- /**
86
- * A handle entry a deferred-aware consumer may read from `useHandle`: either a
87
- * resolved value, or a pending `Promise` that resolves to the value, to `else`,
88
- * or (when no `else` was given) `undefined` on timeout. Reading code should treat
89
- * thenable entries as such and narrow before dereferencing.
90
- */
91
- export type DeferredHandleEntry<TData> =
92
- | TData
93
- | Promise<TData | null | undefined>;
94
-
95
84
  // Internal: a timeout-bounded { promise, resolve }. Not part of the public API
96
85
  // (the public surface is `ctx.use(Handle).defer()`); exported for `withDefer`
97
86
  // and unit tests only. Resolves to `T`, the `else` fallback, or `undefined`.
package/src/handle.ts CHANGED
@@ -10,8 +10,11 @@ import { isUnderTestRunner } from "./runtime-env.js";
10
10
  *
11
11
  * @example
12
12
  * ```ts
13
- * // Define a handle (name auto-generated from file + export)
14
- * export const Breadcrumbs = createHandle<BreadcrumbItem>();
13
+ * // Define a handle (name auto-generated from file + export).
14
+ * // Default collect is the identity: one array per segment that pushed.
15
+ * export const Breadcrumbs = createHandle<BreadcrumbItem, BreadcrumbItem[]>(
16
+ * (segments) => segments.flat(), // opt into a single flat list
17
+ * );
15
18
  *
16
19
  * // Use in handler
17
20
  * const push = ctx.use(Breadcrumbs);
@@ -21,7 +24,7 @@ import { isUnderTestRunner } from "./runtime-env.js";
21
24
  * const crumbs = useHandle(Breadcrumbs);
22
25
  * ```
23
26
  */
24
- export interface Handle<TData, TAccumulated = TData[]> {
27
+ export interface Handle<TData, TAccumulated = TData[][]> {
25
28
  /**
26
29
  * Brand to distinguish handles from loaders in ctx.use()
27
30
  */
@@ -35,10 +38,13 @@ export interface Handle<TData, TAccumulated = TData[]> {
35
38
  }
36
39
 
37
40
  /**
38
- * Default collect function that flattens segment arrays into a single array.
41
+ * Default collect: pass the per-segment data through as-is one array per segment
42
+ * that pushed, in segment order. Lossless, so a consumer can tell which/how-many
43
+ * segments contributed. Callers that want a single flat list opt in with
44
+ * `createHandle((segments) => segments.flat())`.
39
45
  */
40
- function defaultCollect<T>(segments: T[][]): T[] {
41
- return segments.flat();
46
+ function defaultCollect<T>(segments: T[][]): T[][] {
47
+ return segments;
42
48
  }
43
49
 
44
50
  // Module-level registry mapping $$id to collect functions.
@@ -62,13 +68,22 @@ export function getCollectFn(
62
68
  * The $$id is auto-generated by the Vite exposeInternalIds plugin based on
63
69
  * file path and export name. No manual naming required.
64
70
  *
65
- * @param collect - Optional collect function (default: flatten into array)
71
+ * @param collect - Optional collect function. Default: pass the per-segment data
72
+ * through as-is (one array per segment that pushed, in segment order). Lossless,
73
+ * so a consumer can tell which/how-many segments contributed. Opt into a single
74
+ * flat list with `(segments) => segments.flat()`.
66
75
  * @param __injectedId - Auto-injected by Vite plugin, do not provide manually
67
76
  *
68
77
  * @example
69
78
  * ```ts
70
- * // Default: flatten into array
71
- * export const Breadcrumbs = createHandle<BreadcrumbItem>();
79
+ * // Default: per-segment grouping, as-is
80
+ * export const Pushed = createHandle<string>();
81
+ * // Result type: string[][] (e.g. [["a"], ["b", "c"]])
82
+ *
83
+ * // Opt into a single flat list
84
+ * export const Breadcrumbs = createHandle<BreadcrumbItem, BreadcrumbItem[]>(
85
+ * (segments) => segments.flat()
86
+ * );
72
87
  * // Result type: BreadcrumbItem[]
73
88
  *
74
89
  * // Custom: last value wins
@@ -83,8 +98,9 @@ export function getCollectFn(
83
98
  * );
84
99
  * // Result type: MetaTags
85
100
  *
86
- * // Custom: dedupe by href
87
- * export const Breadcrumbs = createHandle<BreadcrumbItem>(
101
+ * // Custom: dedupe by href (TAccumulated must be given — a custom collect that
102
+ * // returns a flat array no longer matches the default TData[][])
103
+ * export const Breadcrumbs = createHandle<BreadcrumbItem, BreadcrumbItem[]>(
88
104
  * (segments) => {
89
105
  * const all = segments.flat();
90
106
  * return all.filter((item, i) => all.findIndex(x => x.href === item.href) === i);
@@ -92,7 +108,7 @@ export function getCollectFn(
92
108
  * );
93
109
  * ```
94
110
  */
95
- export function createHandle<TData, TAccumulated = TData[]>(
111
+ export function createHandle<TData, TAccumulated = TData[][]>(
96
112
  collect?: (segments: TData[][]) => TAccumulated,
97
113
  __injectedId?: string,
98
114
  ): Handle<TData, TAccumulated> {
@@ -156,12 +172,18 @@ export function collectHandleData<TData, TAccumulated>(
156
172
  data: Record<string, Record<string, unknown[]>>,
157
173
  segmentOrder: string[],
158
174
  ): TAccumulated {
175
+ // Fall back to the default (identity, pass-through) collect when none is
176
+ // registered — e.g. the handle's module was not imported so createHandle() never
177
+ // ran. This is harmless for a handle that wanted the default, but a handle with a
178
+ // CUSTOM collect that failed to register silently gets the wrong shape (identity
179
+ // TData[][]) cast as its declared TAccumulated. The runtime can't tell the two
180
+ // apart (a Handle only carries $$id), so warn in dev (folded out of production).
159
181
  const collectFn = getCollectFn(handle.$$id);
160
182
  if (!collectFn && process.env.NODE_ENV !== "production") {
161
183
  console.warn(
162
- `[rango] Handle "${handle.$$id}" has no registered collect function. ` +
163
- `Falling back to flat array. Ensure the handle module is imported so ` +
164
- `createHandle() runs and registers the collect function.`,
184
+ `[rango] Handle "${handle.$$id}" has no registered collect falling back ` +
185
+ `to the identity (per-segment data as-is). If this handle declares a ` +
186
+ `CUSTOM collect, import its module so createHandle() runs where it is read.`,
165
187
  );
166
188
  }
167
189
  const collect = (collectFn ??
@@ -3,8 +3,9 @@
3
3
  /**
4
4
  * Component to render collected meta descriptors in the document head.
5
5
  *
6
- * Supports both sync and async meta descriptors. Async descriptors
7
- * (Promise<MetaDescriptorBase>) are resolved using React's use() hook.
6
+ * Deferred (Promise) meta descriptors are resolved before MetaTags renders
7
+ * (server-side on the full render, client-side before apply on navigation), so
8
+ * it only ever receives resolved descriptors and never suspends.
8
9
  *
9
10
  * When theme is enabled in the router config, MetaTags also renders
10
11
  * the theme initialization script to prevent FOUC (flash of unstyled content).
@@ -28,11 +29,9 @@
28
29
  * ```
29
30
  */
30
31
 
31
- import { use } from "react";
32
32
  import { useHandle } from "../browser/react/use-handle.js";
33
33
  import { Meta } from "./meta.js";
34
- import { isThenable } from "./is-thenable.js";
35
- import type { MetaDescriptor, MetaDescriptorBase } from "../router/types.js";
34
+ import type { MetaDescriptorBase } from "../router/types.js";
36
35
  import { useThemeContext } from "../theme/theme-context.js";
37
36
  import { generateThemeScript } from "../theme/theme-script.js";
38
37
  import { useNonce } from "../browser/react/nonce-context.js";
@@ -96,17 +95,7 @@ function hasTagName(
96
95
  );
97
96
  }
98
97
 
99
- /**
100
- * Check if a value is a Promise. Uses the shared thenable predicate (callable
101
- * `then`) so collect (meta.ts) and render never disagree: an object carrying a
102
- * non-callable `then` (e.g. `{ then: 5 }`) is a SYNC descriptor on both sides,
103
- * not a Promise that would crash React's `use()`.
104
- */
105
- function isPromise(value: unknown): value is Promise<unknown> {
106
- return isThenable(value);
107
- }
108
-
109
- function renderMetaDescriptor(
98
+ export function renderMetaDescriptor(
110
99
  descriptor: MetaDescriptorBase,
111
100
  index: number,
112
101
  ): React.ReactNode {
@@ -189,57 +178,6 @@ function renderMetaDescriptor(
189
178
  );
190
179
  }
191
180
 
192
- // Sentinel a rejected async descriptor resolves to: renderMetaDescriptor sees
193
- // no recognized fields and returns nothing renderable (see renderRejected).
194
- const REJECTED_META: unique symbol = Symbol("rango.rejectedMeta");
195
-
196
- // Cache the rejection-swallowing wrapper per source promise so use() gets a
197
- // stable reference across re-renders (a fresh .then() each render would make
198
- // React treat it as a new pending promise and never settle). WeakMap keys on
199
- // the original promise so entries are collected with it.
200
- const safeMetaPromises = new WeakMap<
201
- Promise<MetaDescriptorBase>,
202
- Promise<MetaDescriptorBase | typeof REJECTED_META>
203
- >();
204
-
205
- function toSafeMetaPromise(
206
- promise: Promise<MetaDescriptorBase>,
207
- ): Promise<MetaDescriptorBase | typeof REJECTED_META> {
208
- let safe = safeMetaPromises.get(promise);
209
- if (!safe) {
210
- // Swallow the rejection at the promise boundary, not via an error boundary:
211
- // an error boundary above a suspended use() makes React abandon the whole
212
- // Suspense subtree (and on the server switch it to client rendering). A
213
- // settled-to-sentinel promise degrades the single bad descriptor to nothing
214
- // while every sibling descriptor still renders.
215
- //
216
- // Normalize via Promise.resolve first: a collected async descriptor may be a
217
- // non-native thenable (a React wakeable in SSR/RSC) whose .then() returns
218
- // void rather than a Promise. Calling .then directly would leave `safe`
219
- // undefined and use(undefined) would throw ("unsupported type passed to
220
- // use()"), 500-ing the page. Promise.resolve adopts the thenable into a
221
- // native Promise whose .then always returns one.
222
- safe = Promise.resolve(promise).then(
223
- (value) => value,
224
- () => REJECTED_META,
225
- );
226
- safeMetaPromises.set(promise, safe);
227
- }
228
- return safe;
229
- }
230
-
231
- export function AsyncMetaTag({
232
- promise,
233
- index,
234
- }: {
235
- promise: Promise<MetaDescriptorBase>;
236
- index: number;
237
- }): React.ReactNode {
238
- const resolved = use(toSafeMetaPromise(promise));
239
- if (resolved === REJECTED_META) return null;
240
- return renderMetaDescriptor(resolved, index);
241
- }
242
-
243
181
  /**
244
182
  * Renders all collected meta descriptors from route handlers.
245
183
  *
@@ -249,11 +187,16 @@ export function AsyncMetaTag({
249
187
  * When theme is enabled in router config, also renders the theme initialization
250
188
  * script to prevent FOUC (flash of unstyled content).
251
189
  *
252
- * Async meta descriptors (Promise<MetaDescriptorBase>) are resolved using
253
- * React's use() hook. RSC streaming handles the Promise resolution.
190
+ * Deferred (Promise) meta descriptors are resolved BEFORE MetaTags renders —
191
+ * server-side on the full/SSR render, client-side before apply on navigation
192
+ * (resolve-by-default) — so MetaTags only ever receives resolved descriptors and
193
+ * never suspends.
254
194
  */
255
195
  export function MetaTags(): React.ReactNode {
256
- const descriptors = useHandle(Meta) as MetaDescriptor[];
196
+ // Deferred descriptors are resolved BEFORE collect runs (resolve-by-default),
197
+ // and collectMeta strips unset markers, so the collected output is always
198
+ // resolved base descriptors (never a Promise).
199
+ const descriptors = useHandle(Meta) as MetaDescriptorBase[];
257
200
  const themeConfig = useThemeContext()?.config ?? null;
258
201
  const nonce = useNonce();
259
202
 
@@ -266,18 +209,9 @@ export function MetaTags(): React.ReactNode {
266
209
  dangerouslySetInnerHTML={{ __html: generateThemeScript(themeConfig) }}
267
210
  />
268
211
  )}
269
- {descriptors.map((descriptor, index) => {
270
- if (isPromise(descriptor)) {
271
- return (
272
- <AsyncMetaTag
273
- key={`async-${index}`}
274
- promise={descriptor}
275
- index={index}
276
- />
277
- );
278
- }
279
- return renderMetaDescriptor(descriptor, index);
280
- })}
212
+ {descriptors.map((descriptor, index) =>
213
+ renderMetaDescriptor(descriptor, index),
214
+ )}
281
215
  </>
282
216
  );
283
217
  }
@@ -23,7 +23,6 @@
23
23
 
24
24
  import type { ReactNode } from "react";
25
25
  import { createHandle, type Handle } from "../handle.js";
26
- import { isThenable } from "./is-thenable.js";
27
26
 
28
27
  /**
29
28
  * A single breadcrumb item.
@@ -42,34 +41,33 @@ export interface BreadcrumbItem {
42
41
  * Collect function for Breadcrumbs handle.
43
42
  * Flattens segments in parent-to-child order with deduplication by href: each
44
43
  * href keeps its FIRST position but takes the LAST value (re-pushing a parent
45
- * href refreshes the label in place without reordering the trail).
46
- * Deferred slots (`ctx.use(Breadcrumbs).defer()`)
47
- * arrive as pending Promise entries with no href yet; they are passed through by
48
- * identity and excluded from the href dedup so concurrent deferred crumbs do not
49
- * all collapse under a single `undefined` href.
44
+ * href refreshes the label in place without reordering the trail). Deferred
45
+ * crumbs (a pushed Promise or `ctx.use(Breadcrumbs).defer()`) are resolved
46
+ * BEFORE collect runs (resolve-by-default), so collect only ever sees resolved
47
+ * items. An item without a string `href` is passed through by identity and
48
+ * excluded from the href dedup.
50
49
  */
51
50
  function collectBreadcrumbs(segments: BreadcrumbItem[][]): BreadcrumbItem[] {
52
51
  const all = segments.flat();
53
52
 
54
- const isResolvedItem = (item: unknown): item is BreadcrumbItem =>
53
+ const hasHref = (item: unknown): item is BreadcrumbItem =>
55
54
  item != null &&
56
55
  typeof item === "object" &&
57
- !isThenable(item) &&
58
56
  typeof (item as { href?: unknown }).href === "string";
59
57
 
60
- // Dedup resolved crumbs by href: keep the FIRST position (preserving
61
- // parent->child order) but the LAST value (a child re-pushing a parent's href
62
- // can refresh its label). Deferred items bypass dedup entirely (they have no
63
- // href yet) and are passed through by identity at their original position.
58
+ // Dedup crumbs by href: keep the FIRST position (preserving parent->child
59
+ // order) but the LAST value (a child re-pushing a parent's href can refresh
60
+ // its label). Items without an href pass through by identity at their original
61
+ // position.
64
62
  const valueByHref = new Map<string, BreadcrumbItem>();
65
63
  for (const item of all) {
66
- if (isResolvedItem(item)) valueByHref.set(item.href, item);
64
+ if (hasHref(item)) valueByHref.set(item.href, item);
67
65
  }
68
66
 
69
67
  const result: BreadcrumbItem[] = [];
70
68
  const emitted = new Set<string>();
71
69
  for (const item of all) {
72
- if (!isResolvedItem(item)) {
70
+ if (!hasHref(item)) {
73
71
  result.push(item);
74
72
  continue;
75
73
  }
@@ -0,0 +1,127 @@
1
+ import type { HandleData, HandleStore } from "../server/handle-store.js";
2
+ import { isThenable } from "./is-thenable.js";
3
+
4
+ /**
5
+ * Runtime-neutral resolution of deferred (Promise) handle values. Shared by the
6
+ * server full-render path (resolve before the payload is finalized so SSR sees
7
+ * resolved values) and the client soft-nav path (NavigationProvider resolves
8
+ * each yield before applying it). Pure — imports only `isThenable` — so it is
9
+ * safe in both the RSC server environment AND the browser.
10
+ *
11
+ * Resolution is SHALLOW: only an entry that is ITSELF a thenable is awaited. A
12
+ * value that merely contains a promise (e.g. `{ data: somePromise }`) is a
13
+ * non-thenable, so the `isThenable` check skips it and it passes through verbatim
14
+ * — the nested promise is the consumer's responsibility. Resolution runs each
15
+ * entry through `Promise.all` with a per-entry `try/catch` (NOT `allSettled`), so
16
+ * one rejecting entry is dropped without rejecting the batch or aborting siblings.
17
+ */
18
+
19
+ /**
20
+ * Handle names ($$id) whose snapshot carries a deferred (top-level Promise)
21
+ * value. Used by the client to decide which buckets to hold-and-resolve, and
22
+ * `.size > 0` answers "does this snapshot have any deferred value".
23
+ */
24
+ export function deferredHandleNames(data: HandleData): Set<string> {
25
+ const names = new Set<string>();
26
+ for (const [handleName, segments] of Object.entries(data)) {
27
+ for (const values of Object.values(segments)) {
28
+ if (values.some(isThenable)) {
29
+ names.add(handleName);
30
+ break;
31
+ }
32
+ }
33
+ }
34
+ return names;
35
+ }
36
+
37
+ /**
38
+ * Snapshot with deferred (Promise) values awaited (shallow — only top-level
39
+ * thenables). A rejected deferred is dropped, and a deferred that resolves to
40
+ * `null`/`undefined` is dropped (the `.defer({ else })` skip semantics), so a
41
+ * nullish deferred slot never reaches a collector. Sync (non-thenable) values —
42
+ * including a legitimate sync `null` push — pass through unchanged.
43
+ */
44
+ export async function resolveDeferredHandleValues(
45
+ data: HandleData,
46
+ ): Promise<HandleData> {
47
+ const out: HandleData = {};
48
+ await Promise.all(
49
+ Object.entries(data).flatMap(([handleName, segments]) => {
50
+ out[handleName] = {};
51
+ return Object.entries(segments).map(async ([segmentId, values]) => {
52
+ out[handleName][segmentId] = await resolveValues(values);
53
+ });
54
+ }),
55
+ );
56
+ return out;
57
+ }
58
+
59
+ // Resolve each entry IN ORDER. A non-thenable passes through verbatim (including
60
+ // a legitimate sync `null`/`undefined` push — the "await iff thenable" contract).
61
+ // A thenable is awaited and dropped when it rejects OR resolves to null/undefined
62
+ // (the `.defer({ else })` skip semantics: a forgotten resolver times out to
63
+ // `else ?? undefined`), so a nullish deferred slot never reaches a collector.
64
+ async function resolveValues(values: unknown[]): Promise<unknown[]> {
65
+ const resolved = await Promise.all(
66
+ values.map(async (v) => {
67
+ if (!isThenable(v)) return { keep: true, value: v };
68
+ try {
69
+ const value = await v;
70
+ return { keep: value != null, value };
71
+ } catch {
72
+ return { keep: false, value: undefined };
73
+ }
74
+ }),
75
+ );
76
+ return resolved.filter((r) => r.keep).map((r) => r.value);
77
+ }
78
+
79
+ /**
80
+ * Full-render handle stream: a one-shot async generator that yields the FINAL
81
+ * handle snapshot with every top-level deferred value resolved. Drop-in for
82
+ * `handleStore.stream()` on full / HTML renders (initial load, 404, progressive
83
+ * enhancement), where the consumer drains the generator to completion before
84
+ * rendering anyway — so the single final yield is equivalent to the streamed
85
+ * yields' converged state, and the SSR markup plus the first sync `useHandle`
86
+ * read see resolved values. `getData()` seals the store and awaits the handler
87
+ * barrier; `resolveDeferredHandleValues` then awaits the pushed-value promises.
88
+ * A hung handle promise blocks the full render like any unresolved await (no
89
+ * handle-specific timeout). Partial / action payloads keep streaming via
90
+ * `handleStore.stream()`.
91
+ */
92
+ export async function* resolvedHandleStream(
93
+ handleStore: HandleStore,
94
+ ): AsyncGenerator<HandleData, void, unknown> {
95
+ // Drain stream() (NOT getData()) for the converged snapshot: stream() sets the
96
+ // store's `completed` flag on seal+settle, and that flag is what makes a LATE
97
+ // push throw LateHandlePushError — an async JSX subtree that suspended and later
98
+ // calls ctx.use(Handle)(...) after collection. getData() never sets `completed`,
99
+ // so the late push would silently land. Both wait for the same settle barrier, so
100
+ // the final yielded value is identical; we just keep the late-push guard.
101
+ let snapshot: HandleData = {};
102
+ for await (const data of handleStore.stream()) {
103
+ snapshot = data;
104
+ }
105
+ yield await resolveDeferredHandleValues(snapshot);
106
+ }
107
+
108
+ /**
109
+ * Resolve the deferred (Promise) values in ONE segment's handle bucket
110
+ * (`handleName -> entries[]`). Same shallow + drop-rejections rule as
111
+ * {@link resolveDeferredHandleValues}, but for the segment-keyed shape the
112
+ * prerender/static collection paths use (`getDataForSegment`). Prerender is a
113
+ * build-time cache, so the baked artifact must hold resolved values — a hit
114
+ * replays them into the segment system, where collect/useHandle expect resolved
115
+ * data.
116
+ */
117
+ export async function resolveSegmentHandleValues(
118
+ segHandles: Record<string, unknown[]>,
119
+ ): Promise<Record<string, unknown[]>> {
120
+ const out: Record<string, unknown[]> = {};
121
+ await Promise.all(
122
+ Object.entries(segHandles).map(async ([handleName, values]) => {
123
+ out[handleName] = await resolveValues(values);
124
+ }),
125
+ );
126
+ return out;
127
+ }
@@ -1,14 +1,13 @@
1
1
  /**
2
- * Single thenable predicate shared by the built-in handles that distinguish a
3
- * synchronous descriptor/item from a deferred `Promise` one (Meta collect, the
4
- * MetaTags render side, and Breadcrumbs).
2
+ * Single thenable predicate for the resolve-by-default handle machinery
3
+ * (`handles/deferred-resolution.ts`, its sole owner) it decides which top-level
4
+ * handle entries are deferred (`Promise`) values to await before any consumer
5
+ * sees them, and which pass through verbatim.
5
6
  *
6
7
  * Requires a CALLABLE `then` (`typeof obj.then === "function"`), not merely a
7
- * `"then" in obj` membership check. The two had drifted: a descriptor carrying a
8
- * non-callable `then` (e.g. a serialized shape `{ then: 5 }`) was classified as
9
- * synchronous by collect but as a Promise by render — so render would call
10
- * React's `use()` on a non-thenable and throw. One owner keeps the collect and
11
- * render sides from ever disagreeing.
8
+ * `"then" in obj` membership check, so a non-promise carrying a `then` field
9
+ * (e.g. a serialized shape `{ then: 5 }`) is treated as a plain sync value rather
10
+ * than awaited. A single predicate keeps that classification consistent.
12
11
  */
13
12
  export function isThenable(value: unknown): value is PromiseLike<unknown> {
14
13
  return (
@@ -29,7 +29,6 @@
29
29
  */
30
30
 
31
31
  import { createHandle, type Handle } from "../handle.js";
32
- import { isThenable } from "./is-thenable.js";
33
32
  import type {
34
33
  MetaDescriptor,
35
34
  MetaDescriptorBase,
@@ -37,12 +36,6 @@ import type {
37
36
  UnsetDescriptor,
38
37
  } from "../router/types.js";
39
38
 
40
- function isPromiseDescriptor(
41
- descriptor: MetaDescriptor,
42
- ): descriptor is Promise<MetaDescriptorBase> {
43
- return isThenable(descriptor);
44
- }
45
-
46
39
  function isUnsetDescriptor(
47
40
  descriptor: MetaDescriptor,
48
41
  ): descriptor is UnsetDescriptor {
@@ -166,37 +159,9 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
166
159
 
167
160
  for (const descriptors of segments) {
168
161
  for (const descriptor of descriptors) {
169
- // Promise descriptors cannot be inspected synchronously (their content is
170
- // unknown until resolved in <MetaTags> via React's use()), so they bypass
171
- // key-based dedup and title-templating: they are appended verbatim. Warn in
172
- // dev when a title template is active so the author knows an async
173
- // descriptor will NOT participate in the template/dedup.
174
- //
175
- // The warning is deliberately a GENERAL note, not a duplicate-<title>
176
- // prediction: collectMeta cannot tell whether this Promise resolves to a
177
- // title (which would indeed yield a 2nd <title>) or to an ordinary
178
- // descriptor like an async og:image (which would not). Asserting a
179
- // duplicate <title> here is a false positive for the common og:image case,
180
- // so the message states only that async descriptors bypass templating —
181
- // not that a duplicate <title> WILL occur.
182
- if (isPromiseDescriptor(descriptor)) {
183
- if (
184
- titleTemplate !== undefined &&
185
- process.env.NODE_ENV !== "production"
186
- ) {
187
- console.warn(
188
- `[Meta] A Promise meta descriptor was pushed while a title template is active. ` +
189
- `Async descriptors bypass deduplication and title-templating: the template is ` +
190
- `not applied to them. If this Promise resolves to a title, resolve the value ` +
191
- `before pushing (or push a synchronous descriptor) so it participates in the ` +
192
- `template; if it resolves to a non-title descriptor (e.g. og:image), this ` +
193
- `note does not apply.`,
194
- );
195
- }
196
- result.push(descriptor);
197
- continue;
198
- }
199
-
162
+ // Deferred (Promise) descriptors are resolved BEFORE collectMeta runs
163
+ // (resolve-by-default), so every descriptor here is synchronous and
164
+ // participates in key-based dedup + title-templating like any other.
200
165
  if (isUnsetDescriptor(descriptor)) {
201
166
  const keyToRemove = descriptor.unset;
202
167
  if (keyToIndex.has(keyToRemove)) {
@@ -262,12 +227,10 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
262
227
  * Use `ctx.use(Meta)` in route handlers to push meta descriptors.
263
228
  * Use `<MetaTags />` component to render them in the document head.
264
229
  *
265
- * Deduplication and title-templating apply only to SYNCHRONOUS descriptors.
266
- * A Promise descriptor (`Promise<MetaDescriptorBase>`) is appended verbatim
267
- * its content is not known until it resolves in `<MetaTags>`, so it cannot be
268
- * keyed for dedup nor receive a parent title template. If you need a child title
269
- * to participate in a layout's `%s` template, push the resolved string title
270
- * synchronously rather than a `Promise<{ title }>`.
230
+ * Deferred (Promise) descriptors are resolved before collectMeta runs
231
+ * (resolve-by-default), so they participate in deduplication and `%s`
232
+ * title-templating identically to synchronous descriptors. A descriptor that
233
+ * resolves to `null`/`undefined` (or rejects) is dropped.
271
234
  */
272
235
  export const Meta: Handle<MetaDescriptor, MetaDescriptor[]> = createHandle<
273
236
  MetaDescriptor,