@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
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import {
10
- requireRequestContext,
10
+ getRequestContext,
11
11
  setRequestContextParams,
12
12
  } from "../server/request-context.js";
13
13
  import { appendMetric } from "../router/metrics.js";
@@ -28,7 +28,7 @@ export function handleRscRendering<TEnv>(
28
28
  env: TEnv,
29
29
  url: URL,
30
30
  isPartial: boolean,
31
- handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
31
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
32
32
  nonce: string | undefined,
33
33
  ): Promise<Response> {
34
34
  // Instrument the whole render phase once through the unified API: it records
@@ -55,10 +55,10 @@ async function handleRscRenderingInner<TEnv>(
55
55
  env: TEnv,
56
56
  url: URL,
57
57
  isPartial: boolean,
58
- handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
58
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
59
59
  nonce: string | undefined,
60
60
  ): Promise<Response> {
61
- const reqCtx = requireRequestContext();
61
+ const reqCtx = getRequestContext();
62
62
 
63
63
  let payload: RscPayload;
64
64
  let hasInterceptSlots = false;
@@ -82,6 +82,15 @@ async function handleRscRenderingInner<TEnv>(
82
82
  prefetchCacheTTL: ctx.router.prefetchCacheTTL,
83
83
  stateCookieName: ctx.router.resolvedStateCookieName,
84
84
  themeConfig: ctx.router.themeConfig,
85
+ // Carry warmupEnabled on the initial full-render payload so the client
86
+ // respects warmup:false from first load. The 404 and PE payloads already
87
+ // include it; without it here warmup could never be disabled on the
88
+ // normal full-load path (partial payloads omit it by design).
89
+ warmupEnabled: ctx.router.warmupEnabled,
90
+ // Carry strictMode on the initial full-render payload so the browser
91
+ // entry knows whether to wrap hydration in React.StrictMode. Partial
92
+ // (navigation) payloads omit it by design; StrictMode is decided once.
93
+ strictMode: ctx.router.strictMode,
85
94
  initialTheme: reqCtx.theme,
86
95
  },
87
96
  });
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import {
19
- requireRequestContext,
19
+ getRequestContext,
20
20
  setRequestContextParams,
21
21
  } from "../server/request-context.js";
22
22
  import { appendMetric } from "../router/metrics.js";
@@ -74,7 +74,7 @@ export async function executeServerAction<TEnv>(
74
74
  env: TEnv,
75
75
  url: URL,
76
76
  actionId: string,
77
- handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
77
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
78
78
  ): Promise<Response | ActionContinuation> {
79
79
  const temporaryReferences = ctx.createTemporaryReferenceSet();
80
80
 
@@ -115,9 +115,55 @@ export async function executeServerAction<TEnv>(
115
115
  // Keep the original error as `cause` for server-side logging, but do not
116
116
  // interpolate it into the message: that string can surface to the client
117
117
  // and may leak decode internals.
118
- throw new Error("Failed to decode action arguments", {
118
+ const decodeError = new Error("Failed to decode action arguments", {
119
119
  cause: error,
120
120
  });
121
+
122
+ // Produce a router-controlled response instead of re-throwing into the
123
+ // host (which would surface as an opaque 500). This mirrors the no-JS PE
124
+ // path, where a malformed form body renders the route error boundary or
125
+ // returns an explicit 400 (progressive-enhancement.ts). Attempt boundary
126
+ // rendering first; if a boundary matches, defer the render to the
127
+ // revalidation phase (errorBoundary continuation) so it runs inside route
128
+ // middleware, identical to the action-threw path below. Otherwise return a
129
+ // plain 400 — the JS and no-JS paths now converge on the same outcome.
130
+ let decodeBoundary: MatchResult | undefined;
131
+ try {
132
+ decodeBoundary =
133
+ (await ctx.router.matchError(request, { env }, decodeError, "route")) ??
134
+ undefined;
135
+ } catch {
136
+ // matchError itself failed — fall through to the plain 400 below.
137
+ decodeBoundary = undefined;
138
+ }
139
+
140
+ ctx.callOnError(decodeError, "action", {
141
+ request,
142
+ url,
143
+ env,
144
+ actionId,
145
+ handledByBoundary: !!decodeBoundary,
146
+ });
147
+
148
+ if (decodeBoundary) {
149
+ return {
150
+ returnValue: { ok: false, data: decodeError },
151
+ // 400: malformed action request, matching the PE explicit-400 status
152
+ // class (the action-threw path uses 500; a decode failure is a bad
153
+ // request, not an action runtime error).
154
+ actionStatus: 400,
155
+ temporaryReferences,
156
+ actionContext: {
157
+ actionId,
158
+ actionUrl: new URL(url),
159
+ actionResult: decodeError,
160
+ formData: actionFormData,
161
+ },
162
+ errorBoundary: decodeBoundary,
163
+ };
164
+ }
165
+
166
+ return createResponseWithMergedHeaders(null, { status: 400 });
121
167
  }
122
168
 
123
169
  // Execute the server action
@@ -279,7 +325,7 @@ export function revalidateAfterAction<TEnv>(
279
325
  request: Request,
280
326
  env: TEnv,
281
327
  url: URL,
282
- handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
328
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
283
329
  continuation: ActionContinuation,
284
330
  ): Promise<Response> {
285
331
  // Instrument the action-revalidation render through the unified phase API,
@@ -304,7 +350,7 @@ async function revalidateAfterActionInner<TEnv>(
304
350
  request: Request,
305
351
  env: TEnv,
306
352
  url: URL,
307
- handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
353
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
308
354
  continuation: ActionContinuation,
309
355
  ): Promise<Response> {
310
356
  const {
@@ -314,7 +360,7 @@ async function revalidateAfterActionInner<TEnv>(
314
360
  actionContext,
315
361
  errorBoundary,
316
362
  } = continuation;
317
- const reqCtx = requireRequestContext();
363
+ const reqCtx = getRequestContext();
318
364
  const metricsStore = reqCtx._metricsStore;
319
365
 
320
366
  // Action threw and a boundary matched: render the (already-matched) error
package/src/rsc/types.ts CHANGED
@@ -53,6 +53,13 @@ export interface RscPayload {
53
53
  basename?: string;
54
54
  /** Whether connection warmup is enabled */
55
55
  warmupEnabled?: boolean;
56
+ /**
57
+ * Whether the client should hydrate inside React.StrictMode. Carried on
58
+ * the initial full-render payload only; the browser entry reads it once at
59
+ * hydration. Absent on partial (navigation) payloads. Defaults to true on
60
+ * the client when omitted.
61
+ */
62
+ strictMode?: boolean;
56
63
  /**
57
64
  * Server-side redirect with optional state (for partial requests).
58
65
  * `external: true` (from redirect(url, { external: true })) tells the client
@@ -9,6 +9,15 @@
9
9
 
10
10
  import { encodeKV } from "./encode-kv.js";
11
11
 
12
+ /**
13
+ * Decimal-number grammar for `"number"` search params: optional sign, digits
14
+ * with optional fraction, optional exponent. Deliberately excludes hex (`0x`),
15
+ * `Infinity`, and empty/whitespace so `Number()`'s lenient coercions
16
+ * (`Number("")===0`, `Number("0x10")===16`, `Number("Infinity")===Infinity`)
17
+ * do not slip non-decimal values into typed search.
18
+ */
19
+ const DECIMAL_NUMBER_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
20
+
12
21
  /** Supported scalar types for search params (append ? for optional). */
13
22
  export type SearchSchemaValue =
14
23
  | "string"
@@ -161,7 +170,11 @@ type ExtractParamsFromPattern<T extends string> =
161
170
  * Parse URLSearchParams into a typed object using the given schema.
162
171
  *
163
172
  * - `"string"` / `"string?"` - kept as-is
164
- * - `"number"` / `"number?"` - coerced via `Number()`; NaN treated as missing
173
+ * - `"number"` / `"number?"` - parsed as a finite decimal number. Accepts an
174
+ * optional sign, digits with optional fraction, and optional exponent
175
+ * (e.g. `42`, `-3.5`, `1e3`). Empty/whitespace-only, non-decimal forms
176
+ * (`0x10`), and non-finite (`Infinity`) are treated as missing (omitted),
177
+ * NOT coerced to `0`/`16`/`Infinity`.
165
178
  * - `"boolean"` / `"boolean?"` - `"true"` / `"1"` -> true, `"false"` / `"0"` / `""` -> false
166
179
  *
167
180
  * Missing params (both required and optional) are omitted from the result
@@ -187,11 +200,17 @@ export function parseSearchParams<T extends SearchSchema>(
187
200
  if (baseType === "string") {
188
201
  result[key] = raw;
189
202
  } else if (baseType === "number") {
190
- const num = Number(raw);
191
- if (!Number.isNaN(num)) {
192
- result[key] = num;
203
+ // Trim, then require a valid decimal numeral and a finite result.
204
+ // Empty/whitespace, hex (0x10), and Infinity are treated as missing
205
+ // (omitted) — not coerced to 0/16/Infinity by Number()'s lenient rules.
206
+ const trimmed = raw.trim();
207
+ if (trimmed !== "" && DECIMAL_NUMBER_RE.test(trimmed)) {
208
+ const num = Number(trimmed);
209
+ if (Number.isFinite(num)) {
210
+ result[key] = num;
211
+ }
193
212
  }
194
- // NaN treated as missing (undefined)
213
+ // Anything else treated as missing (undefined)
195
214
  } else if (baseType === "boolean") {
196
215
  result[key] = raw === "true" || raw === "1";
197
216
  }
@@ -7,8 +7,9 @@ import type { ResolvedSegment } from "./types.js";
7
7
  * source array (typically a single entry, since distinct loader groups rarely
8
8
  * share a first source). Object first-refs live in a WeakMap (auto-GC);
9
9
  * primitive first-refs (strings/numbers/booleans/null) live in a Map so
10
- * loaders that resolve to primitive data are memoized too bounded in
11
- * practice by the application's loader set.
10
+ * loaders that resolve to primitive data are memoized too. The per-key array
11
+ * is capped (MAX_ENTRIES_PER_KEY, oldest evicted) so a long session under a
12
+ * stable first-ref does not grow it without bound.
12
13
  *
13
14
  * Keying externally means reconciliation's fresh segment objects no longer
14
15
  * drop memoization — the cache survives as long as the underlying loader
@@ -32,6 +33,16 @@ interface LoaderCacheEntry {
32
33
  promise: Promise<any[]>;
33
34
  }
34
35
 
36
+ // Cap the per-key entries array. A stable first-ref (e.g. a layout loader whose
37
+ // loaderData object survives reconciliation across navigations) keeps its
38
+ // WeakMap/Map key alive, while a per-route loader whose ref changes each
39
+ // navigation appends a brand-new sources array under that same live key on
40
+ // every navigation. Nothing was ever removed, so the array grew linearly with
41
+ // navigation count, pinning each stale Promise + sources array from GC — a
42
+ // steady client-side leak over a long session. Only the current render's combo
43
+ // needs to stay warm; evict the oldest beyond the cap.
44
+ const MAX_ENTRIES_PER_KEY = 8;
45
+
35
46
  const objectLoaderCache = IS_BROWSER
36
47
  ? new WeakMap<object, LoaderCacheEntry[]>()
37
48
  : null;
@@ -124,6 +135,10 @@ export function getMemoizedLoaderPromise(
124
135
  const promise = buildLoaderPromise(loaders);
125
136
  const newEntry: LoaderCacheEntry = { sources, promise };
126
137
  if (entries) {
138
+ // Bound the array: drop the oldest entry before appending when at the cap.
139
+ if (entries.length >= MAX_ENTRIES_PER_KEY) {
140
+ entries.shift();
141
+ }
127
142
  entries.push(newEntry);
128
143
  } else if (isObjectLike(first)) {
129
144
  objectLoaderCache.set(first, [newEntry]);
@@ -53,16 +53,16 @@ export async function getLoaderLazy(
53
53
  if (lazyLoaderImports && lazyLoaderImports.size > 0) {
54
54
  const lazyImport = lazyLoaderImports.get(id);
55
55
  if (lazyImport) {
56
- try {
57
- await lazyImport();
56
+ // A failed import is a real server breakage (broken transitive import,
57
+ // syntax error, throw in module top-level code), not a "loader not
58
+ // registered" case. Rethrow so the caller can return a 500 and route
59
+ // the failure through onError, instead of collapsing it to a 404.
60
+ await lazyImport();
58
61
 
59
- const registered = getFetchableLoader(id);
60
- if (registered) {
61
- loaderRegistry.set(id, registered);
62
- return registered;
63
- }
64
- } catch (error) {
65
- console.error(`[LoaderRegistry] Failed to load loader "${id}":`, error);
62
+ const registered = getFetchableLoader(id);
63
+ if (registered) {
64
+ loaderRegistry.set(id, registered);
65
+ return registered;
66
66
  }
67
67
  }
68
68
  }
@@ -72,16 +72,14 @@ export async function getLoaderLazy(
72
72
  if (hashIndex !== -1) {
73
73
  const filePath = id.slice(0, hashIndex);
74
74
 
75
- try {
76
- await import(/* @vite-ignore */ `/${filePath}`);
75
+ // Same as the lazy branch: a thrown import is a server error, not a
76
+ // not-found. Let it propagate to the caller for a 500 + onError.
77
+ await import(/* @vite-ignore */ `/${filePath}`);
77
78
 
78
- const registered = getFetchableLoader(id);
79
- if (registered) {
80
- loaderRegistry.set(id, registered);
81
- return registered;
82
- }
83
- } catch (error) {
84
- console.error(`[LoaderRegistry] Failed to load loader "${id}":`, error);
79
+ const registered = getFetchableLoader(id);
80
+ if (registered) {
81
+ loaderRegistry.set(id, registered);
82
+ return registered;
85
83
  }
86
84
  }
87
85
 
@@ -50,7 +50,11 @@ import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
50
50
  import type { ExecutionContext, RequestScope } from "../types/request-scope.js";
51
51
  import type { ResolvedTracing } from "../router/tracing.js";
52
52
  import { fireAndForgetWaitUntil } from "../types/request-scope.js";
53
- import { THEME_COOKIE } from "../theme/constants.js";
53
+ import {
54
+ THEME_COOKIE,
55
+ isValidTheme,
56
+ warnInvalidTheme,
57
+ } from "../theme/constants.js";
54
58
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
55
59
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
56
60
  import { isInsideCacheScope } from "./context.js";
@@ -58,7 +62,12 @@ import {
58
62
  createReverseFunction,
59
63
  stripInternalParams,
60
64
  } from "../router/handler-context.js";
61
- import { getGlobalRouteMap, isRouteRootScoped } from "../route-map-builder.js";
65
+ import {
66
+ getGlobalRouteMap,
67
+ isRouteRootScoped,
68
+ getSearchSchema,
69
+ } from "../route-map-builder.js";
70
+ import { parseSearchParams } from "../search-params.js";
62
71
  import { invariant } from "../errors.js";
63
72
  import { isAutoGeneratedRouteName } from "../route-name.js";
64
73
 
@@ -536,16 +545,6 @@ export function getLocationState(): LocationStateEntry[] | undefined {
536
545
  return ctx?._locationState;
537
546
  }
538
547
 
539
- /**
540
- * Get the current request context, throwing if not available
541
- * @deprecated Use getRequestContext() directly — it now throws if outside context
542
- */
543
- export function requireRequestContext<
544
- TEnv = DefaultEnv,
545
- >(): RequestContext<TEnv> {
546
- return getRequestContext<TEnv>();
547
- }
548
-
549
548
  export type { ExecutionContext };
550
549
 
551
550
  /**
@@ -676,10 +675,12 @@ export function createRequestContext<TEnv>(
676
675
  const setTheme = (theme: Theme): void => {
677
676
  if (!themeConfig) return;
678
677
 
679
- if (theme !== "system" && !themeConfig.themes.includes(theme)) {
680
- console.warn(
681
- `[Theme] Invalid theme value: "${theme}". Valid values: system, ${themeConfig.themes.join(", ")}`,
682
- );
678
+ // Shared guard (isValidTheme): reject any value not in the configured theme
679
+ // set, AND reject "system" when system detection is off — a cookie of
680
+ // theme=system with enableSystem:false would re-apply a bogus class="system"
681
+ // on the next SSR.
682
+ if (!isValidTheme(theme, themeConfig)) {
683
+ warnInvalidTheme(theme, themeConfig);
683
684
  return;
684
685
  }
685
686
 
@@ -848,7 +849,11 @@ export function createRequestContext<TEnv>(
848
849
 
849
850
  waitUntil(fn: () => Promise<void>): void {
850
851
  if (executionContext?.waitUntil) {
851
- executionContext.waitUntil(fn());
852
+ // Wrap in Promise.resolve().then(fn) so a SYNCHRONOUS throw in a
853
+ // non-async callback becomes a rejected promise handed to the host's
854
+ // waitUntil (logged as a background failure), instead of escaping into
855
+ // the request flow. Mirrors fireAndForgetWaitUntil's deferral.
856
+ executionContext.waitUntil(Promise.resolve().then(fn));
852
857
  } else {
853
858
  fireAndForgetWaitUntil(fn);
854
859
  }
@@ -952,7 +957,17 @@ export function createRequestContext<TEnv>(
952
957
  return ctx;
953
958
  }
954
959
 
955
- const MAX_AGE_ZERO_RE = /;\s*Max-Age\s*=\s*0/i;
960
+ // Capture the Max-Age value so it can be parsed numerically. A leading zero
961
+ // (Max-Age=05) is a non-zero lifetime, not a deletion; only a value that parses
962
+ // to <= 0 marks a cookie for deletion. Pattern-matching a leading "0" misread
963
+ // zero-prefixed values like 05 / 010 as deletions.
964
+ const MAX_AGE_RE = /;\s*Max-Age\s*=\s*(-?\d+)/i;
965
+
966
+ function isCookieDeletion(header: string): boolean {
967
+ const m = MAX_AGE_RE.exec(header);
968
+ if (!m) return false;
969
+ return Number(m[1]) <= 0;
970
+ }
956
971
 
957
972
  function parseResponseCookies(response: Response): Map<string, string | null> {
958
973
  const result = new Map<string, string | null>();
@@ -973,7 +988,7 @@ function parseResponseCookies(response: Response): Map<string, string | null> {
973
988
  continue;
974
989
  }
975
990
 
976
- const isDeleted = MAX_AGE_ZERO_RE.test(header);
991
+ const isDeleted = isCookieDeletion(header);
977
992
  result.set(name, isDeleted ? null : value);
978
993
  }
979
994
 
@@ -1064,12 +1079,24 @@ export function createUseFunction<TEnv>(
1064
1079
 
1065
1080
  const ctx = getContext();
1066
1081
 
1082
+ // Build the typed ctx.search the same way the render path
1083
+ // (createHandlerContext) and the fetchable-loader path (loader-fetch.ts) do:
1084
+ // parse the route's search schema over the cleaned searchParams. The base
1085
+ // RequestContext carries no `search` field, so reading `(ctx as any).search`
1086
+ // here always yielded {} — dropping typed search for action/dispatch loaders.
1087
+ const searchSchema = ctx._routeName
1088
+ ? getSearchSchema(ctx._routeName)
1089
+ : undefined;
1090
+ const loaderSearch = searchSchema
1091
+ ? parseSearchParams(ctx.searchParams, searchSchema)
1092
+ : {};
1093
+
1067
1094
  const loaderCtx: LoaderContext<Record<string, string | undefined>, TEnv> = {
1068
1095
  params: ctx.params,
1069
1096
  routeParams: (ctx.params ?? {}) as Record<string, string>,
1070
1097
  request: ctx.request,
1071
1098
  searchParams: ctx.searchParams,
1072
- search: (ctx as any).search ?? {},
1099
+ search: loaderSearch,
1073
1100
  pathname: ctx.pathname,
1074
1101
  url: ctx.url,
1075
1102
  originalUrl: ctx.originalUrl,
@@ -27,6 +27,11 @@
27
27
  * status, matching handleResponseRoute (RFC 9457 problem+json body with
28
28
  * application/problem+json for json routes, text/plain message otherwise)
29
29
  * - content-negotiated route: Vary: Accept appended
30
+ * - cached response route (cache({...})): getResponse/putResponse
31
+ * hit/SWR/tag write, resolved from the matched entry tree exactly as
32
+ * handleResponseRoute does. The write is scheduled via ctx.waitUntil
33
+ * (a microtask without an executionContext), so a HIT-asserting test must
34
+ * flush microtasks between the seeding dispatch and the asserting one.
30
35
  * - Global middleware (router.use(...)) AND route-level middleware, with full
31
36
  * next()/short-circuit/throw-Response/header+cookie-merge fidelity.
32
37
  * - Partial (client-navigation) requests to a RESPONSE route (?_rsc_partial):
@@ -41,6 +46,15 @@
41
46
  * redirects: a cross-origin Location is rewritten to the basename root unless
42
47
  * redirect(url, { external: true }) opted out, mirroring production's single
43
48
  * handler chokepoint. Soft partial/action redirects are 204 and pass through.
49
+ * - createRouter({ onError }) for CACHE / background-error degradation. dispatch
50
+ * wires the request context's _reportBackgroundError to the router's onError the
51
+ * same way the production RSC handler does, so a cache-read/cache-write/
52
+ * stale-revalidation failure on a cached response route (a throwing route
53
+ * cache({ key })/cache({ tags }), or a custom store whose keyGenerator/
54
+ * getResponse/putResponse throws) fires onError with phase "cache" and
55
+ * metadata.category, while the request still degrades-to-miss exactly as before.
56
+ * (A thrown response-route HANDLER error is the one onError path NOT covered —
57
+ * see "DOES NOT support" below.)
44
58
  *
45
59
  * What dispatch DOES NOT support (and why):
46
60
  * - RSC component routes — rendering requires the Flight serializer + React
@@ -48,10 +62,11 @@
48
62
  * This includes partial requests that resolve to a component route.
49
63
  * - Server actions (?_rsc_action) — RSC protocol concerns handled by
50
64
  * router.fetch().
51
- * - ctx.onError() callbacks on a thrown response-route handler error: the
65
+ * - createRouter({ onError }) on a thrown response-route HANDLER error: the
52
66
  * error is serialized into the same typed 500 / RouterError Response as
53
- * production, but registered onError handlers are NOT invoked here. Cover
54
- * onError side effects with an e2e test.
67
+ * production, but onError is NOT invoked for that path here. Cover handler-error
68
+ * onError side effects with an e2e test. (This is the unchanged boundary; cache
69
+ * and other background errors DO route through onError — see below.)
55
70
  * - Location-state-carrying redirects on a partial/action request: production
56
71
  * embeds a Flight payload (createRedirectFlightResponse) so the client can
57
72
  * restore location state across the redirect. dispatch is RSC-free, so it
@@ -84,6 +99,12 @@ import {
84
99
  import { NOCACHE_SYMBOL } from "../cache/taint.js";
85
100
  import type { SegmentCacheStore } from "../cache/types.js";
86
101
  import type { CacheProfile } from "../cache/profile-registry.js";
102
+ // cache-scope is loaded LAZILY inside the response-route cache path (below):
103
+ // its module graph pulls @vitejs/plugin-rsc/rsc (via segment-codec), which the
104
+ // non-Vite unit-test runner cannot resolve. A static import here would drag that
105
+ // onto the whole testing barrel's eager graph and break every consumer suite
106
+ // that imports `@rangojs/router/testing` without mocking plugin-rsc.
107
+ import type { EntryData } from "../server/context.js";
87
108
  import { setRouterManifest } from "../route-map-builder.js";
88
109
  import { RESPONSE_TYPE_MIME } from "../router/content-negotiation.js";
89
110
  import { RouterError } from "../errors.js";
@@ -103,6 +124,8 @@ import {
103
124
  markExternalRedirect,
104
125
  } from "../redirect-origin.js";
105
126
  import { isWebSocketUpgradeResponse } from "../response-utils.js";
127
+ import { invokeOnError } from "../router/error-handling.js";
128
+ import type { OnErrorCallback } from "../types/error-types.js";
106
129
  import type { Rango } from "../router/router-interfaces.js";
107
130
 
108
131
  /**
@@ -116,6 +139,7 @@ interface DispatchableRouter<TEnv> {
116
139
  routerId?: string;
117
140
  routeMap: Record<string, unknown>;
118
141
  middleware: MiddlewareEntry<TEnv>[];
142
+ onError?: OnErrorCallback<TEnv>;
119
143
  findMatch(pathname: string): {
120
144
  redirectTo?: string;
121
145
  routeKey?: string;
@@ -134,6 +158,7 @@ interface DispatchableRouter<TEnv> {
134
158
  params?: Record<string, string>;
135
159
  routeKey?: string;
136
160
  negotiated?: boolean;
161
+ manifestEntry?: EntryData;
137
162
  } | null>;
138
163
  basename?: string;
139
164
  cache?:
@@ -381,6 +406,22 @@ export async function dispatch<TEnv = any>(
381
406
  cacheStore,
382
407
  cacheProfiles: router.cacheProfiles,
383
408
  });
409
+ // Wire background error reporting so cache degradation (reportCacheError ->
410
+ // _reportBackgroundError) reaches the router's onError, mirroring the production
411
+ // RSC handler (rsc/handler.ts). Without this, dispatch could not observe onError.
412
+ requestContext._reportBackgroundError = (error, category) => {
413
+ if (error != null && typeof error === "object") {
414
+ if (requestContext._reportedErrors.has(error)) return;
415
+ requestContext._reportedErrors.add(error);
416
+ }
417
+ invokeOnError(
418
+ router.onError,
419
+ error,
420
+ "cache",
421
+ { request: req, url, metadata: { category } },
422
+ "RSC",
423
+ );
424
+ };
384
425
  // Match production: the RSC handler stores the router's basename on the
385
426
  // request context (handler.ts), and redirect() prefixes root-relative URLs
386
427
  // with it. Mirror it so basename-redirect tests behave as they do in a real
@@ -417,7 +458,7 @@ export async function dispatch<TEnv = any>(
417
458
  // coreHandler below, mirroring production where handleResponseRoute is
418
459
  // nested inside coreHandler. Built lazily so a redirect/404 path never
419
460
  // touches it.
420
- const callResponseRoute = (): Promise<Response> => {
461
+ const callResponseRoute = async (): Promise<Response> => {
421
462
  // Match production: a partial (client-navigation) request to a response
422
463
  // route is short-circuited to X-RSC-Reload (handleResponseRoute), BEFORE
423
464
  // route-level middleware runs. Route-level middleware is skipped on a
@@ -527,28 +568,70 @@ export async function dispatch<TEnv = any>(
527
568
  if (isPartial) {
528
569
  return partialFinalHandler();
529
570
  }
530
- const routeMiddlewareEntries = (preview?.routeMiddleware ?? []).map(
531
- (mw) => ({
532
- entry: {
533
- pattern: null,
534
- regex: null,
535
- paramNames: [],
536
- handler: mw.handler,
537
- } as MiddlewareEntry<TEnv>,
538
- params: mw.params,
539
- }),
540
- );
541
- if (routeMiddlewareEntries.length === 0) {
542
- return callHandler();
571
+
572
+ // executeHandler = callHandler wrapped by route-level middleware, exactly
573
+ // the unit the production response cache wraps (response-route-handler.ts).
574
+ const executeHandler = (): Promise<Response> => {
575
+ const routeMiddlewareEntries = (preview?.routeMiddleware ?? []).map(
576
+ (mw) => ({
577
+ entry: {
578
+ pattern: null,
579
+ regex: null,
580
+ paramNames: [],
581
+ handler: mw.handler,
582
+ } as MiddlewareEntry<TEnv>,
583
+ params: mw.params,
584
+ }),
585
+ );
586
+ if (routeMiddlewareEntries.length === 0) {
587
+ return callHandler();
588
+ }
589
+ return executeMiddleware<TEnv>(
590
+ routeMiddlewareEntries,
591
+ req,
592
+ env,
593
+ variables,
594
+ callHandler,
595
+ reverse,
596
+ );
597
+ };
598
+
599
+ // Response-route cache path: resolved through the SAME shared serve leaf
600
+ // (rsc/response-cache-serve.ts) production uses, so a cached
601
+ // path.json/path.text route hits/SWRs/writes tags through dispatch exactly
602
+ // as in production — and the two can never drift. Resolved from the matched
603
+ // entry tree (preview.manifestEntry), which previewMatch surfaces for
604
+ // response routes.
605
+ const manifestEntry = preview?.manifestEntry;
606
+ if (manifestEntry) {
607
+ // Lazy so the testing barrel's eager graph stays plugin-rsc-free (see the
608
+ // import note above): the leaf takes createCacheScope/resolveCacheTags as
609
+ // INJECTED deps so it never imports plugin-rsc; we hand it the lazily
610
+ // imported pair here, only once a response route actually matched.
611
+ const cacheScopeMod = await import("../cache/cache-scope.js");
612
+ const { serveResponseRouteWithCache } =
613
+ await import("../rsc/response-cache-serve.js");
614
+ // requestContext is RequestContext<TEnv>; the leaf is typed against the
615
+ // default-env RequestContext (it reads only env-agnostic config).
616
+ // Assignable in the router's own tsc but not when a consumer pins a
617
+ // concrete Env — cast to the leaf's param type.
618
+ const cached = await serveResponseRouteWithCache({
619
+ reqCtx: requestContext as Parameters<
620
+ typeof serveResponseRouteWithCache
621
+ >[0]["reqCtx"],
622
+ manifestEntry,
623
+ responseType: responseType as string,
624
+ url,
625
+ executeHandler,
626
+ deps: {
627
+ createCacheScope: cacheScopeMod.createCacheScope,
628
+ resolveCacheTags: cacheScopeMod.resolveCacheTags,
629
+ },
630
+ });
631
+ if (cached !== undefined) return cached;
543
632
  }
544
- return executeMiddleware<TEnv>(
545
- routeMiddlewareEntries,
546
- req,
547
- env,
548
- variables,
549
- callHandler,
550
- reverse,
551
- );
633
+
634
+ return executeHandler().then(finalizeResponse);
552
635
  };
553
636
 
554
637
  // coreHandler is the single terminal the global middleware chain wraps,