@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
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Loader-family capture snapshot: elide + overlay for bake-lane loader
3
+ * containers (docs/design/loader-container-bake.md).
4
+ *
5
+ * A loader on an entry with NO renderable loading() executes during shell
6
+ * capture; its settled container bakes into the prelude while every promise
7
+ * still pending at the quiet window postpones as a hole. To keep a HIT's fresh
8
+ * payload byte-identical to that frozen prelude, the capture pins the container
9
+ * in the shell snapshot:
10
+ *
11
+ * - elide: deep-walk the settled container; a PENDING nested promise is a
12
+ * hole, replaced by {@link LOADER_HOLE_KEY} — and since
13
+ * loader-cache masks every nested thenable at capture
14
+ * ({@link maskNestedContainerThenables}), nested promises are
15
+ * ALWAYS pending here for new captures. The SETTLED-marker branch
16
+ * below is legacy: it fires only if a settled thenable reaches
17
+ * elide anyway, and the overlay keeps decoding SETTLED markers
18
+ * from pre-mask snapshots. The result is promise-free and
19
+ * Flight-serializable.
20
+ * - overlay: on a HIT the loader runs fresh (only the loader body can mint
21
+ * the live nested promises), then the recorded container is laid
22
+ * over it: recorded paths win (they are what the prelude froze),
23
+ * hole-marker paths take the fresh run's value (the live hole),
24
+ * SETTLED-marker paths become Promise.resolve(pinned) — consumers
25
+ * wrote use(data.x) against a promise-shaped container, and
26
+ * handing them the raw value throws React #438 on every HIT (the
27
+ * storefront PDP regression) — and fresh-only paths pass through
28
+ * (they cannot contradict prelude bytes that never rendered them).
29
+ */
30
+
31
+ import { isThenable } from "../../handles/is-thenable.js";
32
+
33
+ // Capture-side nested-thenable masking lives in the LEAF module mask-nested.ts
34
+ // (request-context also needs it for handle pushes and cannot import through
35
+ // loader-mask without a cycle). Re-exported here so loader-cache and the unit
36
+ // tests keep one import site for the snapshot family.
37
+ export { maskNestedContainerThenables } from "./mask-nested.js";
38
+
39
+ /**
40
+ * Marker object standing in for a pending nested promise in a recorded loader
41
+ * container. Shape-checked (not identity-checked) because the record round-trips
42
+ * through Flight serialization and a JSON-embedding store envelope.
43
+ */
44
+ export const LOADER_HOLE_KEY = "$rangoLoaderHole" as const;
45
+
46
+ export interface LoaderHoleMarker {
47
+ [LOADER_HOLE_KEY]: 1;
48
+ }
49
+
50
+ export function isLoaderHoleMarker(value: unknown): value is LoaderHoleMarker {
51
+ return (
52
+ typeof value === "object" &&
53
+ value !== null &&
54
+ (value as Record<string, unknown>)[LOADER_HOLE_KEY] === 1
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Marker wrapping the inlined value of a NESTED promise that settled during
60
+ * capture. The value baked (physics), but the container key was a promise —
61
+ * the overlay must hand consumers a Promise.resolve(value), not the raw value,
62
+ * or an unconditional use(data.x) throws React #438 on every HIT. The ROOT
63
+ * container is never wrapped: loader-cache overlays against the awaited fresh
64
+ * container value.
65
+ */
66
+ export const LOADER_SETTLED_KEY = "$rangoLoaderSettled" as const;
67
+
68
+ export interface LoaderSettledMarker {
69
+ [LOADER_SETTLED_KEY]: 1;
70
+ value: unknown;
71
+ /**
72
+ * Present when the pinned subtree contains hole markers. Computed once at
73
+ * capture (elide already visits every node) so the per-HIT overlay never
74
+ * rescans the pinned structure to pick its rehydration path.
75
+ */
76
+ holes?: 1;
77
+ }
78
+
79
+ export function isLoaderSettledMarker(
80
+ value: unknown,
81
+ ): value is LoaderSettledMarker {
82
+ return (
83
+ typeof value === "object" &&
84
+ value !== null &&
85
+ (value as Record<string, unknown>)[LOADER_SETTLED_KEY] === 1
86
+ );
87
+ }
88
+
89
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
90
+ if (typeof value !== "object" || value === null) return false;
91
+ const proto = Object.getPrototypeOf(value);
92
+ return proto === Object.prototype || proto === null;
93
+ }
94
+
95
+ /**
96
+ * Probe whether a promise is already settled without waiting for it: races it
97
+ * against an immediately-resolved sentinel across two microtask hops (then
98
+ * chaining means a resolved inner value needs one extra hop to surface).
99
+ * Returns the settled state, or "pending" if it has not settled by then.
100
+ */
101
+ async function probeSettled(
102
+ p: PromiseLike<unknown>,
103
+ ): Promise<
104
+ | { state: "fulfilled"; value: unknown }
105
+ | { state: "rejected" }
106
+ | { state: "pending" }
107
+ > {
108
+ const PENDING = Symbol("pending");
109
+ // Two sentinel hops: Promise.resolve(p) adoption costs a microtask, so a
110
+ // single-hop sentinel would misreport an already-fulfilled promise as pending.
111
+ const sentinel = Promise.resolve()
112
+ .then(() => undefined)
113
+ .then(() => PENDING as unknown);
114
+ try {
115
+ const raced = await Promise.race([Promise.resolve(p), sentinel]);
116
+ if (raced === PENDING) return { state: "pending" };
117
+ return { state: "fulfilled", value: raced };
118
+ } catch {
119
+ return { state: "rejected" };
120
+ }
121
+ }
122
+
123
+ export type ElideResult =
124
+ | { state: "ok"; value: unknown; hasHole: boolean }
125
+ | { state: "rejected" };
126
+
127
+ /**
128
+ * Deep-elide a settled bake-lane container for recording. Settled nested
129
+ * promises pin their value behind a settled marker (they baked, but consumers
130
+ * hold a promise-shaped key); pending ones become hole markers; a REJECTED
131
+ * nested promise poisons the record (error UI must never bake into a shared
132
+ * shell) — the caller refuses the capture. Only plain objects/arrays are
133
+ * traversed; anything else (Date, Map, class instance) is a pinned leaf.
134
+ * Cycles are cut as pinned references (best effort — Flight rejects true
135
+ * cycles later regardless).
136
+ *
137
+ * The ROOT container promise-chain unwraps with NO marker: loader-cache
138
+ * overlays against the AWAITED fresh container, so the recorded root must be
139
+ * the unwrapped structure. Everything below it goes through elideNested.
140
+ */
141
+ export async function elideLoaderContainer(
142
+ value: unknown,
143
+ seen: Set<object> = new Set(),
144
+ ): Promise<ElideResult> {
145
+ if (isThenable(value)) {
146
+ const probed = await probeSettled(value);
147
+ if (probed.state === "pending") {
148
+ return { state: "ok", value: { [LOADER_HOLE_KEY]: 1 }, hasHole: true };
149
+ }
150
+ if (probed.state === "rejected") return { state: "rejected" };
151
+ return elideLoaderContainer(probed.value, seen);
152
+ }
153
+ return elideNested(value, seen);
154
+ }
155
+
156
+ async function elideNested(
157
+ value: unknown,
158
+ seen: Set<object>,
159
+ ): Promise<ElideResult> {
160
+ if (isThenable(value)) {
161
+ const probed = await probeSettled(value);
162
+ if (probed.state === "pending") {
163
+ return { state: "ok", value: { [LOADER_HOLE_KEY]: 1 }, hasHole: true };
164
+ }
165
+ if (probed.state === "rejected") return { state: "rejected" };
166
+ const inner = await elideNested(probed.value, seen);
167
+ if (inner.state === "rejected") return inner;
168
+ const marker: LoaderSettledMarker = inner.hasHole
169
+ ? { [LOADER_SETTLED_KEY]: 1, value: inner.value, holes: 1 }
170
+ : { [LOADER_SETTLED_KEY]: 1, value: inner.value };
171
+ return { state: "ok", value: marker, hasHole: inner.hasHole };
172
+ }
173
+
174
+ if (Array.isArray(value)) {
175
+ if (seen.has(value)) return { state: "ok", value, hasHole: false };
176
+ seen.add(value);
177
+ const out: unknown[] = new Array(value.length);
178
+ let hasHole = false;
179
+ for (let i = 0; i < value.length; i++) {
180
+ const r = await elideNested(value[i], seen);
181
+ if (r.state === "rejected") return r;
182
+ out[i] = r.value;
183
+ hasHole ||= r.hasHole;
184
+ }
185
+ return { state: "ok", value: out, hasHole };
186
+ }
187
+
188
+ if (isPlainObject(value)) {
189
+ if (seen.has(value)) return { state: "ok", value, hasHole: false };
190
+ seen.add(value);
191
+ const out: Record<string, unknown> = {};
192
+ let hasHole = false;
193
+ for (const key of Object.keys(value)) {
194
+ const r = await elideNested(value[key], seen);
195
+ if (r.state === "rejected") return r;
196
+ out[key] = r.value;
197
+ hasHole ||= r.hasHole;
198
+ }
199
+ return { state: "ok", value: out, hasHole };
200
+ }
201
+
202
+ return { state: "ok", value, hasHole: false };
203
+ }
204
+
205
+ /**
206
+ * Overlay a recorded (elided) container onto the fresh run's container for a
207
+ * shell HIT. Recorded wins per path; hole markers take the fresh value (the
208
+ * live nested promise); fresh-only object keys pass through. Where the shapes
209
+ * disagree structurally, recorded wins wholesale — it is what the prelude
210
+ * froze, and parity beats freshness inside the shell.
211
+ */
212
+ export function overlayLoaderContainer(
213
+ fresh: unknown,
214
+ recorded: unknown,
215
+ ): unknown {
216
+ if (isLoaderHoleMarker(recorded)) return fresh;
217
+
218
+ if (isLoaderSettledMarker(recorded)) {
219
+ const pinned = recorded.value;
220
+ // Deep holes inside a settled container (capture-computed `holes` bit)
221
+ // need the fresh promise's resolved value to fill them; a fully-pinned
222
+ // container resolves immediately (the prelude already shows it — never
223
+ // gate it on fresh latency). A rejecting fresh run degrades holes to
224
+ // undefined instead of poisoning the pin.
225
+ if (recorded.holes === 1) {
226
+ return Promise.resolve(fresh).then(
227
+ (freshValue) => overlayLoaderContainer(freshValue, pinned),
228
+ () => overlayLoaderContainer(undefined, pinned),
229
+ );
230
+ }
231
+ return Promise.resolve(overlayLoaderContainer(undefined, pinned));
232
+ }
233
+
234
+ if (Array.isArray(recorded)) {
235
+ const freshArr = Array.isArray(fresh) ? fresh : [];
236
+ return recorded.map((item, i) => overlayLoaderContainer(freshArr[i], item));
237
+ }
238
+
239
+ if (isPlainObject(recorded)) {
240
+ if (!isPlainObject(fresh)) {
241
+ // Shape drift: still honor markers (holes fall back to undefined).
242
+ const out: Record<string, unknown> = {};
243
+ for (const key of Object.keys(recorded)) {
244
+ out[key] = overlayLoaderContainer(undefined, recorded[key]);
245
+ }
246
+ return out;
247
+ }
248
+ const out: Record<string, unknown> = {};
249
+ for (const key of Object.keys(recorded)) {
250
+ out[key] = overlayLoaderContainer(fresh[key], recorded[key]);
251
+ }
252
+ for (const key of Object.keys(fresh)) {
253
+ if (!(key in out)) out[key] = fresh[key];
254
+ }
255
+ return out;
256
+ }
257
+
258
+ return recorded;
259
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Capture-side nested-thenable masking — the mechanism behind "nested-promise
3
+ * shape is the liveness declaration" for BOTH bake-lane loader containers
4
+ * (loader-cache.ts) and pushed handle containers (request-context.ts
5
+ * createUseFunction).
6
+ *
7
+ * Deliberately a LEAF module: request-context needs the mask for handle
8
+ * pushes, and loader-mask (the other natural home) imports request-context —
9
+ * importing from there would cycle. This module imports only is-thenable.
10
+ */
11
+
12
+ import { isThenable } from "../../handles/is-thenable.js";
13
+
14
+ /**
15
+ * A promise that never settles — the masked stand-in for a per-request value
16
+ * during shell capture. The consuming Suspense subtree suspends forever, so
17
+ * the static prerender postpones it as a hole instead of baking a per-request
18
+ * value into the shared shell. The capture abort (`maxWaitMs` in
19
+ * captureShellHTML) bounds how long the prerender waits before it freezes the
20
+ * prelude, so this never hangs the request.
21
+ */
22
+ export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
23
+ return new Promise<T>(() => {});
24
+ }
25
+
26
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
27
+ if (typeof value !== "object" || value === null) return false;
28
+ const proto = Object.getPrototypeOf(value);
29
+ return proto === Object.prototype || proto === null;
30
+ }
31
+
32
+ /**
33
+ * Deep-copy a container with every NESTED thenable replaced by a masked
34
+ * (never-resolving) promise. Applied during shell capture to (a) bake-lane
35
+ * loader containers (loader-cache.ts) and (b) pushed handle containers
36
+ * (createUseFunction) — the two rango-owned funnels where consumers declare
37
+ * per-request data by promise SHAPE.
38
+ *
39
+ * Why: a nested promise that happened to SETTLE before the capture's quiet
40
+ * window closed used to bake its value into the SHARED shell (and, for
41
+ * loaders, the snapshot pinned it for every HIT) — per-request data frozen
42
+ * and served cross-session (found live: a storefront basket, carrying the
43
+ * capturing session's basketId/customer identifiers, served to anonymous
44
+ * visitors). The window waits for the slowest shared material on the page, so
45
+ * any real data source (a 5ms SQL read, a 200ms basket API) lost the race.
46
+ * Masking makes the consuming subtree postpone as a hole no matter when the
47
+ * promise settles: liveness by declaration, not by racing the window.
48
+ *
49
+ * Only plain objects/arrays are traversed; other values are leaves. The INPUT
50
+ * IS NEVER MUTATED — handler-side loader consumption (the consumption-lane
51
+ * rule, semantic-matrix PPR3) shares the raw container and must keep real
52
+ * values. Cycles are preserved as cycles in the copy.
53
+ */
54
+ export function maskNestedContainerThenables(
55
+ value: unknown,
56
+ seen: Map<object, unknown> = new Map(),
57
+ ): unknown {
58
+ if (isThenable(value)) return createMaskedLoaderPromise();
59
+
60
+ if (Array.isArray(value)) {
61
+ const cached = seen.get(value);
62
+ if (cached !== undefined) return cached;
63
+ const out: unknown[] = new Array(value.length);
64
+ seen.set(value, out);
65
+ for (let i = 0; i < value.length; i++) {
66
+ out[i] = maskNestedContainerThenables(value[i], seen);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ if (isPlainObject(value)) {
72
+ const cached = seen.get(value);
73
+ if (cached !== undefined) return cached;
74
+ const out: Record<string, unknown> = {};
75
+ seen.set(value, out);
76
+ for (const key of Object.keys(value)) {
77
+ out[key] = maskNestedContainerThenables(value[key], seen);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ return value;
83
+ }
@@ -660,6 +660,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
660
660
  transition: applyViewTransitionDefault(
661
661
  parallelEntry.transition,
662
662
  deps.viewTransitionDefault,
663
+ parallelId,
663
664
  ),
664
665
  params,
665
666
  slot,
@@ -861,6 +862,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
861
862
  transition: applyViewTransitionDefault(
862
863
  entry.transition,
863
864
  deps.viewTransitionDefault,
865
+ entry.shortCode,
864
866
  ),
865
867
  params,
866
868
  belongsToRoute,
@@ -1197,6 +1199,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1197
1199
  transition: applyViewTransitionDefault(
1198
1200
  orphan.transition,
1199
1201
  deps.viewTransitionDefault,
1202
+ orphan.shortCode,
1200
1203
  ),
1201
1204
  ...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
1202
1205
  });
@@ -8,29 +8,49 @@
8
8
  */
9
9
 
10
10
  import type { EntryData } from "../../server/context";
11
+ import { getRequestContext } from "../../server/request-context.js";
11
12
 
12
13
  /**
13
- * Resolve the effective `viewTransition` for a segment's transition config.
14
+ * Resolve a segment's transition config: stamp the `viewTransition` default and
15
+ * peel off a transition({ when }) predicate.
14
16
  *
15
- * The per-segment value (set via the transition() DSL) always wins. When it is
16
- * unset, the router-level createRouter({ viewTransition }) default is stamped
17
- * in so the render gate reads the boundary decision off the segment — server
18
- * and client, via the serialized segment — without the router option being
19
- * threaded to the client. Only `false` is ever stamped; an unset (or "auto")
20
- * value is left untouched because it already means "wrap" at the gate, which
21
- * also avoids needless object allocation and payload growth. Used by both the
22
- * fresh and revalidation resolution paths.
17
+ * `viewTransition`: the per-segment value (set via the transition() DSL) always
18
+ * wins. When it is unset, the router-level createRouter({ viewTransition })
19
+ * default is stamped in so the render gate reads the boundary decision off the
20
+ * segment — server and client, via the serialized segment — without the router
21
+ * option being threaded to the client. Only `false` is ever stamped; an unset
22
+ * (or "auto") value is left untouched because it already means "wrap" at the
23
+ * gate, which also avoids needless object allocation and payload growth.
24
+ *
25
+ * `when`: a server-only predicate. It is STRIPPED from the returned config (a
26
+ * function cannot cross Flight or the segment cache) and recorded on the request
27
+ * context keyed by `segmentId`, so rsc-rendering can evaluate it post-handler —
28
+ * outside any cache scope — and drop this segment's transition when it returns
29
+ * false. Used by both the fresh and revalidation resolution paths.
23
30
  */
24
31
  export function applyViewTransitionDefault(
25
32
  transition: EntryData["transition"],
26
33
  viewTransitionDefault: "auto" | false | undefined,
34
+ segmentId?: string,
27
35
  ): EntryData["transition"] {
28
36
  if (!transition) return transition;
29
- if (
30
- transition.viewTransition === undefined &&
31
- viewTransitionDefault === false
32
- ) {
33
- return { ...transition, viewTransition: false };
37
+ let result = transition;
38
+ if (result.when) {
39
+ if (segmentId !== undefined) {
40
+ try {
41
+ const ctx = getRequestContext();
42
+ (ctx._transitionWhen ??= []).push({ id: segmentId, when: result.when });
43
+ } catch {
44
+ // No active request context (e.g. a unit test calling this util
45
+ // directly). Skip collection; the strip below still applies so the
46
+ // serialized config never carries the function.
47
+ }
48
+ }
49
+ const { when: _when, ...rest } = result;
50
+ result = rest;
51
+ }
52
+ if (result.viewTransition === undefined && viewTransitionDefault === false) {
53
+ return { ...result, viewTransition: false };
34
54
  }
35
- return transition;
55
+ return result;
36
56
  }
@@ -1,12 +1,23 @@
1
- import { encodePathSegment } from "./url-params.js";
1
+ import { encodePathSegment, encodePathRemainder } from "./url-params.js";
2
+ import { parsePattern } from "./parse-pattern.js";
2
3
 
3
4
  /**
4
5
  * Substitute `:param` placeholders in a route pattern with values from
5
- * `params`. Two-pass: optional params (`:name?`) first so absent values
6
- * collapse cleanly, then required params (throws on missing). Constraint
7
- * syntax (`:name(en|gb)`) is stripped from the result. Trailing-slash
8
- * patterns like `/blog/` are preserved unless an optional segment was
9
- * actually omitted.
6
+ * `params`, producing a URL. Built by walking the SAME parsed segments the
7
+ * matcher uses (`parsePattern`) and emitting one piece per segment — so a
8
+ * substituted value is never re-scanned as if it were another placeholder (a
9
+ * catch-all value like `sha:abc/x` used to make the "required" pass read `:abc`
10
+ * and throw). Constraint syntax (`:name(en|gb)`) is stripped; trailing-slash
11
+ * patterns like `/blog/` are preserved unless an optional segment was omitted.
12
+ *
13
+ * Semantics per segment:
14
+ * - static -> emitted verbatim.
15
+ * - `:name` -> required; `undefined` throws, `""` yields an empty segment.
16
+ * - `:name?` -> optional; `undefined`/`""` omitted.
17
+ * - `:name*` / `:name+`-> catch-all; the value is multi-segment, so each segment
18
+ * is encoded and the `/` separators are preserved. `+`
19
+ * (one-or-more) throws when absent; `*` (and bare `*`)
20
+ * omit when absent.
10
21
  *
11
22
  * Shared by `ctx.reverse()` (server), `createReverse()` (typed runtime
12
23
  * helper), and `useReverse()` (client hook). The behavior must stay
@@ -17,40 +28,48 @@ export function substitutePatternParams(
17
28
  params: Record<string, string | undefined>,
18
29
  routeName: string,
19
30
  ): string {
20
- let result = pattern;
21
- let hadOmittedOptional = false;
31
+ const hasTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
32
+ const normalized = hasTrailingSlash ? pattern.slice(0, -1) : pattern;
33
+ const segments = parsePattern(normalized);
22
34
 
23
- result = result.replace(
24
- /:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
25
- (_match, key) => {
26
- const value = params[key as string];
27
- // The matcher omits absent optional params (so `value` is `undefined`
28
- // here), but caller-supplied params or `getParams()` shapes may still
29
- // pass `""` explicitly. Treat both as the absent form.
35
+ const parts: string[] = [];
36
+ for (const seg of segments) {
37
+ if (seg.type === "static") {
38
+ parts.push("/" + seg.value);
39
+ } else if (seg.type === "wildcard") {
40
+ const value = params[seg.value];
30
41
  if (value === undefined || value === "") {
31
- hadOmittedOptional = true;
32
- return "";
42
+ // `:name+` requires at least one segment; bare `*` / `:name*` collapse.
43
+ if (seg.oneOrMore) {
44
+ throw new Error(
45
+ `Missing param "${seg.value}" for route "${routeName}"`,
46
+ );
47
+ }
48
+ } else {
49
+ parts.push("/" + encodePathRemainder(value));
33
50
  }
34
- return encodePathSegment(value);
35
- },
36
- );
37
-
38
- result = result.replace(
39
- /:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(?!\?)/g,
40
- (_match, key) => {
41
- const value = params[key as string];
42
- if (value === undefined) {
43
- throw new Error(`Missing param "${key}" for route "${routeName}"`);
51
+ } else {
52
+ // Plain param. Constraint (`seg.constraint`) is intentionally not re-emitted.
53
+ const value = params[seg.value];
54
+ const suffix = seg.suffix ?? "";
55
+ if (seg.optional) {
56
+ // The matcher omits absent optionals (`undefined`); callers/getParams()
57
+ // may pass `""` explicitly — treat both as absent.
58
+ if (value !== undefined && value !== "") {
59
+ parts.push("/" + encodePathSegment(value) + suffix);
60
+ }
61
+ } else {
62
+ if (value === undefined) {
63
+ throw new Error(
64
+ `Missing param "${seg.value}" for route "${routeName}"`,
65
+ );
66
+ }
67
+ parts.push("/" + encodePathSegment(value) + suffix);
44
68
  }
45
- return encodePathSegment(value);
46
- },
47
- );
48
-
49
- if (hadOmittedOptional) {
50
- const hadTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
51
- result = result.replace(/\/\/+/g, "/").replace(/\/+$/, "") || "/";
52
- if (hadTrailingSlash && !result.endsWith("/")) result += "/";
69
+ }
53
70
  }
54
71
 
72
+ let result = parts.join("") || "/";
73
+ if (hasTrailingSlash && !result.endsWith("/")) result += "/";
55
74
  return result;
56
75
  }
@@ -41,7 +41,7 @@ import { runThenSettle } from "./tracing.js";
41
41
  import type {
42
42
  RouterTracingConfig,
43
43
  SpanRunner,
44
- TracePhaseToggles,
44
+ TracingToggleOptions,
45
45
  } from "./tracing.js";
46
46
 
47
47
  // ---------------------------------------------------------------------------
@@ -92,13 +92,11 @@ const STATUS_ERROR = 2;
92
92
  // Tracing adapter: phase spans via startActiveSpan (the `tracing` slot)
93
93
  // ---------------------------------------------------------------------------
94
94
 
95
- /** Options for createOTelTracing. */
96
- export interface OTelTracingOptions {
97
- /** Master switch. Defaults to true. */
98
- enabled?: boolean;
99
- /** Per-phase span toggles. Omitted phases default to enabled. */
100
- spans?: TracePhaseToggles;
101
- }
95
+ /**
96
+ * Options for createOTelTracing. Alias of the shared {@link TracingToggleOptions}
97
+ * (`enabled` master switch + per-phase `spans` toggles); the name is public API.
98
+ */
99
+ export type OTelTracingOptions = TracingToggleOptions;
102
100
 
103
101
  /**
104
102
  * Create the tracing config that maps the router's phases onto OTel spans via
@@ -38,6 +38,14 @@ export interface RequestEndEvent extends BaseEvent {
38
38
  durationMs: number;
39
39
  segmentCount: number;
40
40
  cacheHit: boolean;
41
+ /**
42
+ * HTTP status when a Response ended the transaction — a thrown-Response
43
+ * short-circuit (redirect / auth gate carries the Response's status, e.g. 302),
44
+ * or dispatch()'s final response status. Absent for a normal render completion:
45
+ * the Response is built after match(), so match()/matchPartial() have no status
46
+ * to stamp there. Lets a sink split 3xx short-circuits from 2xx completions.
47
+ */
48
+ status?: number;
41
49
  }
42
50
 
43
51
  export interface RequestErrorEvent extends BaseEvent {
@@ -320,7 +328,7 @@ export function createConsoleSink(): TelemetrySink {
320
328
  break;
321
329
  case "request.end":
322
330
  console.log(
323
- `[telemetry] ${event.type} ${event.method} ${event.pathname} ${event.durationMs.toFixed(1)}ms segments=${event.segmentCount} cache=${event.cacheHit}`,
331
+ `[telemetry] ${event.type} ${event.method} ${event.pathname} ${event.durationMs.toFixed(1)}ms segments=${event.segmentCount} cache=${event.cacheHit}${event.status !== undefined ? ` status=${event.status}` : ""}`,
324
332
  );
325
333
  break;
326
334
  case "request.error":
@@ -81,17 +81,26 @@ export interface TracePhaseToggles {
81
81
  ssr?: boolean;
82
82
  }
83
83
 
84
+ /**
85
+ * The option pair shared by every tracing factory (enabled master switch +
86
+ * per-phase span toggles). Extended by OTelTracingOptions,
87
+ * CloudflareTracingOptions, VercelTracingOptions, and RouterTracingConfig so
88
+ * a phase added to TracePhaseToggles propagates everywhere from one place.
89
+ */
90
+ export interface TracingToggleOptions {
91
+ /** Master switch. Defaults to true. */
92
+ enabled?: boolean;
93
+ /** Per-phase span toggles. Omitted phases default to enabled. */
94
+ spans?: TracePhaseToggles;
95
+ }
96
+
84
97
  /**
85
98
  * Value passed to `createRouter({ tracing })`. Produced by a platform factory
86
99
  * such as `createCloudflareTracing()`.
87
100
  */
88
- export interface RouterTracingConfig {
101
+ export interface RouterTracingConfig extends TracingToggleOptions {
89
102
  /** Platform span runner. */
90
103
  runner: SpanRunner;
91
- /** Master switch. Defaults to true when a config object is provided. */
92
- enabled?: boolean;
93
- /** Per-phase span toggles. */
94
- spans?: TracePhaseToggles;
95
104
  }
96
105
 
97
106
  /**