@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1fa245e2

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 (160) hide show
  1. package/{CLAUDE.md → AGENTS.md} +4 -0
  2. package/README.md +122 -30
  3. package/dist/bin/rango.js +245 -63
  4. package/dist/vite/index.js +859 -418
  5. package/package.json +3 -3
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +32 -0
  8. package/skills/caching/SKILL.md +49 -8
  9. package/skills/document-cache/SKILL.md +2 -2
  10. package/skills/hooks/SKILL.md +33 -31
  11. package/skills/host-router/SKILL.md +218 -0
  12. package/skills/links/SKILL.md +3 -1
  13. package/skills/loader/SKILL.md +72 -22
  14. package/skills/middleware/SKILL.md +2 -0
  15. package/skills/parallel/SKILL.md +126 -0
  16. package/skills/prerender/SKILL.md +112 -70
  17. package/skills/rango/SKILL.md +0 -1
  18. package/skills/route/SKILL.md +34 -4
  19. package/skills/router-setup/SKILL.md +95 -5
  20. package/skills/typesafety/SKILL.md +35 -23
  21. package/src/__internal.ts +92 -0
  22. package/src/bin/rango.ts +18 -0
  23. package/src/browser/app-version.ts +14 -0
  24. package/src/browser/event-controller.ts +5 -0
  25. package/src/browser/link-interceptor.ts +4 -0
  26. package/src/browser/navigation-bridge.ts +114 -18
  27. package/src/browser/navigation-client.ts +126 -44
  28. package/src/browser/navigation-store.ts +43 -8
  29. package/src/browser/navigation-transaction.ts +11 -9
  30. package/src/browser/partial-update.ts +80 -15
  31. package/src/browser/prefetch/cache.ts +166 -27
  32. package/src/browser/prefetch/fetch.ts +52 -39
  33. package/src/browser/prefetch/policy.ts +6 -0
  34. package/src/browser/prefetch/queue.ts +92 -20
  35. package/src/browser/prefetch/resource-ready.ts +77 -0
  36. package/src/browser/react/Link.tsx +70 -14
  37. package/src/browser/react/NavigationProvider.tsx +40 -4
  38. package/src/browser/react/context.ts +7 -2
  39. package/src/browser/react/use-handle.ts +9 -58
  40. package/src/browser/react/use-router.ts +21 -8
  41. package/src/browser/rsc-router.tsx +143 -59
  42. package/src/browser/scroll-restoration.ts +41 -42
  43. package/src/browser/segment-reconciler.ts +6 -1
  44. package/src/browser/server-action-bridge.ts +454 -436
  45. package/src/browser/types.ts +60 -5
  46. package/src/build/generate-manifest.ts +6 -6
  47. package/src/build/generate-route-types.ts +5 -0
  48. package/src/build/route-trie.ts +19 -3
  49. package/src/build/route-types/include-resolution.ts +8 -1
  50. package/src/build/route-types/router-processing.ts +346 -87
  51. package/src/build/route-types/scan-filter.ts +8 -1
  52. package/src/cache/cache-runtime.ts +15 -11
  53. package/src/cache/cache-scope.ts +48 -7
  54. package/src/cache/cf/cf-cache-store.ts +453 -11
  55. package/src/cache/cf/index.ts +5 -1
  56. package/src/cache/document-cache.ts +17 -7
  57. package/src/cache/index.ts +1 -0
  58. package/src/cache/taint.ts +55 -0
  59. package/src/client.rsc.tsx +2 -1
  60. package/src/client.tsx +3 -102
  61. package/src/context-var.ts +72 -2
  62. package/src/debug.ts +2 -2
  63. package/src/handle.ts +40 -0
  64. package/src/handles/breadcrumbs.ts +66 -0
  65. package/src/handles/index.ts +1 -0
  66. package/src/host/index.ts +0 -3
  67. package/src/index.rsc.ts +8 -37
  68. package/src/index.ts +40 -66
  69. package/src/prerender/store.ts +57 -15
  70. package/src/prerender.ts +138 -77
  71. package/src/reverse.ts +22 -1
  72. package/src/route-definition/dsl-helpers.ts +73 -25
  73. package/src/route-definition/helpers-types.ts +10 -6
  74. package/src/route-definition/index.ts +3 -3
  75. package/src/route-definition/redirect.ts +11 -3
  76. package/src/route-definition/resolve-handler-use.ts +149 -0
  77. package/src/route-map-builder.ts +7 -1
  78. package/src/route-types.ts +11 -0
  79. package/src/router/content-negotiation.ts +100 -1
  80. package/src/router/find-match.ts +4 -2
  81. package/src/router/handler-context.ts +108 -25
  82. package/src/router/intercept-resolution.ts +11 -4
  83. package/src/router/lazy-includes.ts +4 -1
  84. package/src/router/loader-resolution.ts +123 -11
  85. package/src/router/logging.ts +5 -2
  86. package/src/router/manifest.ts +9 -3
  87. package/src/router/match-api.ts +125 -190
  88. package/src/router/match-middleware/background-revalidation.ts +30 -2
  89. package/src/router/match-middleware/cache-lookup.ts +88 -16
  90. package/src/router/match-middleware/cache-store.ts +53 -10
  91. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  92. package/src/router/match-middleware/segment-resolution.ts +61 -5
  93. package/src/router/match-result.ts +22 -15
  94. package/src/router/metrics.ts +238 -13
  95. package/src/router/middleware-types.ts +53 -12
  96. package/src/router/middleware.ts +172 -85
  97. package/src/router/navigation-snapshot.ts +182 -0
  98. package/src/router/pattern-matching.ts +20 -5
  99. package/src/router/prerender-match.ts +114 -10
  100. package/src/router/preview-match.ts +30 -102
  101. package/src/router/request-classification.ts +310 -0
  102. package/src/router/revalidation.ts +27 -7
  103. package/src/router/route-snapshot.ts +245 -0
  104. package/src/router/router-context.ts +6 -1
  105. package/src/router/router-interfaces.ts +50 -5
  106. package/src/router/router-options.ts +50 -19
  107. package/src/router/segment-resolution/fresh.ts +200 -19
  108. package/src/router/segment-resolution/helpers.ts +30 -25
  109. package/src/router/segment-resolution/loader-cache.ts +1 -0
  110. package/src/router/segment-resolution/revalidation.ts +429 -301
  111. package/src/router/segment-wrappers.ts +2 -0
  112. package/src/router/trie-matching.ts +20 -2
  113. package/src/router/types.ts +1 -0
  114. package/src/router.ts +88 -15
  115. package/src/rsc/handler.ts +546 -359
  116. package/src/rsc/index.ts +0 -20
  117. package/src/rsc/manifest-init.ts +5 -1
  118. package/src/rsc/progressive-enhancement.ts +25 -8
  119. package/src/rsc/rsc-rendering.ts +35 -43
  120. package/src/rsc/server-action.ts +16 -10
  121. package/src/rsc/ssr-setup.ts +128 -0
  122. package/src/rsc/types.ts +10 -1
  123. package/src/search-params.ts +16 -13
  124. package/src/segment-system.tsx +140 -4
  125. package/src/server/context.ts +148 -16
  126. package/src/server/loader-registry.ts +9 -8
  127. package/src/server/request-context.ts +182 -34
  128. package/src/server.ts +6 -0
  129. package/src/ssr/index.tsx +4 -0
  130. package/src/static-handler.ts +18 -6
  131. package/src/theme/index.ts +4 -13
  132. package/src/types/cache-types.ts +4 -4
  133. package/src/types/handler-context.ts +149 -49
  134. package/src/types/loader-types.ts +36 -9
  135. package/src/types/route-config.ts +17 -8
  136. package/src/types/route-entry.ts +8 -1
  137. package/src/types/segments.ts +2 -5
  138. package/src/urls/path-helper-types.ts +9 -2
  139. package/src/urls/path-helper.ts +48 -13
  140. package/src/urls/pattern-types.ts +12 -0
  141. package/src/urls/response-types.ts +16 -6
  142. package/src/use-loader.tsx +73 -4
  143. package/src/vite/discovery/bundle-postprocess.ts +61 -89
  144. package/src/vite/discovery/discover-routers.ts +23 -5
  145. package/src/vite/discovery/prerender-collection.ts +48 -15
  146. package/src/vite/discovery/state.ts +17 -13
  147. package/src/vite/index.ts +8 -3
  148. package/src/vite/plugin-types.ts +51 -79
  149. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  150. package/src/vite/plugins/expose-action-id.ts +1 -3
  151. package/src/vite/plugins/performance-tracks.ts +88 -0
  152. package/src/vite/plugins/refresh-cmd.ts +127 -0
  153. package/src/vite/plugins/version-plugin.ts +13 -1
  154. package/src/vite/rango.ts +174 -211
  155. package/src/vite/router-discovery.ts +169 -42
  156. package/src/vite/utils/banner.ts +3 -3
  157. package/src/vite/utils/prerender-utils.ts +78 -0
  158. package/src/vite/utils/shared-utils.ts +3 -2
  159. package/skills/testing/SKILL.md +0 -226
  160. package/src/route-definition/route-function.ts +0 -119
package/src/rsc/index.ts CHANGED
@@ -29,28 +29,8 @@ export type {
29
29
  NonceProvider,
30
30
  } from "./types.js";
31
31
 
32
- // Re-export HandleStore types for consumers who need custom handling
33
- export {
34
- createHandleStore,
35
- type HandleStore,
36
- type HandleData,
37
- } from "../server/handle-store.js";
38
-
39
32
  // Re-export request context utilities for server-side access to env/request/params
40
33
  export {
41
34
  getRequestContext,
42
35
  requireRequestContext,
43
- setRequestContextParams,
44
36
  } from "../server/request-context.js";
45
-
46
- // Re-export cache store types and implementations
47
- export type {
48
- SegmentCacheStore,
49
- CachedEntryData,
50
- CachedEntryResult,
51
- SegmentCacheProvider,
52
- SegmentHandleData,
53
- } from "../cache/types.js";
54
-
55
- export { MemorySegmentCacheStore } from "../cache/memory-segment-store.js";
56
- export { CFCacheStore, type CFCacheStoreOptions } from "../cache/cf/index.js";
@@ -31,7 +31,11 @@ export async function buildRouterTrieFromUrlpatterns(
31
31
  ): Promise<void> {
32
32
  const { generateManifestFull } =
33
33
  await import("../build/generate-manifest.js");
34
- const generated = generateManifestFull(router.urlpatterns);
34
+ const generated = generateManifestFull(
35
+ router.urlpatterns,
36
+ undefined,
37
+ router.basename ? { urlPrefix: router.basename } : undefined,
38
+ );
35
39
  if (
36
40
  generated._routeAncestry &&
37
41
  Object.keys(generated._routeAncestry).length > 0
@@ -10,6 +10,7 @@ import {
10
10
  requireRequestContext,
11
11
  setRequestContextParams,
12
12
  } from "../server/request-context.js";
13
+ import { getSSRSetup } from "./ssr-setup.js";
13
14
  import type { MiddlewareFn } from "../router/middleware.js";
14
15
  import { executeMiddleware } from "../router/middleware.js";
15
16
  import type { RscPayload, ReactFormState } from "./types.js";
@@ -242,6 +243,8 @@ export async function handleProgressiveEnhancement<TEnv>(
242
243
  const payload: RscPayload = {
243
244
  metadata: {
244
245
  pathname: url.pathname,
246
+ routerId: ctx.router.id,
247
+ basename: ctx.router.basename,
245
248
  segments: match.segments,
246
249
  matched: match.matched,
247
250
  diff: match.diff,
@@ -257,10 +260,16 @@ export async function handleProgressiveEnhancement<TEnv>(
257
260
  };
258
261
 
259
262
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
260
- const [ssrModule, streamMode] = await Promise.all([
261
- ctx.loadSSRModule(),
262
- ctx.resolveStreamMode(request, env, url),
263
- ]);
263
+ // metricsStore=undefined is safe: the handler already stashed the early
264
+ // SSR setup promise on request variables, so getSSRSetup returns it
265
+ // without falling back to a fresh startSSRSetup.
266
+ const [ssrModule, streamMode] = await getSSRSetup(
267
+ ctx,
268
+ request,
269
+ env,
270
+ url,
271
+ undefined,
272
+ );
264
273
  const htmlStream = await ssrModule.renderHTML(rscStream, {
265
274
  formState: reactFormState,
266
275
  nonce,
@@ -335,6 +344,8 @@ async function renderPeErrorBoundary<TEnv>(
335
344
  const payload: RscPayload = {
336
345
  metadata: {
337
346
  pathname: url.pathname,
347
+ routerId: ctx.router.id,
348
+ basename: ctx.router.basename,
338
349
  segments: errorResult.segments,
339
350
  matched: errorResult.matched,
340
351
  diff: errorResult.diff,
@@ -350,10 +361,16 @@ async function renderPeErrorBoundary<TEnv>(
350
361
  };
351
362
 
352
363
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
353
- const [ssrModule, streamMode] = await Promise.all([
354
- ctx.loadSSRModule(),
355
- ctx.resolveStreamMode(request, env, url),
356
- ]);
364
+ // metricsStore=undefined is safe: the handler already stashed the early
365
+ // SSR setup promise on request variables, so getSSRSetup returns it
366
+ // without falling back to a fresh startSSRSetup.
367
+ const [ssrModule, streamMode] = await getSSRSetup(
368
+ ctx,
369
+ request,
370
+ env,
371
+ url,
372
+ undefined,
373
+ );
357
374
  const htmlStream = await ssrModule.renderHTML(rscStream, {
358
375
  nonce,
359
376
  streamMode,
@@ -12,6 +12,8 @@ import {
12
12
  getLocationState,
13
13
  } from "../server/request-context.js";
14
14
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
15
+ import { appendMetric } from "../router/metrics.js";
16
+ import { getSSRSetup } from "./ssr-setup.js";
15
17
  import type { RscPayload } from "./types.js";
16
18
  import {
17
19
  createResponseWithMergedHeaders,
@@ -28,13 +30,9 @@ export async function handleRscRendering<TEnv>(
28
30
  handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
29
31
  nonce: string | undefined,
30
32
  ): Promise<Response> {
31
- // Retrieve handler-level timing from variables
32
33
  const reqCtx = requireRequestContext();
33
- const handlerTimingArr: string[] = reqCtx.var.__handlerTiming || [];
34
- const handlerStart: number = reqCtx.var.__handlerStart || 0;
35
34
 
36
35
  let payload: RscPayload;
37
- let serverTiming: string | undefined;
38
36
  let hasInterceptSlots = false;
39
37
 
40
38
  if (isPartial) {
@@ -53,11 +51,11 @@ export async function handleRscRendering<TEnv>(
53
51
  return createSimpleRedirectResponse(match.redirect);
54
52
  }
55
53
 
56
- serverTiming = match.serverTiming;
57
-
58
54
  payload = {
59
55
  metadata: {
60
56
  pathname: url.pathname,
57
+ routerId: ctx.router.id,
58
+ basename: ctx.router.basename,
61
59
  segments: match.segments,
62
60
  matched: match.matched,
63
61
  diff: match.diff,
@@ -66,18 +64,20 @@ export async function handleRscRendering<TEnv>(
66
64
  rootLayout: ctx.router.rootLayout,
67
65
  handles: handleStore.stream(),
68
66
  version: ctx.version,
67
+ prefetchCacheTTL: ctx.router.prefetchCacheTTL,
69
68
  themeConfig: ctx.router.themeConfig,
70
69
  initialTheme: reqCtx.theme,
71
70
  },
72
71
  };
73
72
  } else {
74
73
  setRequestContextParams(result.params, result.routeName);
75
- serverTiming = result.serverTiming;
74
+
76
75
  hasInterceptSlots = !!result.slots;
77
76
 
78
77
  payload = {
79
78
  metadata: {
80
79
  pathname: url.pathname,
80
+ routerId: ctx.router.id,
81
81
  segments: result.segments,
82
82
  matched: result.matched,
83
83
  diff: result.diff,
@@ -86,6 +86,7 @@ export async function handleRscRendering<TEnv>(
86
86
  slots: result.slots,
87
87
  handles: handleStore.stream(),
88
88
  version: ctx.version,
89
+ prefetchCacheTTL: ctx.router.prefetchCacheTTL,
89
90
  },
90
91
  };
91
92
  }
@@ -132,14 +133,14 @@ export async function handleRscRendering<TEnv>(
132
133
  { headers: { "Content-Type": "application/json" } },
133
134
  );
134
135
  } else {
135
- serverTiming = match.serverTiming;
136
-
137
136
  payload = {
138
137
  // Initial SSR can reconstruct the tree from segments + rootLayout,
139
138
  // so we omit root to avoid sending the same structure twice.
140
139
 
141
140
  metadata: {
142
141
  pathname: url.pathname,
142
+ routerId: ctx.router.id,
143
+ basename: ctx.router.basename,
143
144
  segments: match.segments,
144
145
  matched: match.matched,
145
146
  diff: match.diff,
@@ -148,6 +149,7 @@ export async function handleRscRendering<TEnv>(
148
149
  rootLayout: ctx.router.rootLayout,
149
150
  handles: handleStore.stream(),
150
151
  version: ctx.version,
152
+ prefetchCacheTTL: ctx.router.prefetchCacheTTL,
151
153
  themeConfig: ctx.router.themeConfig,
152
154
  initialTheme: reqCtx.theme,
153
155
  },
@@ -166,10 +168,20 @@ export async function handleRscRendering<TEnv>(
166
168
  }
167
169
  }
168
170
 
171
+ const metricsStore = reqCtx._metricsStore;
172
+ const renderStart = performance.now();
173
+
169
174
  // Serialize to RSC stream
170
175
  const rscSerializeStart = performance.now();
171
176
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
172
177
  const rscSerializeDur = performance.now() - rscSerializeStart;
178
+ // This measures synchronous stream creation, not end-to-end stream consumption.
179
+ appendMetric(
180
+ metricsStore,
181
+ "rsc-serialize",
182
+ rscSerializeStart,
183
+ rscSerializeDur,
184
+ );
173
185
 
174
186
  // Determine if this is an RSC request or HTML request.
175
187
  // Partial requests (_rsc_partial) are always RSC -- they come from client-side
@@ -181,15 +193,9 @@ export async function handleRscRendering<TEnv>(
181
193
  !url.searchParams.has("__html")) ||
182
194
  url.searchParams.has("__rsc");
183
195
 
184
- // Build complete Server-Timing: handler phases + match/manifest + RSC serialize
185
- const timingParts: string[] = [...handlerTimingArr];
186
- if (serverTiming) {
187
- timingParts.push(serverTiming);
188
- }
189
- timingParts.push(`rsc-serialize;dur=${rscSerializeDur.toFixed(2)}`);
190
-
191
196
  if (isRscRequest) {
192
- const fullTiming = timingParts.join(", ");
197
+ const renderDur = performance.now() - renderStart;
198
+ appendMetric(metricsStore, "render:total", renderStart, renderDur);
193
199
  const rscHeaders: Record<string, string> = {
194
200
  "content-type": "text/x-component;charset=utf-8",
195
201
  vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
@@ -205,22 +211,19 @@ export async function handleRscRendering<TEnv>(
205
211
  rscHeaders["cache-control"] = cc;
206
212
  }
207
213
  }
208
- if (fullTiming) {
209
- rscHeaders["Server-Timing"] = fullTiming;
210
- }
211
214
  return createResponseWithMergedHeaders(rscStream, {
212
215
  headers: rscHeaders,
213
216
  });
214
217
  }
215
218
 
216
- // Delegate to SSR for HTML response
217
- const ssrSetupStart = performance.now();
218
- const [ssrModule, streamMode] = await Promise.all([
219
- ctx.loadSSRModule(),
220
- ctx.resolveStreamMode(request, env, url),
221
- ]);
222
- const ssrSetupDur = performance.now() - ssrSetupStart;
223
- timingParts.push(`ssr-setup;dur=${ssrSetupDur.toFixed(2)}`);
219
+ // Delegate to SSR for HTML response (reuse early setup if available)
220
+ const [ssrModule, streamMode] = await getSSRSetup(
221
+ ctx,
222
+ request,
223
+ env,
224
+ url,
225
+ metricsStore,
226
+ );
224
227
 
225
228
  const ssrRenderStart = performance.now();
226
229
  const htmlStream = await ssrModule.renderHTML(rscStream, {
@@ -228,23 +231,12 @@ export async function handleRscRendering<TEnv>(
228
231
  streamMode,
229
232
  });
230
233
  const ssrRenderDur = performance.now() - ssrRenderStart;
231
- timingParts.push(`ssr-render-html;dur=${ssrRenderDur.toFixed(2)}`);
234
+ appendMetric(metricsStore, "ssr-render-html", ssrRenderStart, ssrRenderDur);
232
235
 
233
- // Add total handler duration
234
- if (handlerStart) {
235
- const totalHandler = performance.now() - handlerStart;
236
- timingParts.push(`handler-total;dur=${totalHandler.toFixed(2)}`);
237
- }
238
-
239
- const fullTiming = timingParts.join(", ");
240
- const htmlHeaders: Record<string, string> = {
241
- "content-type": "text/html;charset=utf-8",
242
- };
243
- if (fullTiming) {
244
- htmlHeaders["Server-Timing"] = fullTiming;
245
- }
236
+ const renderDur = performance.now() - renderStart;
237
+ appendMetric(metricsStore, "render:total", renderStart, renderDur);
246
238
 
247
239
  return createResponseWithMergedHeaders(htmlStream, {
248
- headers: htmlHeaders,
240
+ headers: { "content-type": "text/html;charset=utf-8" },
249
241
  });
250
242
  }
@@ -21,6 +21,7 @@ import {
21
21
  getLocationState,
22
22
  } from "../server/request-context.js";
23
23
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
24
+ import { appendMetric } from "../router/metrics.js";
24
25
  import type { RscPayload } from "./types.js";
25
26
  import {
26
27
  hasBodyContent,
@@ -207,6 +208,7 @@ export async function executeServerAction<TEnv>(
207
208
  const payload: RscPayload = {
208
209
  metadata: {
209
210
  pathname: url.pathname,
211
+ routerId: ctx.router.id,
210
212
  segments: errorResult.segments,
211
213
  isPartial: true,
212
214
  matched: errorResult.matched,
@@ -274,6 +276,8 @@ export async function revalidateAfterAction<TEnv>(
274
276
  ): Promise<Response> {
275
277
  const { returnValue, actionStatus, temporaryReferences, actionContext } =
276
278
  continuation;
279
+ const reqCtx = requireRequestContext();
280
+ const metricsStore = reqCtx._metricsStore;
277
281
 
278
282
  const matchResult = await ctx.router.matchPartial(
279
283
  request,
@@ -308,11 +312,10 @@ export async function revalidateAfterAction<TEnv>(
308
312
  // Return updated segments
309
313
  setRequestContextParams(matchResult.params, matchResult.routeName);
310
314
 
311
- const serverTiming = matchResult.serverTiming;
312
-
313
315
  const payload: RscPayload = {
314
316
  metadata: {
315
317
  pathname: url.pathname,
318
+ routerId: ctx.router.id,
316
319
  segments: matchResult.segments,
317
320
  isPartial: true,
318
321
  matched: matchResult.matched,
@@ -326,19 +329,22 @@ export async function revalidateAfterAction<TEnv>(
326
329
 
327
330
  attachLocationState(payload);
328
331
 
332
+ const renderStart = performance.now();
329
333
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
330
334
  temporaryReferences,
331
335
  });
332
-
333
- const actionHeaders: Record<string, string> = {
334
- "content-type": "text/x-component;charset=utf-8",
335
- };
336
- if (serverTiming) {
337
- actionHeaders["Server-Timing"] = serverTiming;
338
- }
336
+ const rscSerializeDur = performance.now() - renderStart;
337
+ // This measures synchronous stream creation, not end-to-end stream consumption.
338
+ appendMetric(metricsStore, "rsc-serialize", renderStart, rscSerializeDur);
339
+ appendMetric(
340
+ metricsStore,
341
+ "render:total",
342
+ renderStart,
343
+ performance.now() - renderStart,
344
+ );
339
345
 
340
346
  return createResponseWithMergedHeaders(rscStream, {
341
347
  status: actionStatus,
342
- headers: actionHeaders,
348
+ headers: { "content-type": "text/x-component;charset=utf-8" },
343
349
  });
344
350
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * SSR Setup Utilities
3
+ *
4
+ * Manages early kickoff and retrieval of SSR module loading and stream mode
5
+ * resolution. Both operations are request-scoped but independent of route
6
+ * matching, so they can run in parallel with segment resolution.
7
+ */
8
+
9
+ import type { HandlerContext } from "./handler-context.js";
10
+ import type { SSRModule } from "./types.js";
11
+ import type { SSRStreamMode } from "../router/router-options.js";
12
+ import type { MetricsStore } from "../server/context.js";
13
+ import { appendMetric } from "../router/metrics.js";
14
+ import { _getRequestContext } from "../server/request-context.js";
15
+
16
+ export type SSRSetup = readonly [SSRModule, SSRStreamMode];
17
+
18
+ /**
19
+ * Key used to stash the early SSR setup promise on request variables.
20
+ * Read back via `getSSRSetup`.
21
+ */
22
+ export const SSR_SETUP_VAR = "__ssrSetup";
23
+
24
+ /**
25
+ * Start loading the SSR module and resolving the stream mode in parallel.
26
+ * When a `getMetricsStore` getter is provided, records individual
27
+ * `ssr:module-load` and `ssr:stream-mode` metrics (the getter is called
28
+ * lazily so stores created after kickoff are still captured). Without a
29
+ * getter the promises run bare — no `.then()` microtasks, no
30
+ * `performance.now()` calls — keeping the non-debug hot path lean.
31
+ */
32
+ export function startSSRSetup<TEnv>(
33
+ ctx: HandlerContext<TEnv>,
34
+ request: Request,
35
+ env: TEnv,
36
+ url: URL,
37
+ getMetricsStore?: () => MetricsStore | undefined,
38
+ ): Promise<SSRSetup> {
39
+ if (!getMetricsStore) {
40
+ return Promise.all([
41
+ ctx.loadSSRModule(),
42
+ ctx.resolveStreamMode(request, env, url),
43
+ ]);
44
+ }
45
+ const start = performance.now();
46
+ return Promise.all([
47
+ ctx.loadSSRModule().then((mod) => {
48
+ appendMetric(
49
+ getMetricsStore(),
50
+ "ssr:module-load",
51
+ start,
52
+ performance.now() - start,
53
+ );
54
+ return mod;
55
+ }),
56
+ ctx.resolveStreamMode(request, env, url).then((mode) => {
57
+ appendMetric(
58
+ getMetricsStore(),
59
+ "ssr:stream-mode",
60
+ start,
61
+ performance.now() - start,
62
+ );
63
+ return mode;
64
+ }),
65
+ ]);
66
+ }
67
+
68
+ /**
69
+ * Retrieve the SSR setup result. Returns the early-kicked-off promise
70
+ * when available (stashed on request variables), otherwise starts a
71
+ * fresh setup.
72
+ */
73
+ export function getSSRSetup<TEnv>(
74
+ ctx: HandlerContext<TEnv>,
75
+ request: Request,
76
+ env: TEnv,
77
+ url: URL,
78
+ metricsStore: MetricsStore | undefined,
79
+ ): Promise<SSRSetup> {
80
+ const early = _getRequestContext()?._variables?.[SSR_SETUP_VAR] as
81
+ | Promise<SSRSetup>
82
+ | undefined;
83
+ if (early) return early;
84
+ return startSSRSetup(
85
+ ctx,
86
+ request,
87
+ env,
88
+ url,
89
+ metricsStore ? () => metricsStore : undefined,
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Classify whether a request may require SSR (HTML rendering).
95
+ *
96
+ * Returns false for requests that are definitively RSC-only, loader fetches,
97
+ * prerender collection, or Accept-based RSC (no text/html). This mirrors
98
+ * the isRscRequest decision in rsc-rendering.ts.
99
+ *
100
+ * Note: response/mime routes are excluded by the caller — this function
101
+ * runs after classifyRequest() determines the request mode.
102
+ */
103
+ export function mayNeedSSR(request: Request, url: URL): boolean {
104
+ if (
105
+ url.searchParams.has("_rsc_partial") ||
106
+ url.searchParams.has("_rsc_action") ||
107
+ request.headers.has("rsc-action") ||
108
+ url.searchParams.has("_rsc_loader") ||
109
+ url.searchParams.has("__rsc") ||
110
+ url.searchParams.has("__prerender_collect")
111
+ ) {
112
+ return false;
113
+ }
114
+
115
+ // Mirror the Accept-based RSC decision from rsc-rendering.ts:
116
+ // if Accept is present and does not include text/html (and no __html override),
117
+ // the response will be RSC, not HTML.
118
+ const accept = request.headers.get("accept");
119
+ if (
120
+ accept &&
121
+ !accept.includes("text/html") &&
122
+ !url.searchParams.has("__html")
123
+ ) {
124
+ return false;
125
+ }
126
+
127
+ return true;
128
+ }
package/src/rsc/types.ts CHANGED
@@ -19,6 +19,9 @@ export interface RscPayload {
19
19
  metadata?: {
20
20
  pathname: string;
21
21
  segments: ResolvedSegment[];
22
+ /** Router instance ID. When this changes between navigations, the client
23
+ * discards cached segments and does a full tree replacement (app switch). */
24
+ routerId?: string;
22
25
  isPartial?: boolean;
23
26
  isError?: boolean;
24
27
  matched?: string[];
@@ -32,10 +35,14 @@ export interface RscPayload {
32
35
  handles?: AsyncGenerator<HandleData, void, unknown>;
33
36
  /** RSC version string for cache invalidation */
34
37
  version?: string;
38
+ /** TTL in milliseconds for the client-side in-memory prefetch cache */
39
+ prefetchCacheTTL?: number;
35
40
  /** Theme configuration for FOUC prevention */
36
41
  themeConfig?: ResolvedThemeConfig | null;
37
42
  /** Initial theme from cookie (for SSR hydration) */
38
43
  initialTheme?: Theme;
44
+ /** URL prefix for all routes (from createRouter({ basename })). */
45
+ basename?: string;
39
46
  /** Whether connection warmup is enabled */
40
47
  warmupEnabled?: boolean;
41
48
  /** Server-side redirect with optional state (for partial requests) */
@@ -61,7 +68,9 @@ export interface RSCDependencies {
61
68
  */
62
69
  renderToReadableStream: <T>(
63
70
  payload: T,
64
- options?: { temporaryReferences?: unknown },
71
+ options?: {
72
+ temporaryReferences?: unknown;
73
+ },
65
74
  ) => ReadableStream<Uint8Array>;
66
75
 
67
76
  /**
@@ -55,14 +55,22 @@ type Simplify<T> = { [K in keyof T]: T[K] };
55
55
  /**
56
56
  * Resolve a SearchSchema to its typed object.
57
57
  *
58
+ * Both required and optional params resolve to `T | undefined` at the handler
59
+ * level. The required/optional distinction is a consumer-facing contract
60
+ * (e.g., for href() and reverse() autocomplete) — it tells callers which
61
+ * params the route expects, but the handler must still check for undefined
62
+ * since the framework cannot trust the client to send all required params.
63
+ *
58
64
  * @example
59
65
  * type S = { q: "string"; page: "number?"; sort: "string?" };
60
66
  * type R = ResolveSearchSchema<S>;
61
- * // { q: string; page?: number; sort?: string }
67
+ * // { q: string | undefined; page?: number; sort?: string }
62
68
  */
63
69
  export type ResolveSearchSchema<T extends SearchSchema> = Simplify<
64
70
  {
65
- [K in RequiredKeys<T> & string]: ResolveBaseType<BaseType<T[K]>>;
71
+ [K in RequiredKeys<T> & string]:
72
+ | ResolveBaseType<BaseType<T[K]>>
73
+ | undefined;
66
74
  } & {
67
75
  [K in OptionalKeys<T> & string]?: ResolveBaseType<BaseType<T[K]>>;
68
76
  }
@@ -166,7 +174,9 @@ type ExtractParamsFromPattern<T extends string> =
166
174
  * - `"number"` / `"number?"` - coerced via `Number()`; NaN treated as missing
167
175
  * - `"boolean"` / `"boolean?"` - `"true"` / `"1"` -> true, `"false"` / `"0"` / `""` -> false
168
176
  *
169
- * Missing required params are set to their zero value (empty string / 0 / false).
177
+ * Missing params (both required and optional) are omitted from the result
178
+ * (undefined). The required/optional distinction is a consumer-facing contract
179
+ * only — the handler must check for undefined.
170
180
  */
171
181
  export function parseSearchParams<T extends SearchSchema>(
172
182
  searchParams: URLSearchParams,
@@ -180,13 +190,7 @@ export function parseSearchParams<T extends SearchSchema>(
180
190
  const raw = searchParams.get(key);
181
191
 
182
192
  if (raw === null) {
183
- if (!isOptional) {
184
- // Required param missing: use zero value
185
- if (baseType === "string") result[key] = "";
186
- else if (baseType === "number") result[key] = 0;
187
- else if (baseType === "boolean") result[key] = false;
188
- }
189
- // Optional params are omitted (undefined)
193
+ // Missing params are omitted (undefined) regardless of required/optional
190
194
  continue;
191
195
  }
192
196
 
@@ -194,11 +198,10 @@ export function parseSearchParams<T extends SearchSchema>(
194
198
  result[key] = raw;
195
199
  } else if (baseType === "number") {
196
200
  const num = Number(raw);
197
- if (Number.isNaN(num)) {
198
- if (!isOptional) result[key] = 0;
199
- } else {
201
+ if (!Number.isNaN(num)) {
200
202
  result[key] = num;
201
203
  }
204
+ // NaN treated as missing (undefined)
202
205
  } else if (baseType === "boolean") {
203
206
  result[key] = raw === "true" || raw === "1";
204
207
  }