@rangojs/router 0.0.0-experimental.132 → 0.0.0-experimental.133

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 (141) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +43 -2
  3. package/dist/bin/rango.js +92 -16
  4. package/dist/vite/index.js +166 -70
  5. package/package.json +19 -18
  6. package/skills/breadcrumbs/SKILL.md +1 -1
  7. package/skills/bundle-analysis/SKILL.md +2 -2
  8. package/skills/cache-guide/SKILL.md +2 -2
  9. package/skills/caching/SKILL.md +16 -9
  10. package/skills/debug-manifest/SKILL.md +4 -2
  11. package/skills/document-cache/SKILL.md +2 -2
  12. package/skills/handler-use/SKILL.md +1 -1
  13. package/skills/hooks/SKILL.md +2 -2
  14. package/skills/host-router/SKILL.md +1 -1
  15. package/skills/intercept/SKILL.md +1 -1
  16. package/skills/loader/SKILL.md +2 -0
  17. package/skills/migrate-react-router/SKILL.md +4 -2
  18. package/skills/mime-routes/SKILL.md +1 -1
  19. package/skills/prerender/SKILL.md +2 -0
  20. package/skills/rango/SKILL.md +12 -11
  21. package/skills/response-routes/SKILL.md +2 -2
  22. package/skills/route/SKILL.md +4 -0
  23. package/skills/router-setup/SKILL.md +3 -0
  24. package/skills/scripts/SKILL.md +179 -0
  25. package/skills/testing/SKILL.md +1 -1
  26. package/skills/testing/bindings.md +20 -6
  27. package/skills/testing/cache-prerender.md +5 -2
  28. package/skills/testing/client-components.md +2 -0
  29. package/skills/testing/e2e-parity.md +1 -1
  30. package/skills/testing/flight.md +8 -9
  31. package/skills/testing/render-handler.md +1 -1
  32. package/skills/testing/response-routes.md +1 -1
  33. package/skills/testing/server-actions.md +11 -11
  34. package/skills/testing/setup.md +3 -0
  35. package/skills/typesafety/SKILL.md +3 -2
  36. package/skills/use-cache/SKILL.md +10 -9
  37. package/src/browser/event-controller.ts +109 -2
  38. package/src/browser/partial-update.ts +12 -0
  39. package/src/browser/prefetch/cache.ts +17 -0
  40. package/src/browser/prefetch/fetch.ts +69 -2
  41. package/src/browser/react/Link.tsx +30 -5
  42. package/src/browser/react/NavigationProvider.tsx +12 -2
  43. package/src/browser/react/location-state-shared.ts +14 -2
  44. package/src/browser/react/use-href.tsx +8 -1
  45. package/src/browser/react/use-link-status.ts +23 -2
  46. package/src/browser/response-adapter.ts +14 -3
  47. package/src/browser/rsc-router.tsx +3 -0
  48. package/src/browser/scroll-restoration.ts +8 -3
  49. package/src/browser/server-action-bridge.ts +46 -11
  50. package/src/browser/types.ts +6 -0
  51. package/src/build/generate-route-types.ts +0 -1
  52. package/src/build/route-trie.ts +33 -9
  53. package/src/build/route-types/include-resolution.ts +7 -1
  54. package/src/build/route-types/router-processing.ts +0 -6
  55. package/src/build/route-types/source-scan.ts +105 -7
  56. package/src/cache/cache-policy.ts +42 -8
  57. package/src/cache/cache-runtime.ts +65 -5
  58. package/src/cache/cache-scope.ts +71 -11
  59. package/src/cache/cache-tag.ts +7 -2
  60. package/src/cache/cf/cf-base64.ts +33 -0
  61. package/src/cache/cf/cf-cache-constants.ts +127 -0
  62. package/src/cache/cf/cf-cache-store.ts +85 -613
  63. package/src/cache/cf/cf-cache-types.ts +349 -0
  64. package/src/cache/cf/cf-kv-utils.ts +46 -0
  65. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  66. package/src/cache/document-cache.ts +11 -0
  67. package/src/cache/handle-snapshot.ts +8 -1
  68. package/src/cache/profile-registry.ts +25 -1
  69. package/src/cache/segment-codec.ts +9 -1
  70. package/src/cache/types.ts +4 -0
  71. package/src/client.rsc.tsx +38 -0
  72. package/src/client.tsx +11 -0
  73. package/src/components/DefaultDocument.tsx +8 -2
  74. package/src/context-var.ts +1 -1
  75. package/src/decode-loader-results.ts +7 -1
  76. package/src/escape-script.ts +52 -0
  77. package/src/handles/MetaTags.tsx +56 -5
  78. package/src/handles/Scripts.tsx +183 -0
  79. package/src/handles/breadcrumbs.ts +29 -11
  80. package/src/handles/is-thenable.ts +19 -0
  81. package/src/handles/meta.ts +46 -0
  82. package/src/handles/script.ts +244 -0
  83. package/src/host/cookie-handler.ts +7 -3
  84. package/src/host/pattern-matcher.ts +16 -2
  85. package/src/index.rsc.ts +5 -0
  86. package/src/index.ts +5 -0
  87. package/src/response-utils.ts +25 -0
  88. package/src/route-definition/dsl-helpers.ts +7 -0
  89. package/src/route-definition/redirect.ts +1 -2
  90. package/src/router/content-negotiation.ts +58 -10
  91. package/src/router/intercept-resolution.ts +9 -0
  92. package/src/router/match-middleware/cache-store.ts +10 -1
  93. package/src/router/middleware.ts +10 -3
  94. package/src/router/pattern-matching.ts +25 -23
  95. package/src/router/prefetch-cache-ttl.ts +51 -0
  96. package/src/router/router-interfaces.ts +7 -0
  97. package/src/router/router-options.ts +23 -0
  98. package/src/router/segment-resolution/fresh.ts +10 -0
  99. package/src/router/segment-resolution/helpers.ts +35 -1
  100. package/src/router/segment-resolution/loader-cache.ts +10 -6
  101. package/src/router/segment-resolution/revalidation.ts +6 -0
  102. package/src/router/segment-resolution.ts +1 -0
  103. package/src/router/trie-matching.ts +14 -9
  104. package/src/router.ts +18 -10
  105. package/src/rsc/handler.ts +52 -13
  106. package/src/rsc/helpers.ts +7 -1
  107. package/src/rsc/index.ts +1 -4
  108. package/src/rsc/loader-fetch.ts +107 -37
  109. package/src/rsc/progressive-enhancement.ts +18 -6
  110. package/src/rsc/response-cache-serve.ts +238 -0
  111. package/src/rsc/response-route-handler.ts +16 -133
  112. package/src/rsc/rsc-rendering.ts +13 -4
  113. package/src/rsc/server-action.ts +52 -6
  114. package/src/rsc/types.ts +7 -0
  115. package/src/search-params.ts +24 -5
  116. package/src/segment-loader-promise.ts +17 -2
  117. package/src/server/loader-registry.ts +16 -18
  118. package/src/server/request-context.ts +47 -20
  119. package/src/testing/dispatch.ts +108 -25
  120. package/src/testing/flight.ts +25 -0
  121. package/src/testing/internal/context.ts +25 -2
  122. package/src/testing/render-handler.ts +3 -1
  123. package/src/testing/render-route.tsx +15 -0
  124. package/src/testing/run-loader.ts +10 -3
  125. package/src/theme/ThemeProvider.tsx +20 -6
  126. package/src/theme/ThemeScript.tsx +7 -3
  127. package/src/theme/constants.ts +54 -3
  128. package/src/theme/theme-script.ts +22 -7
  129. package/src/types/request-scope.ts +8 -3
  130. package/src/vite/plugins/cjs-to-esm.ts +8 -1
  131. package/src/vite/plugins/expose-id-utils.ts +10 -1
  132. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
  133. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
  134. package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
  135. package/src/vite/plugins/expose-internal-ids.ts +0 -1
  136. package/src/vite/plugins/version-plugin.ts +5 -17
  137. package/src/vite/plugins/virtual-entries.ts +12 -2
  138. package/src/vite/rango.ts +15 -6
  139. package/src/vite/utils/ast-handler-extract.ts +11 -4
  140. package/src/vite/utils/directive-prologue.ts +40 -0
  141. package/src/vite/utils/prerender-utils.ts +17 -2
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Resolve the prefetch cache TTL once, at router init, into the three derived
3
+ * values the rest of the router consumes: seconds (for the Cache-Control
4
+ * max-age), milliseconds (for the client-side in-memory cache), and the
5
+ * Cache-Control header string (or false when caching is disabled).
6
+ *
7
+ * `false` disables prefetch caching (seconds 0). A non-finite input
8
+ * (NaN/Infinity, e.g. from an env-derived number that failed to parse) is
9
+ * treated as the default rather than producing a malformed
10
+ * `Cache-Control: max-age=NaN` header; CDNs/browsers ignore such a directive,
11
+ * which would silently disable caching on EVERY prefetch response. Negative
12
+ * finite values clamp to 0 (disabled).
13
+ *
14
+ * Policy note: this is the prefetch-TTL policy specifically. Other finite-number
15
+ * guards in the codebase deliberately differ — defer.ts treats Infinity as an
16
+ * intentional disable, and profile-registry.ts THROWS on non-finite/negative ttl
17
+ * at config time. They are NOT the same guard, so don't unify them.
18
+ */
19
+ export interface ResolvedPrefetchCacheTTL {
20
+ /** TTL in seconds for the Cache-Control max-age directive. */
21
+ seconds: number;
22
+ /** TTL in milliseconds for the client-side in-memory prefetch cache. */
23
+ ms: number;
24
+ /** Cache-Control header value, or false when caching is disabled. */
25
+ cacheControl: string | false;
26
+ }
27
+
28
+ const DEFAULT_PREFETCH_CACHE_TTL_SECONDS = 300;
29
+
30
+ export function resolvePrefetchCacheTTL(
31
+ rawTTL: number | false | undefined,
32
+ ): ResolvedPrefetchCacheTTL {
33
+ let seconds: number;
34
+ if (rawTTL === false) {
35
+ seconds = 0;
36
+ } else if (rawTTL === undefined) {
37
+ seconds = DEFAULT_PREFETCH_CACHE_TTL_SECONDS;
38
+ } else if (typeof rawTTL === "number" && Number.isFinite(rawTTL)) {
39
+ seconds = Math.max(0, Math.floor(rawTTL));
40
+ } else {
41
+ // Non-finite (NaN/Infinity): fall back to the default rather than emit a
42
+ // malformed max-age=NaN/Infinity that CDNs and browsers reject.
43
+ seconds = DEFAULT_PREFETCH_CACHE_TTL_SECONDS;
44
+ }
45
+
46
+ return {
47
+ seconds,
48
+ ms: seconds * 1000,
49
+ cacheControl: seconds === 0 ? false : `private, max-age=${seconds}`,
50
+ };
51
+ }
@@ -315,6 +315,13 @@ export interface RangoInternal<
315
315
  */
316
316
  readonly warmupEnabled: boolean;
317
317
 
318
+ /**
319
+ * Whether the client hydrates inside React.StrictMode. Resolved from
320
+ * createRouter({ strictMode }) (default true) and shipped to the client in
321
+ * the initial payload metadata.
322
+ */
323
+ readonly strictMode: boolean;
324
+
318
325
  /**
319
326
  * Whether router-wide performance debugging is enabled.
320
327
  * Used by the request handler to create metrics before middleware runs.
@@ -523,6 +523,29 @@ export interface RangoOptions<TEnv = any> {
523
523
  */
524
524
  warmup?: boolean;
525
525
 
526
+ /**
527
+ * Wrap the hydrated client tree in `React.StrictMode`.
528
+ *
529
+ * The Rango browser entry hydrates the app inside `<React.StrictMode>` by
530
+ * default. StrictMode double-invokes render and (in development) mounts,
531
+ * unmounts, then remounts every effect to surface impure renders and missing
532
+ * effect cleanup. Production builds treat StrictMode as a no-op, so this flag
533
+ * only changes development behavior in a normal app.
534
+ *
535
+ * Set to `false` to hydrate without the StrictMode wrapper. The main reason to
536
+ * opt out is to isolate StrictMode's intentional double-render/double-effect
537
+ * from genuine re-renders when measuring client-hook stability — with
538
+ * StrictMode off, render counts are exact in development too.
539
+ *
540
+ * The value is resolved server-side at router creation and shipped to the
541
+ * client in the initial payload metadata; the browser entry reads it once at
542
+ * hydration. Changing it does not affect the SSR HTML (StrictMode emits no
543
+ * DOM), so toggling it never causes a hydration mismatch.
544
+ *
545
+ * @default true
546
+ */
547
+ strictMode?: boolean;
548
+
526
549
  /**
527
550
  * Shorthand timeout (ms) applied to both action execution and render start.
528
551
  * Does NOT apply to streamIdleMs.
@@ -26,6 +26,7 @@ import {
26
26
  resolveLayoutComponent,
27
27
  resolveWithErrorBoundary,
28
28
  warnOnStreamedResponse,
29
+ buildLoaderErrorContext,
29
30
  } from "./helpers.js";
30
31
  import { applyViewTransitionDefault } from "./view-transition-default.js";
31
32
  import { getRouterContext } from "../router-context.js";
@@ -59,6 +60,13 @@ export async function resolveLoaders<TEnv>(
59
60
  const hasLoading = "loading" in entry && entry.loading !== undefined;
60
61
  const loadingDisabled = hasLoading && entry.loading === false;
61
62
 
63
+ // Error context for wrapLoaderPromise: without it, a throwing DSL loader never
64
+ // fires createRouter({ onError }) (phase "loader") nor emits the loader.error
65
+ // telemetry event — wrapLoaderPromise only builds the onError/telemetry path
66
+ // when errorContext is supplied. Built from ctx so the live render path reports
67
+ // loader failures the same way handlers/actions/routing/fetchable-loaders do.
68
+ const errorContext = buildLoaderErrorContext(ctx);
69
+
62
70
  if (!loadingDisabled) {
63
71
  // Streaming loaders: promises kick off now, settle during RSC serialization.
64
72
  const segments = loaderEntries.map((loaderEntry, i) => {
@@ -79,6 +87,7 @@ export async function resolveLoaders<TEnv>(
79
87
  entry,
80
88
  segmentId,
81
89
  ctx.pathname,
90
+ errorContext,
82
91
  ),
83
92
  belongsToRoute,
84
93
  };
@@ -107,6 +116,7 @@ export async function resolveLoaders<TEnv>(
107
116
  entry,
108
117
  segmentId,
109
118
  ctx.pathname,
119
+ errorContext,
110
120
  );
111
121
  return { wrapped, segmentId };
112
122
  });
@@ -19,7 +19,12 @@ import {
19
19
  import { getRequestContext } from "../../server/request-context.js";
20
20
  import { DefaultErrorFallback } from "../../default-error-boundary.js";
21
21
  import type { EntryData } from "../../server/context";
22
- import type { ResolvedSegment, ErrorInfo, HandlerContext } from "../../types";
22
+ import type {
23
+ ResolvedSegment,
24
+ ErrorInfo,
25
+ HandlerContext,
26
+ InternalHandlerContext,
27
+ } from "../../types";
23
28
  import type { SegmentResolutionDeps } from "../types.js";
24
29
  import { debugLog } from "../logging.js";
25
30
  import { tryStaticLookup } from "./static-store.js";
@@ -27,6 +32,35 @@ import { observeHandler } from "../instrument.js";
27
32
  import type { TelemetrySink } from "../telemetry.js";
28
33
  import { resolveSink, safeEmit, getRequestId } from "../telemetry.js";
29
34
 
35
+ /** The errorContext shape wrapLoaderPromise expects as its 5th argument. */
36
+ type LoaderErrorContext<TEnv> = NonNullable<
37
+ Parameters<SegmentResolutionDeps<TEnv>["wrapLoaderPromise"]>[4]
38
+ >;
39
+
40
+ /**
41
+ * Build the errorContext passed to wrapLoaderPromise so a throwing DSL loader
42
+ * fires createRouter({ onError }) (phase "loader") and emits the loader.error
43
+ * telemetry event. wrapLoaderPromise only wires the onError/telemetry path when
44
+ * this 5th argument is present; every real call site previously omitted it, so
45
+ * loaders were the one phase whose failures were silently dropped (handlers,
46
+ * actions, routing, rendering, and fetchable loaders all reported correctly).
47
+ *
48
+ * The fields come off the handler context, which already carries the request,
49
+ * url, params, env, and (on the internal shape) the matched route name.
50
+ */
51
+ export function buildLoaderErrorContext<TEnv>(
52
+ ctx: HandlerContext<any, TEnv>,
53
+ ): LoaderErrorContext<TEnv> {
54
+ const internal = ctx as InternalHandlerContext<any, TEnv>;
55
+ return {
56
+ request: ctx.request,
57
+ url: ctx.url,
58
+ routeKey: internal._routeName,
59
+ params: ctx.params as Record<string, string>,
60
+ env: ctx.env,
61
+ };
62
+ }
63
+
30
64
  // ---------------------------------------------------------------------------
31
65
  // Handler result processing
32
66
  // ---------------------------------------------------------------------------
@@ -147,12 +147,6 @@ export function resolveLoaderData<TEnv>(
147
147
 
148
148
  const loaderId = loaderEntry.loader.$$id;
149
149
 
150
- const ttl = resolveTtl(options.ttl, store.defaults, DEFAULT_ROUTE_TTL);
151
- const swrWindow = resolveSwrWindow(options.swr, store.defaults);
152
- const swr = swrWindow || undefined;
153
- const tags = resolveTags(loaderEntry);
154
- recordRequestTags(tags);
155
-
156
150
  // A handler that later awaits this same loader via ctx.use(loader) must get
157
151
  // THIS memoized promise, not a fresh execution. Rather than rebind ctx.use
158
152
  // once per cached loader (O(N) chained wrappers + a synchronous
@@ -188,6 +182,16 @@ export function resolveLoaderData<TEnv>(
188
182
  const existing = overrides.get(loaderId);
189
183
  if (existing) return existing;
190
184
 
185
+ // Compute ttl/swr/tags only AFTER the dedup short-circuit: a deduped second
186
+ // resolution of the same loaderId (the orphan-layout inheritance path) must
187
+ // not re-run the user tags() callback. These values are only consumed inside
188
+ // the read-through below, so they belong here, past the dedup gate.
189
+ const ttl = resolveTtl(options.ttl, store.defaults, DEFAULT_ROUTE_TTL);
190
+ const swrWindow = resolveSwrWindow(options.swr, store.defaults);
191
+ const swr = swrWindow || undefined;
192
+ const tags = resolveTags(loaderEntry);
193
+ recordRequestTags(tags);
194
+
191
195
  const dataPromise = (async () => {
192
196
  const codec = await getCodec();
193
197
  const key = await resolveLoaderKey(
@@ -40,6 +40,7 @@ import {
40
40
  tryStaticSlot,
41
41
  resolveLayoutComponent,
42
42
  resolveWithErrorBoundary,
43
+ buildLoaderErrorContext,
43
44
  } from "./helpers.js";
44
45
  import { applyViewTransitionDefault } from "./view-transition-default.js";
45
46
  import { getRouterContext } from "../router-context.js";
@@ -199,6 +200,10 @@ export async function resolveLoadersWithRevalidation<TEnv>(
199
200
  ),
200
201
  );
201
202
 
203
+ // Partial (revalidation) render path: a throwing DSL loader must still fire
204
+ // onError/loader.error. isPartial flags the reporting phase accordingly.
205
+ const errorContext = { ...buildLoaderErrorContext(ctx), isPartial: true };
206
+
202
207
  const loadersToRun = revalidationChecks.filter((c) => c.shouldRun);
203
208
  const segments: ResolvedSegment[] = loadersToRun.map(
204
209
  ({ loaderEntry, loader, segmentId, index }) => ({
@@ -216,6 +221,7 @@ export async function resolveLoadersWithRevalidation<TEnv>(
216
221
  entry,
217
222
  segmentId,
218
223
  ctx.pathname,
224
+ errorContext,
219
225
  ),
220
226
  belongsToRoute,
221
227
  }),
@@ -2,6 +2,7 @@
2
2
  export {
3
3
  handleHandlerResult,
4
4
  warnOnStreamedResponse,
5
+ buildLoaderErrorContext,
5
6
  } from "./segment-resolution/helpers.js";
6
7
  export {
7
8
  resolveLoaders,
@@ -5,7 +5,11 @@
5
5
  * Falls back to null when no match is found (caller uses regex fallback).
6
6
  */
7
7
 
8
- import type { TrieNode, TrieLeaf } from "../build/route-trie.js";
8
+ import type {
9
+ TrieNode,
10
+ TrieLeaf,
11
+ NegotiateVariant,
12
+ } from "../build/route-trie.js";
9
13
  import { safeDecodeURIComponent } from "./url-params.js";
10
14
 
11
15
  export interface TrieMatchResult {
@@ -24,7 +28,7 @@ export interface TrieMatchResult {
24
28
  /** Response type for non-RSC routes (json, text, image, any) */
25
29
  responseType?: string;
26
30
  /** Negotiate variants: response-type routes sharing this path */
27
- negotiateVariants?: Array<{ routeKey: string; responseType: string }>;
31
+ negotiateVariants?: NegotiateVariant[];
28
32
  /** RSC-first: RSC route was defined before response-type variants */
29
33
  rscFirst?: true;
30
34
  }
@@ -231,7 +235,13 @@ function walkTrie(
231
235
  }
232
236
  }
233
237
 
234
- if (node.p) {
238
+ // A required single-segment param captures 1+ chars (the regex matcher emits
239
+ // `([^/]+)`), so an empty path segment from a double slash (`/a//b`) must NOT
240
+ // bind `:s` to "". Reject it here so the trie matches the regex contract and
241
+ // a malformed URL 404s instead of running the handler with an empty param.
242
+ // The suffix-param branch above already requires `segment.length > suffix`,
243
+ // and node.w may legitimately be empty, so only this branch needs the guard.
244
+ if (node.p && segment !== "") {
235
245
  paramValues.push(segment);
236
246
  const result = walkTrie(node.p.c, segments, index + 1, paramValues);
237
247
  paramValues.pop();
@@ -256,12 +266,7 @@ function walkTrie(
256
266
 
257
267
  function joinRemainingSegments(segments: string[], start: number): string {
258
268
  if (start >= segments.length) return "";
259
-
260
- let rest = segments[start]!;
261
- for (let i = start + 1; i < segments.length; i++) {
262
- rest += "/" + segments[i]!;
263
- }
264
- return rest;
269
+ return segments.slice(start).join("/");
265
270
  }
266
271
 
267
272
  /**
package/src/router.ts CHANGED
@@ -110,6 +110,7 @@ import {
110
110
  renderStaticSegment as _renderStaticSegment,
111
111
  } from "./router/prerender-match.js";
112
112
  import { resolveStateCookieName } from "./router/state-cookie-name.js";
113
+ import { resolvePrefetchCacheTTL } from "./router/prefetch-cache-ttl.js";
113
114
 
114
115
  // Re-export public types and values from extracted modules
115
116
  export { RSC_ROUTER_BRAND, RouterRegistry } from "./router/router-registry.js";
@@ -161,6 +162,7 @@ export function createRouter<TEnv = any>(
161
162
  originCheck: originCheckOption,
162
163
  viewTransition: viewTransitionOption = "auto",
163
164
  debugCacheSignal: debugCacheSignalOption = false,
165
+ strictMode: strictModeOption = true,
164
166
  } = options;
165
167
 
166
168
  // Debug cache signal gate (DEVELOPMENT/TEST ONLY). Enabled by the
@@ -230,21 +232,24 @@ export function createRouter<TEnv = any>(
230
232
  routerId,
231
233
  );
232
234
 
233
- // Resolve prefetch cache TTL (default: 300 seconds / 5 minutes)
234
- // Clamp to a non-negative integer for valid Cache-Control max-age.
235
- const rawTTL =
236
- prefetchCacheTTLOption !== undefined ? prefetchCacheTTLOption : 300;
237
- const prefetchCacheTTLSeconds =
238
- rawTTL === false ? 0 : Math.max(0, Math.floor(rawTTL));
239
- const prefetchCacheTTL = prefetchCacheTTLSeconds * 1000;
235
+ // Resolve prefetch cache TTL (default: 300 seconds / 5 minutes). Clamps to a
236
+ // non-negative integer and guards non-finite (NaN/Infinity) inputs so a
237
+ // malformed `Cache-Control: max-age=NaN` can never reach the wire.
238
+ const resolvedPrefetchCacheTTL = resolvePrefetchCacheTTL(
239
+ prefetchCacheTTLOption,
240
+ );
241
+ const prefetchCacheTTL = resolvedPrefetchCacheTTL.ms;
240
242
  const prefetchCacheControl: string | false =
241
- prefetchCacheTTLSeconds === 0
242
- ? false
243
- : `private, max-age=${prefetchCacheTTLSeconds}`;
243
+ resolvedPrefetchCacheTTL.cacheControl;
244
244
 
245
245
  // Resolve warmup enabled flag (default: true)
246
246
  const warmupEnabled = warmupOption !== false;
247
247
 
248
+ // Resolve StrictMode flag (default: true). Shipped to the client in payload
249
+ // metadata; the browser entry reads it once to decide whether to wrap the
250
+ // hydrated tree in React.StrictMode.
251
+ const strictMode = strictModeOption !== false;
252
+
248
253
  // Resolve theme config (null if theme not enabled)
249
254
  const resolvedThemeConfig = themeOption
250
255
  ? resolveThemeConfig(themeOption)
@@ -971,6 +976,9 @@ export function createRouter<TEnv = any>(
971
976
  // Expose warmup enabled flag for handler and client
972
977
  warmupEnabled,
973
978
 
979
+ // Expose StrictMode flag for the initial-render payload metadata
980
+ strictMode,
981
+
974
982
  // Expose router-wide performance debugging for request-level metrics setup
975
983
  debugPerformance,
976
984
 
@@ -13,7 +13,6 @@ import { matchMiddleware, executeMiddleware } from "../router/middleware.js";
13
13
  import {
14
14
  runWithRequestContext,
15
15
  setRequestContextParams,
16
- requireRequestContext,
17
16
  getRequestContext,
18
17
  _getRequestContext,
19
18
  createRequestContext,
@@ -32,7 +31,10 @@ import {
32
31
  buildRouteMiddlewareEntries,
33
32
  } from "./helpers.js";
34
33
  import { guardOutgoingRedirect } from "./redirect-guard.js";
35
- import { isWebSocketUpgradeResponse } from "../response-utils.js";
34
+ import {
35
+ isWebSocketUpgradeResponse,
36
+ appendVaryAccept,
37
+ } from "../response-utils.js";
36
38
  import {
37
39
  handleResponseRoute,
38
40
  type ResponseRouteMatch,
@@ -294,7 +296,17 @@ export function createRSCHandler<
294
296
  ...(locationState && { locationState }),
295
297
  },
296
298
  };
297
- const rscStream = renderToReadableStream<RscPayload>(redirectPayload);
299
+ const rscStream = renderToReadableStream<RscPayload>(redirectPayload, {
300
+ onError: (error: unknown) => {
301
+ const reqCtx = _getRequestContext<TEnv>();
302
+ if (!reqCtx) return;
303
+ callOnError(error, "rendering", {
304
+ request: reqCtx.request,
305
+ url: reqCtx.url,
306
+ env: reqCtx.env,
307
+ });
308
+ },
309
+ });
298
310
  return createResponseWithMergedHeaders(rscStream, {
299
311
  status: 200,
300
312
  headers: { "content-type": "text/x-component;charset=utf-8" },
@@ -762,11 +774,11 @@ export function createRSCHandler<
762
774
  nonce: string | undefined,
763
775
  ): Promise<Response> {
764
776
  // Common setup
765
- const handleStore = requireRequestContext()._handleStore;
777
+ const handleStore = getRequestContext()._handleStore;
766
778
 
767
779
  // Wire up error reporting for late streaming-handle failures
768
780
  handleStore.onError = (error: Error) => {
769
- const reqCtx = requireRequestContext();
781
+ const reqCtx = getRequestContext();
770
782
  callOnError(error, "handler", {
771
783
  request,
772
784
  url,
@@ -790,7 +802,7 @@ export function createRSCHandler<
790
802
  // instead of calling resolveRoute again.
791
803
  if (plan.mode !== "redirect") {
792
804
  setRequestContextParams(plan.route.params, plan.route.routeKey);
793
- requireRequestContext()._classifiedRoute = plan.route;
805
+ getRequestContext()._classifiedRoute = plan.route;
794
806
  }
795
807
 
796
808
  const routeReverse = createReverseFunction(getRequiredRouteMap());
@@ -831,7 +843,9 @@ export function createRSCHandler<
831
843
  }
832
844
  const response = responseOutcome.result;
833
845
  if (plan.negotiated && !isWebSocketUpgradeResponse(response)) {
834
- response.headers.append("Vary", "Accept");
846
+ // handleResponseRoute (callHandlerWithVary) already appends Vary: Accept
847
+ // for negotiated responses; dedup so we don't emit Vary: Accept, Accept.
848
+ appendVaryAccept(response);
835
849
  }
836
850
  return response;
837
851
  }
@@ -839,14 +853,28 @@ export function createRSCHandler<
839
853
  // SSR setup: kick off in parallel for modes that need HTML rendering.
840
854
  // Placed after response-route short-circuit so response/mime routes
841
855
  // never pay for SSR work.
842
- if (plan.mode !== "loader" && mayNeedSSR(request, url)) {
856
+ //
857
+ // Only kick off when the request will actually render HTML, so the
858
+ // eager loadSSRModule() + user resolveStreaming() are not started (and
859
+ // never consumed) for a request that returns an RSC stream — that wasted
860
+ // work also leaves an orphaned Promise.all that can reject (D7). PE form
861
+ // submissions always render HTML (handleProgressiveEnhancement renders via
862
+ // getSSRSetup regardless of Accept). For full/partial-render and action,
863
+ // the render-time HTML decision is exactly !isRscRequest — mayNeedSSR is
864
+ // the coarse transport pre-filter, isRscRequest is the precise Accept call
865
+ // (it, unlike mayNeedSSR, treats a MISSING Accept as RSC). Both must pass.
866
+ const willRenderHtml =
867
+ plan.mode === "pe-render" ||
868
+ (mayNeedSSR(request, url) &&
869
+ !isRscRequest(request, url, plan.mode === "partial-render"));
870
+ if (plan.mode !== "loader" && willRenderHtml) {
843
871
  variables[SSR_SETUP_VAR] = startSSRSetup(
844
872
  handlerCtx,
845
873
  request,
846
874
  env,
847
875
  url,
848
876
  router.debugPerformance
849
- ? () => requireRequestContext()._metricsStore
877
+ ? () => getRequestContext()._metricsStore
850
878
  : undefined,
851
879
  );
852
880
  }
@@ -989,7 +1017,7 @@ export function createRSCHandler<
989
1017
  url: URL,
990
1018
  variables: Record<string, any>,
991
1019
  nonce: string | undefined,
992
- handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
1020
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
993
1021
  isPartial: boolean,
994
1022
  actionContinuation?: ActionContinuation,
995
1023
  ): Promise<Response> {
@@ -1017,7 +1045,9 @@ export function createRSCHandler<
1017
1045
  );
1018
1046
  }
1019
1047
  if (negotiated && !isWebSocketUpgradeResponse(response)) {
1020
- response.headers.append("Vary", "Accept");
1048
+ // handleRscRendering bakes `accept` into the RSC response's Vary list;
1049
+ // dedup so the negotiated append does not list accept twice.
1050
+ appendVaryAccept(response);
1021
1051
  }
1022
1052
  return response;
1023
1053
  } catch (error) {
@@ -1092,14 +1122,23 @@ export function createRSCHandler<
1092
1122
  segments: [notFoundSegment],
1093
1123
  matched: [],
1094
1124
  diff: [],
1125
+ // Shape parity with buildFullPayload (rsc-rendering.ts): the 404
1126
+ // payload carries params/resolvedIds/prefetchCacheTTL the same way a
1127
+ // matched full render does. resolvedIds mirrors the rendered segment
1128
+ // list (the single notFound segment) like the error-boundary path
1129
+ // (match-api.ts) does for its boundary segment.
1130
+ resolvedIds: [notFoundSegment.id],
1131
+ params: {},
1095
1132
  isPartial: false,
1096
1133
  rootLayout: router.rootLayout,
1097
1134
  handles: handleStore.stream(),
1098
1135
  version,
1136
+ prefetchCacheTTL: router.prefetchCacheTTL,
1099
1137
  stateCookieName: router.resolvedStateCookieName,
1100
1138
  themeConfig: router.themeConfig,
1101
1139
  warmupEnabled: router.warmupEnabled,
1102
- initialTheme: requireRequestContext().theme,
1140
+ strictMode: router.strictMode,
1141
+ initialTheme: getRequestContext().theme,
1103
1142
  },
1104
1143
  };
1105
1144
 
@@ -1126,7 +1165,7 @@ export function createRSCHandler<
1126
1165
  request,
1127
1166
  env,
1128
1167
  url,
1129
- requireRequestContext()._metricsStore,
1168
+ getRequestContext()._metricsStore,
1130
1169
  );
1131
1170
  const htmlStream = await ssrModule.renderHTML(rscStream, {
1132
1171
  nonce,
@@ -65,8 +65,14 @@ function applyStubHeaders(target: Headers, stub: Headers): void {
65
65
  * Drain ctx._onResponseCallbacks onto a response. Swapping the array before
66
66
  * iteration prevents re-entrant registrations from double-firing and matches
67
67
  * the contract that each callback runs at most once per request.
68
+ *
69
+ * Exported so the testing primitives' buildRunResponse can reuse the exact
70
+ * production drain (swap-before-iterate + external-redirect brand preservation)
71
+ * rather than maintain a hand-synced copy. This module is plugin-rsc-free on its
72
+ * eager graph (dispatch.ts already statically imports from here in the same
73
+ * testing barrel), so importing it adds nothing to the unit runner's graph.
68
74
  */
69
- function drainOnResponseCallbacks(
75
+ export function drainOnResponseCallbacks(
70
76
  ctx: RequestContext,
71
77
  response: Response,
72
78
  ): Response {
package/src/rsc/index.ts CHANGED
@@ -30,7 +30,4 @@ export type {
30
30
  } from "./types.js";
31
31
 
32
32
  // Re-export request context utilities for server-side access to env/request/params
33
- export {
34
- getRequestContext,
35
- requireRequestContext,
36
- } from "../server/request-context.js";
33
+ export { getRequestContext } from "../server/request-context.js";