@rangojs/router 0.0.0-experimental.18 → 0.0.0-experimental.19

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 (177) hide show
  1. package/README.md +46 -8
  2. package/dist/bin/rango.js +105 -18
  3. package/dist/vite/index.js +227 -93
  4. package/package.json +15 -14
  5. package/skills/hooks/SKILL.md +1 -1
  6. package/skills/intercept/SKILL.md +79 -0
  7. package/skills/layout/SKILL.md +62 -2
  8. package/skills/loader/SKILL.md +94 -1
  9. package/skills/middleware/SKILL.md +81 -0
  10. package/skills/parallel/SKILL.md +57 -2
  11. package/skills/prerender/SKILL.md +187 -17
  12. package/skills/route/SKILL.md +42 -1
  13. package/skills/router-setup/SKILL.md +77 -0
  14. package/src/__internal.ts +1 -1
  15. package/src/bin/rango.ts +38 -19
  16. package/src/browser/action-coordinator.ts +97 -0
  17. package/src/browser/event-controller.ts +25 -27
  18. package/src/browser/history-state.ts +80 -0
  19. package/src/browser/intercept-utils.ts +1 -1
  20. package/src/browser/link-interceptor.ts +0 -3
  21. package/src/browser/merge-segment-loaders.ts +9 -2
  22. package/src/browser/navigation-bridge.ts +46 -13
  23. package/src/browser/navigation-client.ts +32 -61
  24. package/src/browser/navigation-store.ts +1 -31
  25. package/src/browser/navigation-transaction.ts +46 -207
  26. package/src/browser/partial-update.ts +102 -150
  27. package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
  28. package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
  29. package/src/browser/prefetch/policy.ts +42 -0
  30. package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
  31. package/src/browser/react/Link.tsx +28 -23
  32. package/src/browser/react/NavigationProvider.tsx +9 -1
  33. package/src/browser/react/index.ts +2 -6
  34. package/src/browser/react/location-state-shared.ts +1 -1
  35. package/src/browser/react/location-state.ts +2 -0
  36. package/src/browser/react/nonce-context.ts +23 -0
  37. package/src/browser/react/use-action.ts +9 -1
  38. package/src/browser/react/use-handle.ts +3 -25
  39. package/src/browser/react/use-params.ts +2 -4
  40. package/src/browser/react/use-pathname.ts +2 -3
  41. package/src/browser/react/use-router.ts +1 -1
  42. package/src/browser/react/use-search-params.ts +2 -1
  43. package/src/browser/react/use-segments.ts +7 -60
  44. package/src/browser/response-adapter.ts +73 -0
  45. package/src/browser/rsc-router.tsx +29 -23
  46. package/src/browser/scroll-restoration.ts +10 -7
  47. package/src/browser/server-action-bridge.ts +115 -96
  48. package/src/browser/types.ts +1 -31
  49. package/src/browser/validate-redirect-origin.ts +29 -0
  50. package/src/build/generate-manifest.ts +5 -0
  51. package/src/build/generate-route-types.ts +2 -0
  52. package/src/build/route-types/codegen.ts +13 -4
  53. package/src/build/route-types/include-resolution.ts +13 -0
  54. package/src/build/route-types/per-module-writer.ts +15 -3
  55. package/src/build/route-types/router-processing.ts +45 -3
  56. package/src/build/runtime-discovery.ts +13 -1
  57. package/src/cache/background-task.ts +34 -0
  58. package/src/cache/cache-key-utils.ts +44 -0
  59. package/src/cache/cache-policy.ts +125 -0
  60. package/src/cache/cache-runtime.ts +132 -96
  61. package/src/cache/cache-scope.ts +71 -73
  62. package/src/cache/cf/cf-cache-store.ts +9 -4
  63. package/src/cache/document-cache.ts +72 -47
  64. package/src/cache/handle-capture.ts +81 -0
  65. package/src/cache/memory-segment-store.ts +18 -7
  66. package/src/cache/profile-registry.ts +43 -8
  67. package/src/cache/read-through-swr.ts +134 -0
  68. package/src/cache/segment-codec.ts +101 -112
  69. package/src/cache/taint.ts +26 -0
  70. package/src/client.tsx +53 -30
  71. package/src/errors.ts +6 -1
  72. package/src/handle.ts +1 -1
  73. package/src/handles/MetaTags.tsx +5 -2
  74. package/src/host/cookie-handler.ts +8 -3
  75. package/src/host/router.ts +14 -1
  76. package/src/href-client.ts +3 -1
  77. package/src/index.rsc.ts +33 -1
  78. package/src/index.ts +27 -0
  79. package/src/loader.rsc.ts +12 -4
  80. package/src/loader.ts +8 -0
  81. package/src/prerender/store.ts +4 -3
  82. package/src/prerender.ts +76 -18
  83. package/src/reverse.ts +11 -7
  84. package/src/root-error-boundary.tsx +30 -26
  85. package/src/route-definition/dsl-helpers.ts +9 -6
  86. package/src/route-definition/redirect.ts +15 -3
  87. package/src/route-map-builder.ts +38 -2
  88. package/src/route-name.ts +53 -0
  89. package/src/route-types.ts +7 -0
  90. package/src/router/content-negotiation.ts +1 -1
  91. package/src/router/debug-manifest.ts +16 -3
  92. package/src/router/handler-context.ts +94 -15
  93. package/src/router/intercept-resolution.ts +6 -4
  94. package/src/router/lazy-includes.ts +4 -0
  95. package/src/router/loader-resolution.ts +1 -0
  96. package/src/router/logging.ts +100 -3
  97. package/src/router/manifest.ts +32 -3
  98. package/src/router/match-api.ts +61 -7
  99. package/src/router/match-context.ts +3 -0
  100. package/src/router/match-handlers.ts +185 -11
  101. package/src/router/match-middleware/background-revalidation.ts +65 -85
  102. package/src/router/match-middleware/cache-lookup.ts +69 -4
  103. package/src/router/match-middleware/cache-store.ts +2 -0
  104. package/src/router/match-pipelines.ts +8 -43
  105. package/src/router/middleware-types.ts +7 -0
  106. package/src/router/middleware.ts +93 -8
  107. package/src/router/pattern-matching.ts +41 -5
  108. package/src/router/prerender-match.ts +34 -6
  109. package/src/router/preview-match.ts +7 -1
  110. package/src/router/revalidation.ts +61 -2
  111. package/src/router/router-context.ts +15 -0
  112. package/src/router/router-interfaces.ts +34 -0
  113. package/src/router/router-options.ts +200 -0
  114. package/src/router/segment-resolution/fresh.ts +123 -30
  115. package/src/router/segment-resolution/helpers.ts +19 -0
  116. package/src/router/segment-resolution/loader-cache.ts +37 -146
  117. package/src/router/segment-resolution/revalidation.ts +358 -94
  118. package/src/router/segment-wrappers.ts +3 -0
  119. package/src/router/telemetry-otel.ts +299 -0
  120. package/src/router/telemetry.ts +300 -0
  121. package/src/router/timeout.ts +148 -0
  122. package/src/router/types.ts +7 -1
  123. package/src/router.ts +155 -11
  124. package/src/rsc/handler-context.ts +11 -0
  125. package/src/rsc/handler.ts +380 -88
  126. package/src/rsc/helpers.ts +25 -16
  127. package/src/rsc/loader-fetch.ts +84 -42
  128. package/src/rsc/origin-guard.ts +141 -0
  129. package/src/rsc/progressive-enhancement.ts +232 -19
  130. package/src/rsc/response-route-handler.ts +37 -26
  131. package/src/rsc/rsc-rendering.ts +12 -5
  132. package/src/rsc/runtime-warnings.ts +42 -0
  133. package/src/rsc/server-action.ts +134 -58
  134. package/src/rsc/types.ts +8 -0
  135. package/src/search-params.ts +22 -10
  136. package/src/server/context.ts +53 -5
  137. package/src/server/fetchable-loader-store.ts +11 -6
  138. package/src/server/handle-store.ts +66 -9
  139. package/src/server/loader-registry.ts +11 -46
  140. package/src/server/request-context.ts +90 -9
  141. package/src/ssr/index.tsx +63 -27
  142. package/src/static-handler.ts +7 -0
  143. package/src/theme/ThemeProvider.tsx +6 -1
  144. package/src/theme/index.ts +1 -6
  145. package/src/theme/theme-context.ts +1 -28
  146. package/src/theme/theme-script.ts +2 -1
  147. package/src/types/cache-types.ts +5 -0
  148. package/src/types/error-types.ts +3 -0
  149. package/src/types/global-namespace.ts +9 -0
  150. package/src/types/handler-context.ts +35 -13
  151. package/src/types/loader-types.ts +7 -0
  152. package/src/types/route-entry.ts +28 -0
  153. package/src/urls/include-helper.ts +49 -8
  154. package/src/urls/index.ts +1 -0
  155. package/src/urls/path-helper-types.ts +30 -12
  156. package/src/urls/path-helper.ts +17 -2
  157. package/src/urls/pattern-types.ts +21 -1
  158. package/src/urls/response-types.ts +27 -2
  159. package/src/urls/type-extraction.ts +23 -15
  160. package/src/use-loader.tsx +12 -4
  161. package/src/vite/discovery/bundle-postprocess.ts +12 -7
  162. package/src/vite/discovery/discover-routers.ts +30 -18
  163. package/src/vite/discovery/prerender-collection.ts +24 -27
  164. package/src/vite/discovery/route-types-writer.ts +7 -7
  165. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  166. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  167. package/src/vite/plugins/use-cache-transform.ts +91 -3
  168. package/src/vite/rango.ts +3 -3
  169. package/src/vite/router-discovery.ts +99 -36
  170. package/src/vite/utils/prerender-utils.ts +21 -0
  171. package/src/vite/utils/shared-utils.ts +3 -1
  172. package/src/browser/request-controller.ts +0 -164
  173. package/src/href-context.ts +0 -33
  174. package/src/router.gen.ts +0 -6
  175. package/src/static-handler.gen.ts +0 -5
  176. package/src/urls.gen.ts +0 -8
  177. /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
@@ -142,79 +142,76 @@ export const deserializeComponent: (encoded: string) => Promise<unknown> =
142
142
  export async function serializeSegments(
143
143
  segments: ResolvedSegment[],
144
144
  ): Promise<SerializedSegmentData[]> {
145
- const serialized: SerializedSegmentData[] = [];
146
-
147
- for (const segment of segments) {
148
- const temporaryReferences = createTemporaryReferenceSet();
149
-
150
- // Await component if it's a Promise (intercepts with loading keep component as Promise)
151
- const componentResolved =
152
- segment.component instanceof Promise
153
- ? await segment.component
154
- : segment.component;
155
-
156
- // Serialize the component to RSC stream
157
- const stream = renderToReadableStream(componentResolved, {
158
- temporaryReferences,
159
- });
160
-
161
- // Convert stream to string
162
- const encoded = await streamToString(stream);
163
-
164
- // RSC-serialize layout if present (ReactNode)
165
- const encodedLayout = segment.layout
166
- ? await rscSerialize(segment.layout)
167
- : undefined;
168
-
169
- // RSC-serialize loading if present (ReactNode) - preserves tree structure
170
- // Use "null" string to distinguish explicit null from undefined
171
- const encodedLoading =
172
- segment.loading !== undefined
173
- ? segment.loading === null
174
- ? "null"
175
- : await rscSerialize(segment.loading)
176
- : undefined;
177
-
178
- // Await and RSC-serialize loaderData if present
179
- const loaderDataResolved =
180
- segment.loaderData instanceof Promise
181
- ? await segment.loaderData
182
- : segment.loaderData;
183
- const encodedLoaderData = await rscSerialize(loaderDataResolved);
184
-
185
- // Await and RSC-serialize loaderDataPromise if present
186
- const loaderDataPromiseResolved =
187
- segment.loaderDataPromise instanceof Promise
188
- ? await segment.loaderDataPromise
189
- : segment.loaderDataPromise;
190
- const encodedLoaderDataPromise = await rscSerialize(
191
- loaderDataPromiseResolved,
192
- );
193
-
194
- serialized.push({
195
- encoded,
196
- encodedLayout,
197
- encodedLoading,
198
- encodedLoaderData,
199
- encodedLoaderDataPromise,
200
- metadata: {
201
- id: segment.id,
202
- type: segment.type,
203
- namespace: segment.namespace,
204
- index: segment.index,
205
- params: segment.params,
206
- slot: segment.slot,
207
- belongsToRoute: segment.belongsToRoute,
208
- layoutName: segment.layoutName,
209
- parallelName: segment.parallelName,
210
- loaderId: segment.loaderId,
211
- loaderIds: segment.loaderIds,
212
- transition: segment.transition,
213
- },
214
- });
215
- }
145
+ return Promise.all(
146
+ segments.map(async (segment): Promise<SerializedSegmentData> => {
147
+ const temporaryReferences = createTemporaryReferenceSet();
148
+
149
+ // Await component if it's a Promise (intercepts with loading keep component as Promise)
150
+ const componentResolved =
151
+ segment.component instanceof Promise
152
+ ? await segment.component
153
+ : segment.component;
154
+
155
+ // Serialize the component to RSC stream
156
+ const stream = renderToReadableStream(componentResolved, {
157
+ temporaryReferences,
158
+ });
159
+
160
+ // RSC-serialize loading: "null" string distinguishes explicit null from undefined
161
+ const encodedLoading =
162
+ segment.loading !== undefined
163
+ ? segment.loading === null
164
+ ? "null"
165
+ : await rscSerialize(segment.loading)
166
+ : undefined;
167
+
168
+ // Await loaderData / loaderDataPromise if they're Promises
169
+ const loaderDataResolved =
170
+ segment.loaderData instanceof Promise
171
+ ? await segment.loaderData
172
+ : segment.loaderData;
173
+ const loaderDataPromiseResolved =
174
+ segment.loaderDataPromise instanceof Promise
175
+ ? await segment.loaderDataPromise
176
+ : segment.loaderDataPromise;
177
+
178
+ // Parallelize stream-to-string and RSC serialization of sub-fields
179
+ const [
180
+ encoded,
181
+ encodedLayout,
182
+ encodedLoaderData,
183
+ encodedLoaderDataPromise,
184
+ ] = await Promise.all([
185
+ streamToString(stream),
186
+ segment.layout ? rscSerialize(segment.layout) : undefined,
187
+ rscSerialize(loaderDataResolved),
188
+ rscSerialize(loaderDataPromiseResolved),
189
+ ]);
216
190
 
217
- return serialized;
191
+ return {
192
+ encoded,
193
+ encodedLayout,
194
+ encodedLoading,
195
+ encodedLoaderData,
196
+ encodedLoaderDataPromise,
197
+ metadata: {
198
+ id: segment.id,
199
+ type: segment.type,
200
+ namespace: segment.namespace,
201
+ index: segment.index,
202
+ params: segment.params,
203
+ slot: segment.slot,
204
+ belongsToRoute: segment.belongsToRoute,
205
+ layoutName: segment.layoutName,
206
+ parallelName: segment.parallelName,
207
+ loaderId: segment.loaderId,
208
+ loaderIds: segment.loaderIds,
209
+ transition: segment.transition,
210
+ mountPath: segment.mountPath,
211
+ },
212
+ };
213
+ }),
214
+ );
218
215
  }
219
216
 
220
217
  /**
@@ -224,44 +221,36 @@ export async function serializeSegments(
224
221
  export async function deserializeSegments(
225
222
  data: SerializedSegmentData[],
226
223
  ): Promise<ResolvedSegment[]> {
227
- const segments: ResolvedSegment[] = [];
228
-
229
- for (const item of data) {
230
- const temporaryReferences = createTemporaryReferenceSet();
231
-
232
- // Revive the component from cached string
233
- const stream = stringToStream(item.encoded);
234
- const component = await createFromReadableStream(stream, {
235
- temporaryReferences,
236
- });
237
-
238
- // RSC-deserialize layout, loaderData, loaderDataPromise in parallel.
239
- // Handle the "null" sentinel for loading before RSC deserialization.
240
- // During serialization, loading: null is stored as the string "null" to
241
- // distinguish it from undefined. This sentinel must be intercepted here
242
- // rather than passed to rscDeserialize, which would try to decode it as
243
- // an RSC Flight payload.
244
- const loadingIsNullSentinel = item.encodedLoading === "null";
245
-
246
- const [layout, loaderData, loaderDataPromise, loadingData] =
247
- await Promise.all([
248
- rscDeserialize(item.encodedLayout),
249
- rscDeserialize(item.encodedLoaderData),
250
- rscDeserialize(item.encodedLoaderDataPromise),
251
- loadingIsNullSentinel
252
- ? (null as any)
253
- : rscDeserialize(item.encodedLoading),
254
- ]);
255
-
256
- segments.push({
257
- ...item.metadata,
258
- component,
259
- layout,
260
- loading: loadingData,
261
- loaderData,
262
- loaderDataPromise,
263
- } as ResolvedSegment);
264
- }
265
-
266
- return segments;
224
+ return Promise.all(
225
+ data.map(async (item): Promise<ResolvedSegment> => {
226
+ const temporaryReferences = createTemporaryReferenceSet();
227
+
228
+ // Handle the "null" sentinel for loading before RSC deserialization.
229
+ // During serialization, loading: null is stored as the string "null" to
230
+ // distinguish it from undefined.
231
+ const loadingIsNullSentinel = item.encodedLoading === "null";
232
+
233
+ const [component, layout, loaderData, loaderDataPromise, loadingData] =
234
+ await Promise.all([
235
+ createFromReadableStream(stringToStream(item.encoded), {
236
+ temporaryReferences,
237
+ }),
238
+ rscDeserialize(item.encodedLayout),
239
+ rscDeserialize(item.encodedLoaderData),
240
+ rscDeserialize(item.encodedLoaderDataPromise),
241
+ loadingIsNullSentinel
242
+ ? (null as any)
243
+ : rscDeserialize(item.encodedLoading),
244
+ ]);
245
+
246
+ return {
247
+ ...item.metadata,
248
+ component,
249
+ layout,
250
+ loading: loadingData,
251
+ loaderData,
252
+ loaderDataPromise,
253
+ } as ResolvedSegment;
254
+ }),
255
+ );
267
256
  }
@@ -25,11 +25,37 @@ export function isTainted(value: unknown): boolean {
25
25
  * cookies(), headers(), ctx.set(), ctx.header(), etc. check this flag and
26
26
  * throw if present — reads would cache per-request data under a shared key,
27
27
  * and side effects would be lost on cache hit.
28
+ *
29
+ * The value is a numeric reference count, not a boolean. Multiple concurrent
30
+ * cached functions sharing the same ctx/requestCtx each increment on entry
31
+ * and decrement on exit. Guards fire when count > 0.
28
32
  */
29
33
  export const INSIDE_CACHE_EXEC: unique symbol = Symbol.for(
30
34
  "rango:inside-cache-exec",
31
35
  ) as any;
32
36
 
37
+ /**
38
+ * Increment the INSIDE_CACHE_EXEC ref count on an object.
39
+ */
40
+ export function stampCacheExec(obj: object): void {
41
+ const current = (obj as any)[INSIDE_CACHE_EXEC] ?? 0;
42
+ (obj as any)[INSIDE_CACHE_EXEC] = current + 1;
43
+ }
44
+
45
+ /**
46
+ * Decrement the INSIDE_CACHE_EXEC ref count on an object.
47
+ * Deletes the symbol when the count reaches zero so the `in` check
48
+ * used by guards no longer fires.
49
+ */
50
+ export function unstampCacheExec(obj: object): void {
51
+ const current = (obj as any)[INSIDE_CACHE_EXEC] ?? 0;
52
+ if (current <= 1) {
53
+ delete (obj as any)[INSIDE_CACHE_EXEC];
54
+ } else {
55
+ (obj as any)[INSIDE_CACHE_EXEC] = current - 1;
56
+ }
57
+ }
58
+
33
59
  /**
34
60
  * Throw if ctx is inside a "use cache" execution.
35
61
  * Call from side-effecting ctx methods (set, header, etc.) and cookie mutations.
package/src/client.tsx CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  LoaderBoundary,
22
22
  } from "./route-content-wrapper.js";
23
23
  import { OutletProvider } from "./outlet-provider.js";
24
+ import { MountContextProvider } from "./browser/react/mount-context.js";
24
25
 
25
26
  /**
26
27
  * Outlet component - renders child content in layouts
@@ -87,6 +88,8 @@ export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
87
88
  content = segment.component ?? null;
88
89
  }
89
90
 
91
+ let result: ReactNode;
92
+
90
93
  // If segment has a layout, wrap appropriately
91
94
  if (segment.layout) {
92
95
  // Check if this segment has loaders that need streaming
@@ -106,25 +109,23 @@ export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
106
109
  </LoaderBoundary>
107
110
  );
108
111
 
109
- return (
112
+ result = (
110
113
  <OutletProvider content={loaderAwareContent} segment={segment}>
111
114
  {segment.layout}
112
115
  </OutletProvider>
113
116
  );
117
+ } else {
118
+ // No loaders - wrap in OutletProvider so layout can use <Outlet />
119
+ result = (
120
+ <OutletProvider content={content} segment={segment}>
121
+ {segment.layout}
122
+ </OutletProvider>
123
+ );
114
124
  }
115
-
116
- // No loaders - wrap in OutletProvider so layout can use <Outlet />
117
- return (
118
- <OutletProvider content={content} segment={segment}>
119
- {segment.layout}
120
- </OutletProvider>
121
- );
122
- }
123
-
124
- // No layout but has loaders - wrap content with LoaderBoundary for useLoader context
125
- // This is common for intercept routes that use useLoader without a custom layout
126
- if (segment.loaderDataPromise && segment.loaderIds) {
127
- return (
125
+ } else if (segment.loaderDataPromise && segment.loaderIds) {
126
+ // No layout but has loaders - wrap content with LoaderBoundary for useLoader context
127
+ // This is common for intercept routes that use useLoader without a custom layout
128
+ result = (
128
129
  <LoaderBoundary
129
130
  loaderDataPromise={segment.loaderDataPromise}
130
131
  loaderIds={segment.loaderIds}
@@ -136,9 +137,20 @@ export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
136
137
  {content}
137
138
  </LoaderBoundary>
138
139
  );
140
+ } else {
141
+ result = content;
139
142
  }
140
143
 
141
- return content;
144
+ // Wrap with MountContextProvider for include() scoped parallel/intercept slots
145
+ if (segment.mountPath) {
146
+ return (
147
+ <MountContextProvider value={segment.mountPath}>
148
+ {result}
149
+ </MountContextProvider>
150
+ );
151
+ }
152
+
153
+ return result;
142
154
  }
143
155
 
144
156
  // Default: render child content
@@ -202,6 +214,8 @@ export function ParallelOutlet({ name }: { name: `@${string}` }): ReactNode {
202
214
  content = segment.component ?? null;
203
215
  }
204
216
 
217
+ let result: ReactNode;
218
+
205
219
  // If segment has a layout, wrap appropriately
206
220
  if (segment.layout) {
207
221
  // Check if this segment has loaders that need streaming
@@ -220,25 +234,23 @@ export function ParallelOutlet({ name }: { name: `@${string}` }): ReactNode {
220
234
  </LoaderBoundary>
221
235
  );
222
236
 
223
- return (
237
+ result = (
224
238
  <OutletProvider content={loaderAwareContent} segment={segment}>
225
239
  {segment.layout}
226
240
  </OutletProvider>
227
241
  );
242
+ } else {
243
+ // No loaders - wrap in OutletProvider so layout can use <Outlet />
244
+ result = (
245
+ <OutletProvider content={content} segment={segment}>
246
+ {segment.layout}
247
+ </OutletProvider>
248
+ );
228
249
  }
229
-
230
- // No loaders - wrap in OutletProvider so layout can use <Outlet />
231
- return (
232
- <OutletProvider content={content} segment={segment}>
233
- {segment.layout}
234
- </OutletProvider>
235
- );
236
- }
237
-
238
- // No layout but has loaders - wrap content with LoaderBoundary for useLoader context
239
- // This is common for intercept routes that use useLoader without a custom layout
240
- if (segment.loaderDataPromise && segment.loaderIds) {
241
- return (
250
+ } else if (segment.loaderDataPromise && segment.loaderIds) {
251
+ // No layout but has loaders - wrap content with LoaderBoundary for useLoader context
252
+ // This is common for intercept routes that use useLoader without a custom layout
253
+ result = (
242
254
  <LoaderBoundary
243
255
  loaderDataPromise={segment.loaderDataPromise}
244
256
  loaderIds={segment.loaderIds}
@@ -250,9 +262,20 @@ export function ParallelOutlet({ name }: { name: `@${string}` }): ReactNode {
250
262
  {content}
251
263
  </LoaderBoundary>
252
264
  );
265
+ } else {
266
+ result = content;
253
267
  }
254
268
 
255
- return content;
269
+ // Wrap with MountContextProvider for include() scoped parallel/intercept slots
270
+ if (segment.mountPath) {
271
+ return (
272
+ <MountContextProvider value={segment.mountPath}>
273
+ {result}
274
+ </MountContextProvider>
275
+ );
276
+ }
277
+
278
+ return result;
256
279
  }
257
280
 
258
281
  // OutletProvider is defined in outlet-provider.tsx to break a circular
package/src/errors.ts CHANGED
@@ -327,7 +327,12 @@ export function sanitizeError(error: unknown): Response {
327
327
  return error;
328
328
  }
329
329
 
330
- const isDev = (import.meta as any).env?.DEV ?? true;
330
+ // Vite replaces import.meta.env.DEV at compile time. The fallback covers
331
+ // non-Vite environments (plain Node, test runners without Vite transforms).
332
+ // SECURITY: fail closed — default to production when the environment is ambiguous.
333
+ const isDev =
334
+ (import.meta as any).env?.DEV ??
335
+ globalThis.process?.env?.NODE_ENV === "development";
331
336
 
332
337
  if (isDev) {
333
338
  // Development: Send full error details for debugging
package/src/handle.ts CHANGED
@@ -95,7 +95,7 @@ export function createHandle<TData, TAccumulated = TData[]>(
95
95
  ): Handle<TData, TAccumulated> {
96
96
  const handleId = __injectedId ?? "";
97
97
 
98
- if (!handleId && process.env.NODE_ENV !== "production") {
98
+ if (!handleId && process.env.NODE_ENV === "development") {
99
99
  throw new Error(
100
100
  "[rsc-router] Handle is missing $$id. " +
101
101
  "Make sure the exposeInternalIds Vite plugin is enabled and " +
@@ -28,8 +28,9 @@ import { use } from "react";
28
28
  import { useHandle } from "../browser/react/use-handle.js";
29
29
  import { Meta } from "./meta.js";
30
30
  import type { MetaDescriptor, MetaDescriptorBase } from "../router/types.js";
31
- import { getSSRThemeConfig } from "../theme/theme-context.js";
31
+ import { useThemeContext } from "../theme/theme-context.js";
32
32
  import { generateThemeScript } from "../theme/theme-script.js";
33
+ import { useNonce } from "../browser/react/nonce-context.js";
33
34
 
34
35
  // Type guards for MetaDescriptorBase variants
35
36
  function hasCharSet(d: MetaDescriptorBase): d is { charSet: "utf-8" } {
@@ -216,13 +217,15 @@ function AsyncMetaTag({
216
217
  */
217
218
  export function MetaTags(): React.ReactNode {
218
219
  const descriptors = useHandle(Meta) as MetaDescriptor[];
219
- const themeConfig = getSSRThemeConfig();
220
+ const themeConfig = useThemeContext()?.config ?? null;
221
+ const nonce = useNonce();
220
222
 
221
223
  return (
222
224
  <>
223
225
  {/* Theme script must be first to prevent FOUC */}
224
226
  {themeConfig && (
225
227
  <script
228
+ nonce={nonce}
226
229
  dangerouslySetInnerHTML={{ __html: generateThemeScript(themeConfig) }}
227
230
  />
228
231
  )}
@@ -26,9 +26,14 @@ export function parseCookies(request: Request): Record<string, string> {
26
26
  const pairs = cookieHeader.split(";");
27
27
 
28
28
  for (const pair of pairs) {
29
- const [key, value] = pair.trim().split("=");
30
- if (key && value) {
31
- cookies[key] = decodeURIComponent(value);
29
+ const [name, ...rest] = pair.trim().split("=");
30
+ if (name && rest.length > 0) {
31
+ const value = rest.join("=");
32
+ try {
33
+ cookies[name] = decodeURIComponent(value);
34
+ } catch {
35
+ cookies[name] = value;
36
+ }
32
37
  }
33
38
  }
34
39
 
@@ -149,7 +149,20 @@ export function createHostRouter(options: HostRouterOptions = {}): HostRouter {
149
149
  return finalHandler();
150
150
  }
151
151
 
152
- return mw(request, input, next);
152
+ // Guard against double next() calls — a second call would
153
+ // re-enter the downstream chain and run handlers/side-effects twice.
154
+ let nextCalled = false;
155
+ const guardedNext = (): Promise<Response> => {
156
+ if (nextCalled) {
157
+ throw new Error(
158
+ `[HostRouter] Middleware called next() more than once.`,
159
+ );
160
+ }
161
+ nextCalled = true;
162
+ return next();
163
+ };
164
+
165
+ return mw(request, input, guardedNext);
153
166
  }
154
167
 
155
168
  return next();
@@ -182,7 +182,9 @@ export type ValidPaths<TRoutes = GetRegisteredRoutes> =
182
182
  */
183
183
  export function href<T extends ValidPaths>(path: T, mount?: string): string {
184
184
  if (mount && mount !== "/") {
185
- return mount + path;
185
+ // Strip trailing slash from mount to avoid double-slash when joining
186
+ const normalizedMount = mount.endsWith("/") ? mount.slice(0, -1) : mount;
187
+ return normalizedMount + path;
186
188
  }
187
189
  return path;
188
190
  }
package/src/index.rsc.ts CHANGED
@@ -72,7 +72,12 @@ export type {
72
72
  } from "./types.js";
73
73
 
74
74
  // Router options type (server-only, so import directly)
75
- export type { RSCRouterOptions } from "./router.js";
75
+ export type {
76
+ RSCRouterOptions,
77
+ SSRStreamMode,
78
+ SSROptions,
79
+ ResolveStreamingContext,
80
+ } from "./router.js";
76
81
 
77
82
  // Server-side createLoader and redirect
78
83
  export {
@@ -230,3 +235,30 @@ export {
230
235
 
231
236
  // Path-based response type lookup from RegisteredRoutes
232
237
  export type { PathResponse } from "./href-client.js";
238
+
239
+ // Telemetry sink
240
+ export { createConsoleSink } from "./router/telemetry.js";
241
+ export { createOTelSink } from "./router/telemetry-otel.js";
242
+ export type { OTelTracer, OTelSpan } from "./router/telemetry-otel.js";
243
+ export type {
244
+ TelemetrySink,
245
+ TelemetryEvent,
246
+ RequestStartEvent,
247
+ RequestEndEvent,
248
+ RequestErrorEvent,
249
+ RequestTimeoutEvent,
250
+ LoaderStartEvent,
251
+ LoaderEndEvent,
252
+ LoaderErrorEvent,
253
+ HandlerErrorEvent,
254
+ CacheDecisionEvent,
255
+ RevalidationDecisionEvent,
256
+ } from "./router/telemetry.js";
257
+
258
+ // Timeout types and error class
259
+ export { RouterTimeoutError } from "./router/timeout.js";
260
+ export type {
261
+ RouterTimeouts,
262
+ TimeoutPhase,
263
+ TimeoutContext,
264
+ } from "./router/timeout.js";
package/src/index.ts CHANGED
@@ -282,3 +282,30 @@ export {
282
282
 
283
283
  // Path-based response type lookup from RegisteredRoutes
284
284
  export type { PathResponse } from "./href-client.js";
285
+
286
+ // Telemetry sink
287
+ export { createConsoleSink } from "./router/telemetry.js";
288
+ export { createOTelSink } from "./router/telemetry-otel.js";
289
+ export type { OTelTracer, OTelSpan } from "./router/telemetry-otel.js";
290
+ export type {
291
+ TelemetrySink,
292
+ TelemetryEvent,
293
+ RequestStartEvent,
294
+ RequestEndEvent,
295
+ RequestErrorEvent,
296
+ RequestTimeoutEvent,
297
+ LoaderStartEvent,
298
+ LoaderEndEvent,
299
+ LoaderErrorEvent,
300
+ HandlerErrorEvent,
301
+ CacheDecisionEvent,
302
+ RevalidationDecisionEvent,
303
+ } from "./router/telemetry.js";
304
+
305
+ // Timeout types and error class
306
+ export { RouterTimeoutError } from "./router/timeout.js";
307
+ export type {
308
+ RouterTimeouts,
309
+ TimeoutPhase,
310
+ TimeoutContext,
311
+ } from "./router/timeout.js";
package/src/loader.rsc.ts CHANGED
@@ -52,11 +52,19 @@ export function createLoader<T>(
52
52
  // For fetchable loaders, __injectedId is also passed as a parameter
53
53
  const loaderId = __injectedId || "";
54
54
 
55
- // If not fetchable, store fn in registry and return a plain object.
56
- // Server-side code looks up fn via getFetchableLoader($$id).
55
+ if (!loaderId && process.env.NODE_ENV === "development") {
56
+ throw new Error(
57
+ "[rsc-router] Loader is missing $$id. " +
58
+ "Make sure the exposeInternalIds Vite plugin is enabled and " +
59
+ "the loader is exported with: export const MyLoader = createLoader(...)",
60
+ );
61
+ }
62
+
63
+ // If not fetchable, store fn in registry (for SSR ctx.use() resolution)
64
+ // but mark fetchable=false so the _rsc_loader endpoint rejects it.
57
65
  if (fetchable === undefined) {
58
66
  if (fn && loaderId) {
59
- registerFetchableLoader(loaderId, fn, []);
67
+ registerFetchableLoader(loaderId, fn, [], false);
60
68
  }
61
69
  return {
62
70
  __brand: "loader",
@@ -71,7 +79,7 @@ export function createLoader<T>(
71
79
  // Register the function in the internal registry by $$id (server-side only)
72
80
  // The loader fetch handler looks it up by $$id when load() is called from the client.
73
81
  if (fn && loaderId) {
74
- registerFetchableLoader(loaderId, fn, middleware);
82
+ registerFetchableLoader(loaderId, fn, middleware, true);
75
83
  }
76
84
 
77
85
  return {
package/src/loader.ts CHANGED
@@ -49,6 +49,14 @@ export function createLoader<T>(
49
49
  ): LoaderDefinition<Awaited<T>, Record<string, string | undefined>> {
50
50
  const loaderId = __injectedId || "";
51
51
 
52
+ if (!loaderId && process.env.NODE_ENV === "development") {
53
+ throw new Error(
54
+ "[rsc-router] Loader is missing $$id. " +
55
+ "Make sure the exposeInternalIds Vite plugin is enabled and " +
56
+ "the loader is exported with: export const MyLoader = createLoader(...)",
57
+ );
58
+ }
59
+
52
60
  return {
53
61
  __brand: "loader",
54
62
  $$id: loaderId,
@@ -21,7 +21,7 @@ export interface PrerenderStore {
21
21
  get(
22
22
  routeName: string,
23
23
  paramHash: string,
24
- meta?: { pathname: string },
24
+ meta?: { pathname: string; isPassthroughRoute?: boolean },
25
25
  ): PrerenderEntry | null | Promise<PrerenderEntry | null>;
26
26
  }
27
27
 
@@ -58,11 +58,12 @@ declare global {
58
58
  */
59
59
  export function createDevPrerenderStore(devUrl: string): PrerenderStore {
60
60
  return {
61
- async get(_routeName, paramHash, meta) {
61
+ async get(routeName, paramHash, meta) {
62
62
  if (!meta?.pathname) return null;
63
63
  const isIntercept = paramHash.endsWith("/i");
64
- let url = `${devUrl}/__rsc_prerender?pathname=${encodeURIComponent(meta.pathname)}`;
64
+ let url = `${devUrl}/__rsc_prerender?pathname=${encodeURIComponent(meta.pathname)}&routeName=${encodeURIComponent(routeName)}`;
65
65
  if (isIntercept) url += "&intercept=1";
66
+ if (meta.isPassthroughRoute) url += "&passthrough=1";
66
67
  try {
67
68
  const res = await fetch(url);
68
69
  if (!res.ok) return null;