@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.
- package/AGENTS.md +8 -0
- package/README.md +43 -2
- package/dist/bin/rango.js +92 -16
- package/dist/vite/index.js +166 -70
- package/package.json +19 -18
- package/skills/breadcrumbs/SKILL.md +1 -1
- package/skills/bundle-analysis/SKILL.md +2 -2
- package/skills/cache-guide/SKILL.md +2 -2
- package/skills/caching/SKILL.md +16 -9
- package/skills/debug-manifest/SKILL.md +4 -2
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/handler-use/SKILL.md +1 -1
- package/skills/hooks/SKILL.md +2 -2
- package/skills/host-router/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +1 -1
- package/skills/loader/SKILL.md +2 -0
- package/skills/migrate-react-router/SKILL.md +4 -2
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/prerender/SKILL.md +2 -0
- package/skills/rango/SKILL.md +12 -11
- package/skills/response-routes/SKILL.md +2 -2
- package/skills/route/SKILL.md +4 -0
- package/skills/router-setup/SKILL.md +3 -0
- package/skills/scripts/SKILL.md +179 -0
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/bindings.md +20 -6
- package/skills/testing/cache-prerender.md +5 -2
- package/skills/testing/client-components.md +2 -0
- package/skills/testing/e2e-parity.md +1 -1
- package/skills/testing/flight.md +8 -9
- package/skills/testing/render-handler.md +1 -1
- package/skills/testing/response-routes.md +1 -1
- package/skills/testing/server-actions.md +11 -11
- package/skills/testing/setup.md +3 -0
- package/skills/typesafety/SKILL.md +3 -2
- package/skills/use-cache/SKILL.md +10 -9
- package/src/browser/event-controller.ts +109 -2
- package/src/browser/partial-update.ts +12 -0
- package/src/browser/prefetch/cache.ts +17 -0
- package/src/browser/prefetch/fetch.ts +69 -2
- package/src/browser/react/Link.tsx +30 -5
- package/src/browser/react/NavigationProvider.tsx +12 -2
- package/src/browser/react/location-state-shared.ts +14 -2
- package/src/browser/react/use-href.tsx +8 -1
- package/src/browser/react/use-link-status.ts +23 -2
- package/src/browser/response-adapter.ts +14 -3
- package/src/browser/rsc-router.tsx +3 -0
- package/src/browser/scroll-restoration.ts +8 -3
- package/src/browser/server-action-bridge.ts +46 -11
- package/src/browser/types.ts +6 -0
- package/src/build/generate-route-types.ts +0 -1
- package/src/build/route-trie.ts +33 -9
- package/src/build/route-types/include-resolution.ts +7 -1
- package/src/build/route-types/router-processing.ts +0 -6
- package/src/build/route-types/source-scan.ts +105 -7
- package/src/cache/cache-policy.ts +42 -8
- package/src/cache/cache-runtime.ts +65 -5
- package/src/cache/cache-scope.ts +71 -11
- package/src/cache/cache-tag.ts +7 -2
- package/src/cache/cf/cf-base64.ts +33 -0
- package/src/cache/cf/cf-cache-constants.ts +127 -0
- package/src/cache/cf/cf-cache-store.ts +85 -613
- package/src/cache/cf/cf-cache-types.ts +349 -0
- package/src/cache/cf/cf-kv-utils.ts +46 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
- package/src/cache/document-cache.ts +11 -0
- package/src/cache/handle-snapshot.ts +8 -1
- package/src/cache/profile-registry.ts +25 -1
- package/src/cache/segment-codec.ts +9 -1
- package/src/cache/types.ts +4 -0
- package/src/client.rsc.tsx +38 -0
- package/src/client.tsx +11 -0
- package/src/components/DefaultDocument.tsx +8 -2
- package/src/context-var.ts +1 -1
- package/src/decode-loader-results.ts +7 -1
- package/src/escape-script.ts +52 -0
- package/src/handles/MetaTags.tsx +56 -5
- package/src/handles/Scripts.tsx +183 -0
- package/src/handles/breadcrumbs.ts +29 -11
- package/src/handles/is-thenable.ts +19 -0
- package/src/handles/meta.ts +46 -0
- package/src/handles/script.ts +244 -0
- package/src/host/cookie-handler.ts +7 -3
- package/src/host/pattern-matcher.ts +16 -2
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +5 -0
- package/src/response-utils.ts +25 -0
- package/src/route-definition/dsl-helpers.ts +7 -0
- package/src/route-definition/redirect.ts +1 -2
- package/src/router/content-negotiation.ts +58 -10
- package/src/router/intercept-resolution.ts +9 -0
- package/src/router/match-middleware/cache-store.ts +10 -1
- package/src/router/middleware.ts +10 -3
- package/src/router/pattern-matching.ts +25 -23
- package/src/router/prefetch-cache-ttl.ts +51 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +23 -0
- package/src/router/segment-resolution/fresh.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +35 -1
- package/src/router/segment-resolution/loader-cache.ts +10 -6
- package/src/router/segment-resolution/revalidation.ts +6 -0
- package/src/router/segment-resolution.ts +1 -0
- package/src/router/trie-matching.ts +14 -9
- package/src/router.ts +18 -10
- package/src/rsc/handler.ts +52 -13
- package/src/rsc/helpers.ts +7 -1
- package/src/rsc/index.ts +1 -4
- package/src/rsc/loader-fetch.ts +107 -37
- package/src/rsc/progressive-enhancement.ts +18 -6
- package/src/rsc/response-cache-serve.ts +238 -0
- package/src/rsc/response-route-handler.ts +16 -133
- package/src/rsc/rsc-rendering.ts +13 -4
- package/src/rsc/server-action.ts +52 -6
- package/src/rsc/types.ts +7 -0
- package/src/search-params.ts +24 -5
- package/src/segment-loader-promise.ts +17 -2
- package/src/server/loader-registry.ts +16 -18
- package/src/server/request-context.ts +47 -20
- package/src/testing/dispatch.ts +108 -25
- package/src/testing/flight.ts +25 -0
- package/src/testing/internal/context.ts +25 -2
- package/src/testing/render-handler.ts +3 -1
- package/src/testing/render-route.tsx +15 -0
- package/src/testing/run-loader.ts +10 -3
- package/src/theme/ThemeProvider.tsx +20 -6
- package/src/theme/ThemeScript.tsx +7 -3
- package/src/theme/constants.ts +54 -3
- package/src/theme/theme-script.ts +22 -7
- package/src/types/request-scope.ts +8 -3
- package/src/vite/plugins/cjs-to-esm.ts +8 -1
- package/src/vite/plugins/expose-id-utils.ts +10 -1
- package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
- package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
- package/src/vite/plugins/expose-internal-ids.ts +0 -1
- package/src/vite/plugins/version-plugin.ts +5 -17
- package/src/vite/plugins/virtual-entries.ts +12 -2
- package/src/vite/rango.ts +15 -6
- package/src/vite/utils/ast-handler-extract.ts +11 -4
- package/src/vite/utils/directive-prologue.ts +40 -0
- package/src/vite/utils/prerender-utils.ts +17 -2
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
|
|
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
|
|
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
|
|
58
|
+
handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
|
|
59
59
|
nonce: string | undefined,
|
|
60
60
|
): Promise<Response> {
|
|
61
|
-
const reqCtx =
|
|
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
|
});
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
package/src/search-params.ts
CHANGED
|
@@ -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?"` -
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
//
|
|
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
|
|
11
|
-
*
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
1099
|
+
search: loaderSearch,
|
|
1073
1100
|
pathname: ctx.pathname,
|
|
1074
1101
|
url: ctx.url,
|
|
1075
1102
|
originalUrl: ctx.originalUrl,
|
package/src/testing/dispatch.ts
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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,
|