@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.124
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/dist/bin/rango.js +7 -2
- package/dist/vite/index.js +47 -6
- package/package.json +61 -21
- package/skills/cache-guide/SKILL.md +8 -6
- package/skills/caching/SKILL.md +148 -1
- package/skills/hooks/SKILL.md +38 -27
- package/skills/host-router/SKILL.md +16 -2
- package/skills/intercept/SKILL.md +4 -2
- package/skills/layout/SKILL.md +11 -6
- package/skills/loader/SKILL.md +6 -2
- package/skills/middleware/SKILL.md +4 -2
- package/skills/migrate-nextjs/SKILL.md +38 -16
- package/skills/parallel/SKILL.md +9 -4
- package/skills/rango/SKILL.md +27 -15
- package/skills/route/SKILL.md +4 -2
- package/skills/testing/SKILL.md +129 -0
- package/skills/testing/bindings.md +89 -0
- package/skills/testing/cache-prerender.md +98 -0
- package/skills/testing/client-components.md +122 -0
- package/skills/testing/e2e-parity.md +125 -0
- package/skills/testing/flight.md +89 -0
- package/skills/testing/handles.md +129 -0
- package/skills/testing/loader.md +128 -0
- package/skills/testing/middleware.md +99 -0
- package/skills/testing/render-handler.md +118 -0
- package/skills/testing/response-routes.md +95 -0
- package/skills/testing/reverse-and-types.md +84 -0
- package/skills/testing/server-actions.md +107 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +120 -0
- package/skills/use-cache/SKILL.md +9 -7
- package/src/browser/action-fence.ts +37 -0
- package/src/browser/cookie-name.ts +140 -0
- package/src/browser/invalidate-client-cache.ts +52 -0
- package/src/browser/navigation-bridge.ts +14 -1
- package/src/browser/navigation-client.ts +14 -1
- package/src/browser/navigation-store-handle.ts +39 -0
- package/src/browser/navigation-store.ts +26 -12
- package/src/browser/prefetch/fetch.ts +7 -0
- package/src/browser/rango-state.ts +176 -97
- package/src/browser/react/index.ts +0 -6
- package/src/browser/rsc-router.tsx +12 -4
- package/src/browser/server-action-bridge.ts +77 -15
- package/src/browser/types.ts +7 -1
- package/src/cache/cache-error.ts +104 -0
- package/src/cache/cache-policy.ts +95 -1
- package/src/cache/cache-runtime.ts +79 -13
- package/src/cache/cache-scope.ts +55 -4
- package/src/cache/cache-tag.ts +135 -0
- package/src/cache/cf/cf-cache-store.ts +2080 -224
- package/src/cache/cf/index.ts +15 -1
- package/src/cache/document-cache.ts +74 -7
- package/src/cache/index.ts +17 -0
- package/src/cache/memory-segment-store.ts +164 -14
- package/src/cache/tag-invalidation.ts +230 -0
- package/src/cache/types.ts +27 -0
- package/src/client.rsc.tsx +1 -1
- package/src/client.tsx +0 -6
- package/src/component-utils.ts +19 -0
- package/src/handle.ts +29 -9
- package/src/host/testing.ts +43 -14
- package/src/index.rsc.ts +29 -1
- package/src/index.ts +43 -1
- package/src/loader.rsc.ts +24 -3
- package/src/loader.ts +16 -2
- package/src/prerender.ts +24 -3
- package/src/router/basename.ts +14 -0
- package/src/router/match-handlers.ts +62 -20
- package/src/router/prerender-match.ts +6 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +30 -0
- package/src/router/segment-resolution/loader-cache.ts +8 -17
- package/src/router/state-cookie-name.ts +33 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router.ts +36 -7
- package/src/rsc/handler.ts +13 -1
- package/src/rsc/helpers.ts +19 -0
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +8 -1
- package/src/rsc/rsc-rendering.ts +2 -0
- package/src/rsc/types.ts +2 -0
- package/src/runtime-env.ts +18 -0
- package/src/server/cookie-store.ts +52 -1
- package/src/server/request-context.ts +105 -2
- package/src/static-handler.ts +25 -3
- package/src/testing/cache-status.ts +166 -0
- package/src/testing/collect-handle.ts +63 -0
- package/src/testing/dispatch.ts +581 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +188 -0
- package/src/testing/e2e/index.ts +149 -0
- package/src/testing/e2e/matchers.ts +51 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +387 -0
- package/src/testing/e2e/server.ts +195 -0
- package/src/testing/flight-matchers.ts +110 -0
- package/src/testing/flight-normalize.ts +38 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +682 -0
- package/src/testing/flight.entry.ts +52 -0
- package/src/testing/flight.ts +234 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +119 -0
- package/src/testing/internal/context.ts +390 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +80 -0
- package/src/testing/render-handler.ts +360 -0
- package/src/testing/render-route.tsx +594 -0
- package/src/testing/run-loader.ts +474 -0
- package/src/testing/run-middleware.ts +231 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +305 -0
- package/src/types/cache-types.ts +13 -4
- package/src/types/error-types.ts +5 -1
- package/src/types/global-namespace.ts +11 -1
- package/src/types/handler-context.ts +16 -5
- package/src/browser/react/use-client-cache.ts +0 -58
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runLoader — unit-test a loader function in isolation.
|
|
3
|
+
*
|
|
4
|
+
* Pass the RAW async loader body `(ctx) => ...`, or a registered `createLoader()`
|
|
5
|
+
* handle (its fn is recovered from the fetchable registry by `$$id`). The raw
|
|
6
|
+
* body needs no build step; the handle works because `createLoader` assigns a
|
|
7
|
+
* runtime-fallback `$$id` and registers its fn even without the Vite plugin (when
|
|
8
|
+
* imported through the server build — the consumer's `@rangojs/router` under the
|
|
9
|
+
* `rangoTestConfig()` preset). Either way the function is invoked directly with a
|
|
10
|
+
* constructed LoaderContext.
|
|
11
|
+
*
|
|
12
|
+
* The LoaderContext mirrors the canonical shape the router builds at runtime
|
|
13
|
+
* (see createUseFunction in server/request-context.ts). The loader runs inside
|
|
14
|
+
* runWithRequestContext so getRequestContext(), cookie reads, and header
|
|
15
|
+
* mutations resolve exactly as in production.
|
|
16
|
+
*
|
|
17
|
+
* Limitations (v1):
|
|
18
|
+
* - `ctx.rendered()` is not available — it requires the DSL render barrier,
|
|
19
|
+
* which only exists inside a full match. Calling it throws.
|
|
20
|
+
* - `ctx.reverse()` throws unless `routeMap` is provided (it does NOT fall back
|
|
21
|
+
* to the global route map — that would leak whichever routes another test
|
|
22
|
+
* registered).
|
|
23
|
+
* - `ctx.use(handle)` follows the production rule: reading a handle before
|
|
24
|
+
* `await ctx.rendered()` throws (pass `rendered` to mock the barrier).
|
|
25
|
+
* - `use cache` functions only cache (and only fire their taint/profile guards)
|
|
26
|
+
* when a `cacheStore` is provided — without one, registerCachedFunction
|
|
27
|
+
* bypasses (it checks for a store first). Pass `cacheStore`/`cacheProfiles`
|
|
28
|
+
* to exercise cached loaders; otherwise such a call runs uncached, like an
|
|
29
|
+
* app with no cache configured.
|
|
30
|
+
* - `formData` is exposed verbatim; no multipart parsing is performed.
|
|
31
|
+
* - Scoped dot-local reverse (`.sibling`) uses only the supplied `routeMap`;
|
|
32
|
+
* the production root-scoping signal (derived from the global registry) is
|
|
33
|
+
* not modeled, so a dotted name resolves against `routeMap` as given.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
runWithRequestContext,
|
|
38
|
+
type RequestContext,
|
|
39
|
+
} from "../server/request-context.js";
|
|
40
|
+
import { createReverseFunction } from "../router/handler-context.js";
|
|
41
|
+
import { getFetchableLoader } from "../server/fetchable-loader-store.js";
|
|
42
|
+
import type { LoaderContext, LoaderDefinition } from "../types.js";
|
|
43
|
+
import type { ContextVar } from "../context-var.js";
|
|
44
|
+
import { isHandle, type Handle } from "../handle.js";
|
|
45
|
+
import { collectHandle } from "./collect-handle.js";
|
|
46
|
+
import type { ThemeConfig } from "../theme/types.js";
|
|
47
|
+
import type { SegmentCacheStore } from "../cache/types.js";
|
|
48
|
+
import type { CacheProfile } from "../cache/profile-registry.js";
|
|
49
|
+
import {
|
|
50
|
+
createTestRequestContext,
|
|
51
|
+
buildRunSnapshot,
|
|
52
|
+
type CreateTestContextOptions,
|
|
53
|
+
type VarsInit,
|
|
54
|
+
type StateCookieSeed,
|
|
55
|
+
} from "./internal/context.js";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The loader context surfaced to a `runLoader` body. It mirrors the runtime
|
|
59
|
+
* LoaderContext but RELAXES the two members that are otherwise bound to the
|
|
60
|
+
* app's global route/var augmentation, because in a unit test they are driven by
|
|
61
|
+
* the `routeMap` / `vars` options instead:
|
|
62
|
+
* - `reverse` accepts any route name (the names come from `routeMap`, not the
|
|
63
|
+
* registered route map), and
|
|
64
|
+
* - `get` accepts any string key or ContextVar (keys come from `vars`).
|
|
65
|
+
*/
|
|
66
|
+
export type TestLoaderContext<TEnv = any> = Omit<
|
|
67
|
+
LoaderContext<any, TEnv>,
|
|
68
|
+
"reverse" | "get"
|
|
69
|
+
> & {
|
|
70
|
+
reverse: (
|
|
71
|
+
name: string,
|
|
72
|
+
params?: Record<string, string>,
|
|
73
|
+
search?: Record<string, unknown>,
|
|
74
|
+
) => string;
|
|
75
|
+
get: {
|
|
76
|
+
<T>(contextVar: ContextVar<T>): T | undefined;
|
|
77
|
+
<T = unknown>(key: string): T | undefined;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A resolver for `ctx.use(OtherLoader)` composition. Receives the dependency
|
|
83
|
+
* loader definition and returns its data (or a promise of it). When omitted,
|
|
84
|
+
* `ctx.use` delegates to the real request-context use(), which executes the
|
|
85
|
+
* dependency's own `fn` if present.
|
|
86
|
+
*/
|
|
87
|
+
export type UseResolver = <T>(
|
|
88
|
+
loader: LoaderDefinition<T, any>,
|
|
89
|
+
) => Promise<T> | T;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Options for runLoader.
|
|
93
|
+
*/
|
|
94
|
+
export interface RunLoaderOptions<TEnv = any> {
|
|
95
|
+
/** Route params surfaced as `ctx.params` and `ctx.routeParams`. */
|
|
96
|
+
params?: Record<string, string>;
|
|
97
|
+
/** Search params; merged into the request URL so `ctx.searchParams` reflects them. */
|
|
98
|
+
search?: Record<string, string>;
|
|
99
|
+
/**
|
|
100
|
+
* The TYPED `ctx.search` object a route's search schema would produce. Distinct
|
|
101
|
+
* from `search` (which sets the raw `ctx.searchParams`): a loader on a typed
|
|
102
|
+
* search route reads `ctx.search`, which is otherwise `{}` in a test.
|
|
103
|
+
*/
|
|
104
|
+
searchData?: Record<string, unknown>;
|
|
105
|
+
/** Router basename surfaced on the context (drives redirect() prefixing). */
|
|
106
|
+
basename?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Theme config in the same shape `createRouter({ theme })` takes (e.g. `true`
|
|
109
|
+
* or `{ themes: [...] }`). Without it `ctx.theme`/`ctx.setTheme` are inert.
|
|
110
|
+
*/
|
|
111
|
+
theme?: ThemeConfig | true;
|
|
112
|
+
/** Environment bindings surfaced as `ctx.env`. */
|
|
113
|
+
env?: TEnv;
|
|
114
|
+
/** Override the backing Request (string or Request). Defaults to a localhost GET. */
|
|
115
|
+
request?: Request | string;
|
|
116
|
+
/** Variables a prior middleware would have set (object or [key, value] list). */
|
|
117
|
+
vars?: VarsInit;
|
|
118
|
+
/** Route name -> pattern map enabling `ctx.reverse()`. */
|
|
119
|
+
routeMap?: Record<string, string>;
|
|
120
|
+
/** Matched route name for scoped `.name` reverse resolution. */
|
|
121
|
+
routeName?: string;
|
|
122
|
+
/** HTTP method surfaced as `ctx.method`. Defaults to "GET". */
|
|
123
|
+
method?: string;
|
|
124
|
+
/** Request body surfaced as `ctx.body`. */
|
|
125
|
+
body?: unknown;
|
|
126
|
+
/** Form data surfaced as `ctx.formData`. */
|
|
127
|
+
formData?: FormData;
|
|
128
|
+
/**
|
|
129
|
+
* Seed the data `ctx.use(OtherLoader)` returns, by loader REFERENCE — the same
|
|
130
|
+
* tuple form `renderHandler` / `renderRoute` use (`[[OtherLoader, data]]`).
|
|
131
|
+
* Matched by reference, so a real `createLoader()` handle resolves regardless
|
|
132
|
+
* of its build-injected `$$id`. For dynamic resolution (compute per dependency)
|
|
133
|
+
* use `use` instead; `loaders` is checked first.
|
|
134
|
+
*/
|
|
135
|
+
loaders?: ReadonlyArray<readonly [LoaderDefinition<any, any>, unknown]>;
|
|
136
|
+
/** Resolver for `ctx.use(OtherLoader)` composition (dynamic; `loaders` wins if both match). */
|
|
137
|
+
use?: UseResolver;
|
|
138
|
+
/**
|
|
139
|
+
* Cache store backing `use cache` functions the loader invokes. Without it,
|
|
140
|
+
* a cached function bypasses (registerCachedFunction checks for a store
|
|
141
|
+
* first) and runs uncached — its taint/profile guards never fire. Pass one
|
|
142
|
+
* (e.g. `new MemorySegmentCacheStore()`) to test a cached loader.
|
|
143
|
+
*/
|
|
144
|
+
cacheStore?: SegmentCacheStore;
|
|
145
|
+
/** Cache profiles (the `createRouter({ cacheProfiles })` shape). */
|
|
146
|
+
cacheProfiles?: Record<string, CacheProfile>;
|
|
147
|
+
/**
|
|
148
|
+
* Customize the rango state cookie a loader that calls
|
|
149
|
+
* `invalidateClientCache()` rotates (the name is always seeded — default
|
|
150
|
+
* `rango-state_router_0` — so it rotates like production). Assert via the
|
|
151
|
+
* `Set-Cookie` on the request context's response.
|
|
152
|
+
*/
|
|
153
|
+
stateCookie?: StateCookieSeed;
|
|
154
|
+
/**
|
|
155
|
+
* Mock the `ctx.rendered()` render barrier so a loader that does
|
|
156
|
+
* `await ctx.rendered()` (to read handle data pushed during render) can be
|
|
157
|
+
* unit-tested. By default `ctx.rendered()` throws, because the real barrier
|
|
158
|
+
* only exists during a full route match. Pass `true` to resolve it
|
|
159
|
+
* immediately, or a function to control its timing/side effects.
|
|
160
|
+
*
|
|
161
|
+
* This tests the loader's POST-barrier compute logic against the seeded
|
|
162
|
+
* `handles` below — it does NOT exercise the real push -> accumulate -> barrier
|
|
163
|
+
* wiring (that stays e2e). Pair with `handles` to feed `ctx.use(SomeHandle)`.
|
|
164
|
+
*/
|
|
165
|
+
rendered?: boolean | (() => void | Promise<void>);
|
|
166
|
+
/**
|
|
167
|
+
* Seed the values `ctx.use(SomeHandle)` returns — the ACCUMULATED handle data a
|
|
168
|
+
* loader reads after `await ctx.rendered()`. Matched by handle reference, so a
|
|
169
|
+
* real handle works regardless of its (build-injected) `$$id`.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* await runLoader(livePricesBody, {
|
|
173
|
+
* rendered: true,
|
|
174
|
+
* handles: [[RenderedProducts, ["widget-a", "widget-b"]]],
|
|
175
|
+
* });
|
|
176
|
+
*/
|
|
177
|
+
handles?: ReadonlyArray<readonly [Handle<any, any>, unknown]>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Merge `search` into a request's URL, returning a value `toRequest` can build.
|
|
182
|
+
* Keeps the original method/headers/body when a Request was passed.
|
|
183
|
+
*/
|
|
184
|
+
function withSearch(
|
|
185
|
+
request: Request | string | undefined,
|
|
186
|
+
search: Record<string, string> | undefined,
|
|
187
|
+
): Request | string | undefined {
|
|
188
|
+
if (!search) return request;
|
|
189
|
+
const DEFAULT_ORIGIN = "http://localhost/";
|
|
190
|
+
if (request instanceof Request) {
|
|
191
|
+
const url = new URL(request.url);
|
|
192
|
+
for (const [key, value] of Object.entries(search)) {
|
|
193
|
+
url.searchParams.set(key, value);
|
|
194
|
+
}
|
|
195
|
+
return new Request(url, request);
|
|
196
|
+
}
|
|
197
|
+
const url = new URL(request ?? DEFAULT_ORIGIN, DEFAULT_ORIGIN);
|
|
198
|
+
for (const [key, value] of Object.entries(search)) {
|
|
199
|
+
url.searchParams.set(key, value);
|
|
200
|
+
}
|
|
201
|
+
return url.toString();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A raw loader body, or a registered `createLoader()` handle (its fn is recovered). */
|
|
205
|
+
export type RunnableLoader<T> =
|
|
206
|
+
| ((ctx: TestLoaderContext) => Promise<T> | T)
|
|
207
|
+
| LoaderDefinition<T, any>;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve the function to run from either a raw body or a `createLoader()` handle.
|
|
211
|
+
*
|
|
212
|
+
* A handle carries no inline body (`createLoader` registers it in the fetchable
|
|
213
|
+
* registry by `$$id`), so recover it from there — `def.fn` first (a hand-built
|
|
214
|
+
* def), then the registry. This works when the handle resolves through the
|
|
215
|
+
* SERVER build (the consumer's `@rangojs/router` under `rangoTestConfig`, which
|
|
216
|
+
* registers the fn); the CLIENT stub drops the body, so a handle imported that
|
|
217
|
+
* way is unrecoverable and we say so explicitly.
|
|
218
|
+
*/
|
|
219
|
+
function resolveLoaderFn<T>(
|
|
220
|
+
loader: RunnableLoader<T>,
|
|
221
|
+
): (ctx: TestLoaderContext) => Promise<T> | T {
|
|
222
|
+
if (typeof loader === "function") {
|
|
223
|
+
return loader as (ctx: TestLoaderContext) => Promise<T> | T;
|
|
224
|
+
}
|
|
225
|
+
const def = loader as LoaderDefinition<T, any>;
|
|
226
|
+
const fn = def.fn ?? getFetchableLoader(def.$$id)?.fn;
|
|
227
|
+
if (!fn) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`runLoader() received a createLoader() handle whose function could not be ` +
|
|
230
|
+
`recovered (id "${def.$$id || "<empty>"}"). The loader was likely imported ` +
|
|
231
|
+
`through the CLIENT build, which drops the body. Either import it through ` +
|
|
232
|
+
`@rangojs/router with the rangoTestConfig() preset (resolves to the server ` +
|
|
233
|
+
`build that registers the fn), or pass the raw loader body directly: ` +
|
|
234
|
+
`runLoader((ctx) => ...).`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return fn as (ctx: TestLoaderContext) => Promise<T> | T;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Run a loader and return its resolved data. Pass the RAW loader body, or a
|
|
242
|
+
* registered `createLoader()` handle (its fn is recovered from the registry).
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* ```ts
|
|
246
|
+
* // raw body
|
|
247
|
+
* const a = await runLoader(
|
|
248
|
+
* async (ctx) => ({ id: ctx.params.id, user: ctx.get("user") }),
|
|
249
|
+
* { params: { id: "42" }, vars: { user: { name: "Ada" } } },
|
|
250
|
+
* );
|
|
251
|
+
* // registered createLoader() handle (recovered from the registry)
|
|
252
|
+
* const b = await runLoader(ProductLoader, { params: { id: "42" } });
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
// Build the createTestRequestContext options from runLoader's options. Shared by
|
|
256
|
+
// runLoader (returns the loader data) and runLoaderResult (also snapshots effects).
|
|
257
|
+
function buildLoaderCtxOpts(
|
|
258
|
+
opts: RunLoaderOptions,
|
|
259
|
+
): CreateTestContextOptions<any> {
|
|
260
|
+
return {
|
|
261
|
+
env: opts.env,
|
|
262
|
+
// Bake opts.search into the request URL itself so ctx.request.url, ctx.url,
|
|
263
|
+
// and ctx.searchParams all agree (production carries the query string on the
|
|
264
|
+
// real request — a loader reading ctx.request.url must see it too).
|
|
265
|
+
request: withSearch(opts.request, opts.search),
|
|
266
|
+
requestInit: opts.method ? { method: opts.method } : undefined,
|
|
267
|
+
vars: opts.vars,
|
|
268
|
+
routeMap: opts.routeMap,
|
|
269
|
+
routeName: opts.routeName,
|
|
270
|
+
params: opts.params,
|
|
271
|
+
basename: opts.basename,
|
|
272
|
+
theme: opts.theme,
|
|
273
|
+
cacheStore: opts.cacheStore,
|
|
274
|
+
cacheProfiles: opts.cacheProfiles,
|
|
275
|
+
stateCookie: opts.stateCookie,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Enter `reqCtx` and run `fn` with a seeded TestLoaderContext (the same ctx shape
|
|
280
|
+
// a real loader receives). The single place the loader context is built, so
|
|
281
|
+
// runLoader and runLoaderResult share identical loader-context semantics.
|
|
282
|
+
function runWithLoaderContext<R>(
|
|
283
|
+
reqCtx: RequestContext<any>,
|
|
284
|
+
opts: RunLoaderOptions,
|
|
285
|
+
fn: (ctx: TestLoaderContext) => R,
|
|
286
|
+
): R {
|
|
287
|
+
// Seed values for ctx.use(SomeHandle), matched by handle reference (so a real
|
|
288
|
+
// handle resolves regardless of its build-injected $$id).
|
|
289
|
+
const handleSeeds = new Map<unknown, unknown>(opts.handles ?? []);
|
|
290
|
+
|
|
291
|
+
// Seed values for ctx.use(OtherLoader), matched by loader reference (same model
|
|
292
|
+
// as renderHandler/renderRoute). Checked before the `use` resolver.
|
|
293
|
+
const loaderSeeds = new Map<unknown, unknown>(opts.loaders ?? []);
|
|
294
|
+
|
|
295
|
+
// Tracks whether the mocked render barrier has settled. ctx.use(handle)
|
|
296
|
+
// reads are gated on this, matching production (loader-resolution.ts).
|
|
297
|
+
let renderedResolved = false;
|
|
298
|
+
|
|
299
|
+
return runWithRequestContext(reqCtx, () => {
|
|
300
|
+
const reverse = opts.routeMap
|
|
301
|
+
? createReverseFunction(opts.routeMap, opts.routeName, opts.params ?? {})
|
|
302
|
+
: ((() => {
|
|
303
|
+
// Documented contract: reverse requires routeMap. Do NOT fall back to
|
|
304
|
+
// reqCtx.reverse (the global route map) — that leaks whichever routes
|
|
305
|
+
// another test registered and contradicts the documented behavior.
|
|
306
|
+
throw new Error(
|
|
307
|
+
"ctx.reverse() requires the `routeMap` option in runLoader(). " +
|
|
308
|
+
"Pass { routeMap: { name: pattern, ... } } to enable reverse().",
|
|
309
|
+
);
|
|
310
|
+
}) as TestLoaderContext["reverse"]);
|
|
311
|
+
|
|
312
|
+
const loaderCtx: TestLoaderContext = {
|
|
313
|
+
params: opts.params ?? {},
|
|
314
|
+
routeParams: (opts.params ?? {}) as Record<string, string>,
|
|
315
|
+
request: reqCtx.request,
|
|
316
|
+
searchParams: reqCtx.searchParams,
|
|
317
|
+
search: opts.searchData ?? {},
|
|
318
|
+
pathname: reqCtx.pathname,
|
|
319
|
+
url: reqCtx.url,
|
|
320
|
+
originalUrl: reqCtx.originalUrl,
|
|
321
|
+
env: reqCtx.env,
|
|
322
|
+
waitUntil: reqCtx.waitUntil.bind(reqCtx),
|
|
323
|
+
executionContext: reqCtx.executionContext,
|
|
324
|
+
get: reqCtx.get as TestLoaderContext["get"],
|
|
325
|
+
use: ((dep: LoaderDefinition<any, any> | Handle<any, any>) => {
|
|
326
|
+
// Match production (loader-resolution.ts): reading a handle in a loader
|
|
327
|
+
// requires the render barrier to have settled. Gate BEFORE returning a
|
|
328
|
+
// seed, so a loader that forgets `await ctx.rendered()` fails in the
|
|
329
|
+
// test exactly as it would at runtime.
|
|
330
|
+
if (isHandle(dep) && !renderedResolved) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`ctx.use(handle) in a loader requires "await ctx.rendered()" first. ` +
|
|
333
|
+
`Handle "${(dep as Handle<any, any>).$$id}" cannot be read until ` +
|
|
334
|
+
`the render tree has settled.`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
// Handle reads (ctx.use(SomeHandle)) resolve from the seeded map first.
|
|
338
|
+
if (handleSeeds.has(dep)) return handleSeeds.get(dep);
|
|
339
|
+
// Post-barrier, an UNSEEDED handle must match production
|
|
340
|
+
// (loader-resolution.ts -> collectHandleData), which runs the handle's
|
|
341
|
+
// registered collect over empty segments (collect([])) rather than
|
|
342
|
+
// throwing or leaking into the loader resolver. Resolve it via
|
|
343
|
+
// collectHandle, which recovers and runs that same collect.
|
|
344
|
+
if (isHandle(dep)) return collectHandle(dep, []);
|
|
345
|
+
// Loader reads (ctx.use(OtherLoader)) resolve from the seeded map next,
|
|
346
|
+
// then the dynamic `use` resolver, then the real request-context use().
|
|
347
|
+
if (loaderSeeds.has(dep)) return loaderSeeds.get(dep);
|
|
348
|
+
if (opts.use) return opts.use(dep as LoaderDefinition<any, any>);
|
|
349
|
+
return reqCtx.use(dep as LoaderDefinition<any, any>);
|
|
350
|
+
}) as LoaderContext<any, any>["use"],
|
|
351
|
+
method: opts.method ?? "GET",
|
|
352
|
+
body: opts.body,
|
|
353
|
+
formData: opts.formData,
|
|
354
|
+
reverse: reverse as TestLoaderContext["reverse"],
|
|
355
|
+
rendered:
|
|
356
|
+
opts.rendered !== undefined && opts.rendered !== false
|
|
357
|
+
? async () => {
|
|
358
|
+
if (typeof opts.rendered === "function") {
|
|
359
|
+
await opts.rendered();
|
|
360
|
+
}
|
|
361
|
+
// Barrier has settled: subsequent ctx.use(handle) reads resolve.
|
|
362
|
+
renderedResolved = true;
|
|
363
|
+
}
|
|
364
|
+
: () => {
|
|
365
|
+
throw new Error(
|
|
366
|
+
"ctx.rendered() is not available in runLoader() by default. It " +
|
|
367
|
+
"requires the DSL render barrier, which only exists during a " +
|
|
368
|
+
"full route match. To unit-test a loader's post-barrier logic, " +
|
|
369
|
+
"pass { rendered: true } to mock the barrier and { handles: " +
|
|
370
|
+
"[[SomeHandle, accumulatedData]] } to seed ctx.use(SomeHandle). " +
|
|
371
|
+
"For the real push/accumulate/barrier wiring, use an e2e test.",
|
|
372
|
+
);
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
return fn(loaderCtx);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Run a loader and return its resolved data.
|
|
382
|
+
*
|
|
383
|
+
* Effects the loader sets (cookies, response headers, a thrown redirect) are NOT
|
|
384
|
+
* observable here — use {@link runLoaderResult} for an auth-style loader that
|
|
385
|
+
* sets a `Set-Cookie` and/or `throw redirect(...)`.
|
|
386
|
+
*/
|
|
387
|
+
export async function runLoader<T>(
|
|
388
|
+
loader: RunnableLoader<T>,
|
|
389
|
+
opts: RunLoaderOptions = {},
|
|
390
|
+
): Promise<T> {
|
|
391
|
+
const loaderFn = resolveLoaderFn(loader);
|
|
392
|
+
const { ctx } = createTestRequestContext(buildLoaderCtxOpts(opts));
|
|
393
|
+
return runWithLoaderContext(ctx as RequestContext<any>, opts, (loaderCtx) =>
|
|
394
|
+
Promise.resolve(loaderFn(loaderCtx)),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* What a loader run accumulated: its data PLUS the response effects it produced,
|
|
400
|
+
* surfaced as PUBLIC values (parity with `runMiddleware`/`runInRequestContext`)
|
|
401
|
+
* so an effect-setting loader is assertable without casting through the
|
|
402
|
+
* `@internal` request context.
|
|
403
|
+
*/
|
|
404
|
+
export interface RunLoaderResult<T> {
|
|
405
|
+
/**
|
|
406
|
+
* The loader's resolved data (the value bare `runLoader` returns), or
|
|
407
|
+
* `undefined` if it threw (see {@link thrown}). Named `result` for parity with
|
|
408
|
+
* `runInRequestContext`'s envelope.
|
|
409
|
+
*/
|
|
410
|
+
result: T | undefined;
|
|
411
|
+
/**
|
|
412
|
+
* What the loader threw (commonly a `Response` from `throw redirect(...)` on a
|
|
413
|
+
* success path) — captured, NOT re-thrown; assert on it. `undefined` if the
|
|
414
|
+
* loader returned normally.
|
|
415
|
+
*/
|
|
416
|
+
thrown: unknown;
|
|
417
|
+
/**
|
|
418
|
+
* The merged `Response` (status + headers + Set-Cookie). On a thrown redirect,
|
|
419
|
+
* that redirect's `Location` merged with the accumulated cookies/headers — so a
|
|
420
|
+
* loader that sets a session cookie then `throw redirect("/")` exposes BOTH.
|
|
421
|
+
*/
|
|
422
|
+
response: Response;
|
|
423
|
+
/** Effective cookie view: request cookies + the loader's mutations, last-write-wins. */
|
|
424
|
+
cookies: Record<string, string>;
|
|
425
|
+
/** Response headers as `{ name: value }`, EXCLUDING set-cookie (use `cookies`). Lowercased. */
|
|
426
|
+
headers: Record<string, string>;
|
|
427
|
+
/** Location state the loader set (`ctx.setLocationState()` / `redirect({ state })`). */
|
|
428
|
+
locationState: Record<string, unknown>;
|
|
429
|
+
/** The resolved rango state cookie name seeded for the run (default `rango-state_router_0`). */
|
|
430
|
+
stateCookieName: string;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Run a loader AND surface the response effects it produced. The richer sibling
|
|
435
|
+
* of {@link runLoader} (which returns the bare data): use this when the loader
|
|
436
|
+
* sets a cookie / response header / location-state, or `throw redirect(...)`, and
|
|
437
|
+
* the test must assert that output.
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* ```ts
|
|
441
|
+
* // AuthLoader: validates, sets a `session` cookie, then `throw redirect("/")`.
|
|
442
|
+
* const { thrown, response, cookies } = await runLoaderResult(AuthLoader, {
|
|
443
|
+
* request: new Request("https://app.test/login?token=ok"),
|
|
444
|
+
* });
|
|
445
|
+
* expect((thrown as Response).headers.get("Location")).toBe("/");
|
|
446
|
+
* expect(cookies.session).toBeDefined();
|
|
447
|
+
* expect(
|
|
448
|
+
* response.headers.getSetCookie().some((c) => c.startsWith("session=")),
|
|
449
|
+
* ).toBe(true);
|
|
450
|
+
* ```
|
|
451
|
+
*/
|
|
452
|
+
export async function runLoaderResult<T>(
|
|
453
|
+
loader: RunnableLoader<T>,
|
|
454
|
+
opts: RunLoaderOptions = {},
|
|
455
|
+
): Promise<RunLoaderResult<T>> {
|
|
456
|
+
const loaderFn = resolveLoaderFn(loader);
|
|
457
|
+
const { ctx, stateCookieName } = createTestRequestContext(
|
|
458
|
+
buildLoaderCtxOpts(opts),
|
|
459
|
+
);
|
|
460
|
+
const reqCtx = ctx as RequestContext<any>;
|
|
461
|
+
let result: T | undefined;
|
|
462
|
+
let thrown: unknown;
|
|
463
|
+
try {
|
|
464
|
+
result = await runWithLoaderContext(reqCtx, opts, (loaderCtx) =>
|
|
465
|
+
Promise.resolve(loaderFn(loaderCtx)),
|
|
466
|
+
);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
// Capture (do NOT re-throw): a loader's success path is often
|
|
469
|
+
// `throw redirect(...)`, and the cookie/flash it set before the throw must
|
|
470
|
+
// stay observable (parity with runInRequestContext).
|
|
471
|
+
thrown = error;
|
|
472
|
+
}
|
|
473
|
+
return { result, ...buildRunSnapshot(reqCtx, thrown, stateCookieName) };
|
|
474
|
+
}
|