@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.d98a8e9d
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/README.md +120 -25
- package/dist/bin/rango.js +147 -57
- package/dist/testing/vitest.js +82 -0
- package/dist/vite/index.js +2154 -861
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +57 -11
- package/skills/api-client/SKILL.md +211 -0
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +220 -30
- package/skills/caching/SKILL.md +116 -8
- package/skills/composability/SKILL.md +27 -2
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +229 -20
- package/skills/host-router/SKILL.md +45 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +46 -4
- package/skills/layout/SKILL.md +28 -7
- package/skills/links/SKILL.md +247 -17
- package/skills/loader/SKILL.md +219 -9
- package/skills/middleware/SKILL.md +47 -12
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/mime-routes/SKILL.md +27 -0
- package/skills/observability/SKILL.md +137 -0
- package/skills/parallel/SKILL.md +71 -6
- package/skills/prerender/SKILL.md +14 -33
- package/skills/rango/SKILL.md +243 -22
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +122 -47
- package/skills/route/SKILL.md +57 -4
- package/skills/router-setup/SKILL.md +3 -3
- package/skills/server-actions/SKILL.md +751 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/testing/SKILL.md +128 -0
- package/skills/testing/bindings.md +89 -0
- package/skills/testing/cache-prerender.md +98 -0
- package/skills/testing/client-components.md +121 -0
- package/skills/testing/e2e-parity.md +124 -0
- package/skills/testing/flight.md +89 -0
- package/skills/testing/handles.md +127 -0
- package/skills/testing/loader.md +108 -0
- package/skills/testing/middleware.md +97 -0
- package/skills/testing/render-handler.md +102 -0
- package/skills/testing/response-routes.md +94 -0
- package/skills/testing/reverse-and-types.md +83 -0
- package/skills/testing/server-actions.md +89 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +120 -0
- package/skills/typesafety/SKILL.md +319 -27
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +294 -0
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +116 -0
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/event-controller.ts +86 -70
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/navigation-bridge.ts +84 -11
- package/src/browser/navigation-client.ts +104 -68
- package/src/browser/navigation-store.ts +32 -9
- package/src/browser/navigation-transaction.ts +10 -28
- package/src/browser/partial-update.ts +64 -26
- package/src/browser/prefetch/cache.ts +183 -44
- package/src/browser/prefetch/fetch.ts +228 -37
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +30 -2
- package/src/browser/react/NavigationProvider.tsx +72 -31
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/location-state-shared.ts +175 -4
- package/src/browser/react/location-state.ts +39 -13
- package/src/browser/react/use-handle.ts +17 -9
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +20 -8
- package/src/browser/react/use-reverse.ts +106 -0
- package/src/browser/react/use-router.ts +22 -2
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/response-adapter.ts +32 -1
- package/src/browser/rsc-router.tsx +69 -22
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +23 -30
- package/src/browser/types.ts +21 -0
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +60 -35
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/index.ts +8 -1
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +95 -25
- package/src/build/route-types/codegen.ts +4 -4
- package/src/build/route-types/include-resolution.ts +1 -1
- package/src/build/route-types/per-module-writer.ts +7 -4
- package/src/build/route-types/router-processing.ts +55 -14
- package/src/build/route-types/scan-filter.ts +1 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-scope.ts +28 -42
- package/src/cache/cf/cf-cache-store.ts +54 -13
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +96 -205
- package/src/context-var.ts +5 -5
- package/src/decode-loader-results.ts +36 -0
- package/src/errors.ts +30 -4
- package/src/handle.ts +32 -14
- package/src/host/index.ts +2 -2
- package/src/host/router.ts +129 -57
- package/src/host/types.ts +31 -2
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +140 -21
- package/src/index.rsc.ts +10 -6
- package/src/index.ts +54 -17
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +25 -7
- package/src/loader.ts +16 -9
- package/src/missing-id-error.ts +68 -0
- package/src/outlet-context.ts +1 -1
- package/src/prerender.ts +27 -6
- package/src/response-utils.ts +37 -0
- package/src/reverse.ts +65 -36
- package/src/route-content-wrapper.tsx +6 -28
- package/src/route-definition/dsl-helpers.ts +384 -257
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +100 -28
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-types.ts +26 -41
- package/src/router/basename.ts +14 -0
- package/src/router/content-negotiation.ts +15 -2
- package/src/router/error-handling.ts +1 -1
- package/src/router/find-match.ts +54 -6
- package/src/router/handler-context.ts +21 -38
- package/src/router/intercept-resolution.ts +4 -18
- package/src/router/lazy-includes.ts +41 -22
- package/src/router/loader-resolution.ts +82 -36
- package/src/router/manifest.ts +41 -19
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +63 -20
- package/src/router/match-middleware/cache-lookup.ts +44 -91
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/match-result.ts +53 -32
- package/src/router/metrics.ts +1 -1
- package/src/router/middleware-types.ts +15 -26
- package/src/router/middleware.ts +99 -84
- package/src/router/pattern-matching.ts +116 -19
- package/src/router/prerender-match.ts +1 -1
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +4 -28
- package/src/router/revalidation.ts +58 -2
- package/src/router/router-interfaces.ts +45 -28
- package/src/router/router-options.ts +40 -1
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +27 -6
- package/src/router/segment-resolution/revalidation.ts +147 -106
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router/trie-matching.ts +40 -16
- package/src/router/types.ts +8 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +52 -30
- package/src/rsc/handler-context.ts +2 -2
- package/src/rsc/handler.ts +28 -69
- package/src/rsc/helpers.ts +91 -43
- package/src/rsc/index.ts +1 -1
- package/src/rsc/manifest-init.ts +28 -41
- package/src/rsc/origin-guard.ts +28 -10
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/response-error.ts +79 -12
- package/src/rsc/response-route-handler.ts +57 -61
- package/src/rsc/rsc-rendering.ts +35 -51
- package/src/rsc/runtime-warnings.ts +9 -10
- package/src/rsc/server-action.ts +17 -37
- package/src/rsc/ssr-setup.ts +16 -0
- package/src/rsc/types.ts +8 -2
- package/src/runtime-env.ts +18 -0
- package/src/search-params.ts +4 -4
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +132 -116
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +175 -53
- package/src/server/cookie-store.ts +28 -4
- package/src/server/request-context.ts +67 -51
- package/src/ssr/index.tsx +5 -1
- 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 +326 -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 +51 -0
- package/src/testing/flight.ts +234 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +106 -0
- package/src/testing/internal/context.ts +304 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +42 -0
- package/src/testing/render-handler.ts +323 -0
- package/src/testing/render-route.tsx +590 -0
- package/src/testing/run-loader.ts +363 -0
- package/src/testing/run-middleware.ts +205 -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 +285 -0
- package/src/types/global-namespace.ts +39 -26
- package/src/types/handler-context.ts +68 -50
- package/src/types/index.ts +1 -0
- package/src/types/loader-types.ts +11 -9
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +35 -2
- package/src/urls/include-helper.ts +34 -67
- package/src/urls/index.ts +1 -5
- package/src/urls/path-helper-types.ts +41 -7
- package/src/urls/path-helper.ts +17 -52
- package/src/urls/pattern-types.ts +36 -19
- package/src/urls/response-types.ts +22 -29
- package/src/urls/type-extraction.ts +58 -139
- package/src/urls/urls-function.ts +1 -5
- package/src/use-loader.tsx +413 -42
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +6 -6
- package/src/vite/discovery/discover-routers.ts +106 -75
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +67 -26
- package/src/vite/discovery/route-types-writer.ts +40 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/discovery/state.ts +33 -0
- package/src/vite/discovery/virtual-module-codegen.ts +13 -23
- package/src/vite/index.ts +2 -0
- package/src/vite/plugin-types.ts +67 -0
- package/src/vite/plugins/cjs-to-esm.ts +8 -7
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +28 -5
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-action-id.ts +54 -30
- package/src/vite/plugins/expose-id-utils.ts +12 -8
- package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
- package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +496 -486
- package/src/vite/plugins/performance-tracks.ts +29 -25
- package/src/vite/plugins/use-cache-transform.ts +65 -50
- package/src/vite/plugins/version-injector.ts +39 -23
- package/src/vite/plugins/version-plugin.ts +59 -2
- package/src/vite/plugins/virtual-entries.ts +2 -2
- package/src/vite/rango.ts +116 -29
- package/src/vite/router-discovery.ts +750 -100
- package/src/vite/utils/ast-handler-extract.ts +15 -15
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/bundle-analysis.ts +4 -2
- package/src/vite/utils/client-chunks.ts +190 -0
- package/src/vite/utils/forward-user-plugins.ts +193 -0
- package/src/vite/utils/manifest-utils.ts +8 -59
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +21 -6
- package/src/vite/utils/shared-utils.ts +107 -26
- package/src/browser/action-response-classifier.ts +0 -99
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Testing middleware — runMiddleware
|
|
2
|
+
|
|
3
|
+
**Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `middleware()` (see `/middleware`)
|
|
4
|
+
|
|
5
|
+
`runMiddleware` executes your chain through the router's REAL `executeLoaderMiddleware`, so `next()`, return-Response and throw-Response short-circuits, double-next guards, and header/cookie merge are production-identical. You SEED the request and any prior-middleware state (`vars`, `params`, `env`, `routeMap`); everything else (cookie/header merge, request-context resolution) is real machinery.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `RunMiddlewareOptions<TEnv>`
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| --------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `request` | `Request \| string` | The request the chain runs under: a `Request`, or a URL string (absolute or path). Optional — defaults to `http://localhost/`; pass it for path-, header-, or cookie-driven middleware. |
|
|
14
|
+
| `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. Your seam for doubling platform bindings (see `./bindings.md`). |
|
|
15
|
+
| `params` | `Record<string, string>` | Route params surfaced as `ctx.params`. |
|
|
16
|
+
| `vars` | `VarsInit` | Variables a prior middleware would have set (object form, or `[key, value]` tuples where `key` may be a `createVar()` handle). |
|
|
17
|
+
| `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`. |
|
|
18
|
+
| `routeName` | `string` | Matched route name surfaced as `ctx.routeName`. Does NOT enable scoped `.name` reverse: the chain's `reverse` is deliberately map-only, matching production app/response middleware. |
|
|
19
|
+
| `basename` | `string` | Router basename surfaced on the context (drives `redirect()` prefixing). |
|
|
20
|
+
| `theme` | `ThemeConfig \| true` | Theme config in the `createRouter({ theme })` shape; enables `ctx.theme`. |
|
|
21
|
+
| `next` | `() => Promise<Response>` | Terminal handler invoked when the chain calls `next()` all the way through. Defaults to a 200 empty Response. Use it to model the downstream route/handler response. |
|
|
22
|
+
| `cacheStore` | `SegmentCacheStore` | Cache store backing any `use cache` function a middleware invokes. Without it, `registerCachedFunction` bypasses, so the cached fn runs uncached and its taint/profile guards never fire. |
|
|
23
|
+
| `cacheProfiles` | `Record<string, CacheProfile>` | Cache profiles in the `createRouter({ cacheProfiles })` shape. |
|
|
24
|
+
|
|
25
|
+
### Context — `MiddlewareContext` (what your code receives)
|
|
26
|
+
|
|
27
|
+
The `ctx` your middleware reads. Notable fields:
|
|
28
|
+
|
|
29
|
+
| Field | Type | Meaning |
|
|
30
|
+
| --------------------------- | ----------------------- | ---------------------------------------------------------------------------------- |
|
|
31
|
+
| `params` | `TParams` | URL params from `opts.params`. |
|
|
32
|
+
| `env` | `TEnv` | Bindings from `opts.env`. |
|
|
33
|
+
| `get` / `set` | fns | Read/write context vars (shared with handlers); `get` resolves what `vars` seeded. |
|
|
34
|
+
| `header(name, value)` | fn | Queue a response header before `next()`, or set it directly after. |
|
|
35
|
+
| `reverse` | `ScopedReverseFunction` | URL-from-name. Map-only (no auto-fill); needs `routeMap`. |
|
|
36
|
+
| `setLocationState(entries)` | fn | Attach flash/location state to the response. |
|
|
37
|
+
| `theme` / `setTheme` | `Theme` / fn | Current theme; `undefined` unless `theme` is passed. |
|
|
38
|
+
| `routeName` | `string` | Matched route name (from `opts.routeName`). |
|
|
39
|
+
|
|
40
|
+
### Returns — `RunMiddlewareResult<TEnv>`
|
|
41
|
+
|
|
42
|
+
| Field | Type | Meaning |
|
|
43
|
+
| --------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
44
|
+
| `response` | `Response` | The final Response: the downstream response, or a middleware short-circuit. |
|
|
45
|
+
| `ctx` | `RequestContext<TEnv>` | The underlying RequestContext (NOT a per-middleware `MiddlewareContext`). Use `ctx.get(...)` for anything the envelope above doesn't surface. |
|
|
46
|
+
| `nextCalled` | `number` | Times the terminal handler ran: `0` on short-circuit, `1` on pass-through. |
|
|
47
|
+
| `cookies` | `Record<string, string>` | Effective cookie view: request cookies merged with chain sets/deletes (last-write-wins), as `{ name: value }`. |
|
|
48
|
+
| `headers` | `Record<string, string>` | Final response headers as `{ name: value }`, lowercased, EXCLUDING `set-cookie` (use `cookies`). |
|
|
49
|
+
| `locationState` | `Record<string, unknown>` | Flat `{ key: value }` state set via `setLocationState()` / `redirect({ state })` (empty when none). |
|
|
50
|
+
|
|
51
|
+
## Recipe
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { describe, it, expect } from "vitest";
|
|
55
|
+
import { runMiddleware } from "@rangojs/router/testing";
|
|
56
|
+
import type { Middleware } from "@rangojs/router";
|
|
57
|
+
|
|
58
|
+
const requireUser: Middleware = async (ctx, next) => {
|
|
59
|
+
if (!ctx.get("user")) return new Response(null, { status: 401 });
|
|
60
|
+
return next();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
describe("requireUser", () => {
|
|
64
|
+
it("passes through when the user is present", async () => {
|
|
65
|
+
const { response, nextCalled } = await runMiddleware(requireUser, {
|
|
66
|
+
request: "/dashboard",
|
|
67
|
+
vars: { user: { id: 1 } }, // object form; or [[key, value]] tuples (key may be a createVar())
|
|
68
|
+
});
|
|
69
|
+
expect(nextCalled).toBe(1);
|
|
70
|
+
expect(response.status).toBe(200);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("short-circuits (return OR throw Response) when unauthenticated", async () => {
|
|
74
|
+
const { response, nextCalled } = await runMiddleware(requireUser, {
|
|
75
|
+
request: "/dashboard",
|
|
76
|
+
});
|
|
77
|
+
expect(nextCalled).toBe(0);
|
|
78
|
+
expect(response.status).toBe(401);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Pass an array to run several in order. Cookies set inside middleware via the standalone `cookies().set(...)` (imported from `@rangojs/router`, NOT a `ctx` method) surface on the result's `cookies` and on the merged response `Set-Cookie`.
|
|
84
|
+
|
|
85
|
+
## Caveats
|
|
86
|
+
|
|
87
|
+
- No `handles`/`rendered` option by design: middleware runs BEFORE the render barrier, so it has no post-barrier `ctx.use(Handle)` access in production. Read handle data in a loader/handler and test it with `runLoader` (see `./handles.md`).
|
|
88
|
+
- A COMPONENT route's guard stack cannot be exercised through `dispatch` (it throws on component routes), and `renderToFlightString`/`renderRoute` don't run route middleware. Extract the middleware fn and unit-test it here, or assert the guard stack at e2e.
|
|
89
|
+
- Middleware-phase `ctx.reverse` is map-only (no auto-fill from current params), matching production — enable it with `routeMap`. `routeName` only feeds `ctx.routeName`; it does NOT scope `.name` reverse (the chain reverse stays map-only by design).
|
|
90
|
+
- `ctx.theme` is `undefined` unless `theme` is passed; `redirect()` does no basename prefixing unless `basename` is seeded.
|
|
91
|
+
- Platform bindings are yours to double via `env` (see `./bindings.md`).
|
|
92
|
+
|
|
93
|
+
## See also
|
|
94
|
+
|
|
95
|
+
- `/middleware` — the DSL this tests
|
|
96
|
+
- Siblings: `./response-routes.md`, `./server-actions.md`, `./loader.md`, `./bindings.md`
|
|
97
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "Middleware"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Testing a route handler — renderHandler
|
|
2
|
+
|
|
3
|
+
**Layer:** RSC unit (react-server project) · **Import:** `@rangojs/router/testing/flight` · **DSL it tests:** a route handler `(ctx) => rsc` (see `/route`)
|
|
4
|
+
|
|
5
|
+
A Rango route handler is a pure function `(ctx) => rsc` — the function you pass to `path("/p/:slug", ProductPage)`, NOT a component. `renderHandler` runs it with the REAL `HandlerContext` the router builds at runtime (so `ctx.params`, `ctx.use(Loader)`, `ctx.use(Meta)`, `ctx.reverse`, `ctx.get`, response headers via `ctx.headers`, and the standalone `cookies()` all work), serializes the returned RSC, and deserializes it to an inspectable tree. The render and effects are real; loaders are SEEDED (no real loader runs — same model as `runLoader`).
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `RenderHandlerOptions`
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| ------------------ | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `params` | `Record<string, string>` | Route params surfaced as `ctx.params`. |
|
|
14
|
+
| `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. |
|
|
15
|
+
| `request` | `Request \| string` | Backing Request (string or `Request`); defaults to a localhost GET. |
|
|
16
|
+
| `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) the handler reads via `cookies()`. |
|
|
17
|
+
| `vars` | `VarsInit` (object or `[[Var, value]]` tuples) | Variables a prior middleware set, read via `ctx.get(...)`. |
|
|
18
|
+
| `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
|
|
19
|
+
| `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`. |
|
|
20
|
+
| `loaders` | `ReadonlyArray<readonly [LoaderDefinition, unknown]>` | Seed the data `ctx.use(SomeLoader)` returns. Matched by loader reference; NO real loader runs. |
|
|
21
|
+
| `clientComponents` | `Record<string, unknown>` | `"use client"` components in the handler's RSC, so they serialize as real boundaries when `rangoUseClientTransform()` is not wired. Keyed by name. |
|
|
22
|
+
|
|
23
|
+
### Context — `HandlerContext` (what your handler receives)
|
|
24
|
+
|
|
25
|
+
| Field | Type | Meaning |
|
|
26
|
+
| ------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------- |
|
|
27
|
+
| `params` | `Record<string, string>` | The seeded route params. |
|
|
28
|
+
| `env` | `TEnv` | The seeded environment bindings. |
|
|
29
|
+
| `request` | `Request` | The backing request. |
|
|
30
|
+
| `searchParams` | `URLSearchParams` | Parsed query of `request.url`. |
|
|
31
|
+
| `pathname` | `string` | Pathname of `request.url`. |
|
|
32
|
+
| `url` | `URL` | Parsed `request.url`. |
|
|
33
|
+
| `routeName` | `string \| undefined` | The matched route name (from `routeName`). |
|
|
34
|
+
| `use` | `(loaderOrHandle) => data \| pushFn` | A loader returns its seeded data; a handle returns a push fn that RECORDS to `result.handles`. |
|
|
35
|
+
| `reverse` | `(name, params?) => string` | Build a URL from `routeMap`. |
|
|
36
|
+
| `get` | `(Var) => value` | Read a seeded `vars` variable. |
|
|
37
|
+
| `headers` | `Headers` | Response headers; set via `ctx.headers.set(...)` (merged into `result.response`). |
|
|
38
|
+
| `setLocationState` | `(entries) => void` | Set location state (surfaced on `result.locationState`). |
|
|
39
|
+
| `waitUntil` | `(promise) => void` | Register background work. |
|
|
40
|
+
|
|
41
|
+
### Returns — `RenderHandlerResult`
|
|
42
|
+
|
|
43
|
+
| Field | Type | Meaning |
|
|
44
|
+
| --------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
45
|
+
| `tree` | `unknown` | Deserialized RSC the handler returned; `undefined` when it returned/threw a `Response`. Inspect with `findClientBoundaries`. |
|
|
46
|
+
| `flight` | `string \| undefined` | Raw Flight wire string; `undefined` on a `Response`. |
|
|
47
|
+
| `thrown` | `unknown` | The value the handler THREW (a `redirect()`/`notFound()` Response), captured not re-thrown. |
|
|
48
|
+
| `response` | `Response` | Merged Response (status + headers + Set-Cookie), folding a thrown/returned redirect with accumulated effects. |
|
|
49
|
+
| `cookies` | `Record<string, string>` | Effective cookie view after the handler ran. |
|
|
50
|
+
| `headers` | `Record<string, string>` | Response headers (excludes set-cookie; includes a redirect `Location`). |
|
|
51
|
+
| `locationState` | `Record<string, unknown>` | Location state the handler set (`ctx.setLocationState`/`redirect({ state })`). |
|
|
52
|
+
| `handles` | `Map<Handle, unknown[]>` | What the handler pushed via `ctx.use(Handle)(...)` (e.g. `Meta`, `Breadcrumbs`), keyed by handle. |
|
|
53
|
+
|
|
54
|
+
## Recipe
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import {
|
|
58
|
+
renderHandler,
|
|
59
|
+
findClientBoundaries,
|
|
60
|
+
} from "@rangojs/router/testing/flight";
|
|
61
|
+
import { ProductPage } from "../src/pages/product"; // the real handler: (ctx) => rsc
|
|
62
|
+
import { ProductLoader } from "../src/loaders/product";
|
|
63
|
+
import { Tenant } from "../src/middleware/tenant";
|
|
64
|
+
import { Meta } from "../src/handles";
|
|
65
|
+
|
|
66
|
+
it("renders the product page for a tenant", async () => {
|
|
67
|
+
const { tree, handles } = await renderHandler(ProductPage, {
|
|
68
|
+
params: { slug: "wine" },
|
|
69
|
+
loaders: [[ProductLoader, { name: "Wine", price: 9 }]], // seeds ctx.use(ProductLoader)
|
|
70
|
+
vars: [[Tenant, { name: "Acme" }]], // seeds ctx.get(Tenant)
|
|
71
|
+
routeMap: { product: "/p/:slug" }, // enables ctx.reverse
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(JSON.stringify(tree)).toContain("Wine");
|
|
75
|
+
const [counter] = findClientBoundaries(tree, "Counter"); // islands inspectable too
|
|
76
|
+
expect(handles.get(Meta)).toEqual([{ title: "Wine - Shop" }]); // ctx.use(Meta) pushes
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("captures a guarded redirect", async () => {
|
|
80
|
+
const { thrown, response } = await renderHandler(ProductPage, {
|
|
81
|
+
params: { slug: "missing" },
|
|
82
|
+
loaders: [[ProductLoader, null]],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(thrown).toBeInstanceOf(Response); // throw redirect() is captured, not re-thrown
|
|
86
|
+
expect(response.status).toBe(302);
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Caveats
|
|
91
|
+
|
|
92
|
+
- An unseeded `ctx.use(loader)` REJECTS with a setup error — seed every dependency via `{ loaders: [[OtherLoader, data]] }`, matched by reference. Loaders are SEEDED, not executed (same as `runLoader`).
|
|
93
|
+
- Same alias requirement as flight tests: without the `@rangojs/router -> index.rsc.ts` alias (see [`./setup.md`](./setup.md)), a handler reading `getRequestContext()`/`cookies()` hits the throwing out-of-react-server stub. Symptom: `tree: undefined` with the stub error on `thrown`.
|
|
94
|
+
- A `throw redirect()` is captured on `thrown` (with `tree` undefined, since it produced a `Response`) — assert on `thrown`/`response`, no try/catch needed.
|
|
95
|
+
- No hydration and no interaction — for clicks, forms, and navigation use e2e.
|
|
96
|
+
- `renderHandler` runs a handler FUNCTION `(ctx) => rsc`; for a plain ELEMENT `<Page/>` use `renderServerTree` (see [`./server-tree.md`](./server-tree.md)).
|
|
97
|
+
|
|
98
|
+
## See also
|
|
99
|
+
|
|
100
|
+
- `/route` — the DSL this tests
|
|
101
|
+
- Siblings: [`./server-tree.md`](./server-tree.md), [`./server-actions.md`](./server-actions.md), [`./setup.md`](./setup.md), [`./loader.md`](./loader.md)
|
|
102
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "renderHandler — run a real route handler and assert its RSC"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Testing a response route / redirect — dispatch
|
|
2
|
+
|
|
3
|
+
**Layer:** integration (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** response routes (json/text/html/xml/md), redirects, 404 (see `/response-routes`, `/mime-routes`)
|
|
4
|
+
|
|
5
|
+
`dispatch` runs the router's REAL matching (reusing `previewMatch`) and the real global + route-level middleware chain, with no RSC render — so redirects, 404s, response routes, content negotiation, and middleware short-circuits behave exactly as in production. You SEED the request and `env`; everything else (matching, middleware, header/cookie merge) is real machinery.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `DispatchOptions<TEnv>`
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| -------------------- | ------------------- | ---------------------------------------------------------------------------------- |
|
|
13
|
+
| `request` (required) | `Request \| string` | The request to dispatch: a `Request`, or a URL string (absolute or path). |
|
|
14
|
+
| `env` | `TEnv` | Environment bindings forwarded to matching and middleware (surfaced as `ctx.env`). |
|
|
15
|
+
|
|
16
|
+
### Context — response-handler `ctx` (what your code receives)
|
|
17
|
+
|
|
18
|
+
The lightweight context a RESPONSE-route handler reads (mirrors the production `handleResponseRoute` shape). Notable fields:
|
|
19
|
+
|
|
20
|
+
| Field | Type | Meaning |
|
|
21
|
+
| --------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
|
22
|
+
| `request` | `Request` | The dispatched request. |
|
|
23
|
+
| `params` | `Record<string, string>` | URL params from the matched route. |
|
|
24
|
+
| `env` | `TEnv` | Bindings from `opts.env`. |
|
|
25
|
+
| `searchParams` | `URLSearchParams` | Query params with internal `_rsc*` params stripped. |
|
|
26
|
+
| `url` | `URL` | Cleaned request URL (internal `_rsc*` params removed). |
|
|
27
|
+
| `pathname` | `string` | Matched pathname. |
|
|
28
|
+
| `reverse` | `ReverseFunction` | URL-from-name. Map-only (NO auto-fill from current params), matching the production response-route handler. |
|
|
29
|
+
| `get` | fn | Read context vars set by prior middleware. |
|
|
30
|
+
| `header(name, value)` | fn | Set a response header; surfaces on the returned `Response`. |
|
|
31
|
+
| `waitUntil` | fn | Register a deferred task (no-op fidelity in tests). |
|
|
32
|
+
|
|
33
|
+
### Returns — `dispatch(router, opts) -> Promise<Response>`
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
function dispatch<TEnv = any>(
|
|
37
|
+
router: Rango<TEnv, any>,
|
|
38
|
+
opts: DispatchOptions<TEnv>,
|
|
39
|
+
): Promise<Response>;
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
A real `Response`: response-route body, a 308 redirect (`Location`), a 404, or a middleware short-circuit. A `path.json` handler that returns a bare value is serialized verbatim (no envelope); a returned `Response` passes through unchanged; cookies and `ctx.header(...)` surface on the `Response`. `dispatch` accepts your public router type directly (no cast).
|
|
43
|
+
|
|
44
|
+
## Recipe
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { describe, it, expect } from "vitest";
|
|
48
|
+
import { dispatch } from "@rangojs/router/testing";
|
|
49
|
+
import { createRouter } from "@rangojs/router";
|
|
50
|
+
import { apiPatterns } from "../src/api/urls"; // path.json(...) routes, no Prerender
|
|
51
|
+
|
|
52
|
+
const router = createRouter().routes(apiPatterns);
|
|
53
|
+
|
|
54
|
+
describe("api routes via dispatch", () => {
|
|
55
|
+
it("serializes a JSON response route as the bare handler value", async () => {
|
|
56
|
+
const res = await dispatch(router, { request: "/health" });
|
|
57
|
+
expect(res.status).toBe(200);
|
|
58
|
+
expect(res.headers.get("content-type")).toBe(
|
|
59
|
+
"application/json;charset=utf-8",
|
|
60
|
+
);
|
|
61
|
+
expect(await res.json()).toEqual({ status: "ok" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("maps a thrown RouterError to its status + RFC 9457 problem+json", async () => {
|
|
65
|
+
const res = await dispatch(router, { request: "/products/999" }); // handler throws RouterError 404
|
|
66
|
+
expect(res.status).toBe(404);
|
|
67
|
+
expect(res.headers.get("content-type")).toBe(
|
|
68
|
+
"application/problem+json;charset=utf-8",
|
|
69
|
+
);
|
|
70
|
+
expect((await res.json()).code).toBe("NOT_FOUND"); // { title, status, detail, code }
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns 404 for an unmatched path", async () => {
|
|
74
|
+
expect((await dispatch(router, { request: "/nope" })).status).toBe(404);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`dispatch` also covers trailing-slash/redirect targets (`findMatch`) — a redirected path returns a 308 with the `Location` (query preserved). Pass `env` via `{ env }`.
|
|
80
|
+
|
|
81
|
+
## Caveats
|
|
82
|
+
|
|
83
|
+
- Hitting a COMPONENT (RSC) route throws a clear directive error: `dispatch` is for response routes + redirects + 404 + content negotiation, plus the global + route-level middleware guard stack on RESPONSE routes — it never renders React. Use Flight primitives or e2e to exercise component rendering.
|
|
84
|
+
- A COMPONENT route's guard stack cannot run here. Assert it at e2e, or extract the middleware fn and unit-test it with `runMiddleware` (see `./middleware.md`).
|
|
85
|
+
- JSON serialization is bare, applied in `response-route-handler.ts`: a `path.json` handler that returns a value is serialized verbatim (`JSON.stringify(value)`, status 200, `application/json`) — no envelope. Returning a `Response` (e.g. `Response.json(x)`) passes through unchanged. A thrown error yields an RFC 9457 problem+json body `{ title, status, detail, code }` (`application/problem+json`) with the error's status (`RouterError.status`, else 500, or the effective `ctx.setStatus()` override); `code` is the `RouterError.code`, else `"INTERNAL"`. The `type` member is omitted this phase. Assert the shape matching what your handler returns.
|
|
86
|
+
- Setup: needs the preset (alias + virtual stubs) or a Vite-RSC env (see `./setup.md`); a bare router import throws on Vite virtuals.
|
|
87
|
+
- A router using `Prerender()`/`createLoader()`/`Static()` now constructs in a bare test (each assigns a runtime fallback `$$id`). Importing the whole router _file_ may still need the plugin (its page modules pull app deps / `virtual:` modules) — build from a focused include (your API routes) for whole-router dispatch.
|
|
88
|
+
- A `_rsc_partial` request to a response route runs global middleware first (an auth gate can still 401/redirect), then returns `X-RSC-Reload` — route-level middleware is skipped, exactly like production.
|
|
89
|
+
|
|
90
|
+
## See also
|
|
91
|
+
|
|
92
|
+
- `/response-routes`, `/mime-routes` — the DSL this tests
|
|
93
|
+
- Siblings: `./middleware.md`, `./setup.md`, `./cache-prerender.md`
|
|
94
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "dispatch — request to Response" (the `rangoTestConfig` preset stubs `@vitejs/plugin-rsc/rsc`, so no per-file `vi.mock` is needed)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Testing reverse/href and type-level contracts
|
|
2
|
+
|
|
3
|
+
**Layer:** unit (node) + typecheck · **Import:** `@rangojs/router/client` (useReverse), `@rangojs/router/testing` (assertGeneratedRoutesMatch) · **DSL it tests:** `reverse`/`href`/`useReverse` (see `/typesafety`, `/links`)
|
|
4
|
+
|
|
5
|
+
The reverse/href/params/env types are a real contract: a wrong route name, a missing param, or an unknown env binding should be a COMPILE error, not a runtime surprise. The type-test recipes have no runtime API — `tsc --noEmit` IS the assertion. `assertGeneratedRoutesMatch` is the one runtime helper here: it runs the router's real matching to expand lazy includes, then diffs the live `routeMap` against the generated named-routes map you seed.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `assertGeneratedRoutesMatch(router, generatedMap?)`
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| -------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `router` | `{ routeMap; findMatch? }` | Your router (real impl). `routeMap` is the live name→pattern map; `findMatch` (when present) is called to force-expand lazy `include()`d routes. |
|
|
14
|
+
| `generatedMap` | `Record<string, unknown>` (optional) | The imported `*.named-routes.gen.ts` map (name→pattern, or `{ path }` objects). Omit to diff against the global route map (`getGlobalRouteMap()`) instead. |
|
|
15
|
+
|
|
16
|
+
### Context — `GeneratedRoutesDiff` (what `diffGeneratedRoutes` returns)
|
|
17
|
+
|
|
18
|
+
| Field | Type | Meaning |
|
|
19
|
+
| ---------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
20
|
+
| `missing` | `string[]` | Names in the generated map but absent at runtime (stale generated entry). |
|
|
21
|
+
| `extra` | `string[]` | Names at runtime but absent from the generated map (ungenerated route). Auto-generated internal names (`$path_*`/`$prefix_*`) are excluded. |
|
|
22
|
+
| `mismatch` | `[name, generated, runtime][]` | Names in both whose patterns differ. |
|
|
23
|
+
| `ok` | `boolean` | True when `missing`, `extra`, and `mismatch` are all empty. |
|
|
24
|
+
|
|
25
|
+
### Returns — `assertGeneratedRoutesMatch`
|
|
26
|
+
|
|
27
|
+
`void` on match. On drift, throws an `Error` listing every missing, extra, and mismatched route plus a "regenerate the `*.named-routes.gen.ts` file" hint. (`diffGeneratedRoutes` returns the `GeneratedRoutesDiff` above without throwing.)
|
|
28
|
+
|
|
29
|
+
## Recipe
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// 1. Negative assertions inline with @ts-expect-error — the directive ERRORS if
|
|
33
|
+
// the line below it ever starts compiling (i.e. if the type guard regresses).
|
|
34
|
+
// Validated by `tsc --noEmit`; a runtime test cannot assert this.
|
|
35
|
+
import { useReverse } from "@rangojs/router/client";
|
|
36
|
+
|
|
37
|
+
const reverse = useReverse({ post: "/blog/:slug" });
|
|
38
|
+
reverse("post", { slug: "hi" }); // ok
|
|
39
|
+
// @ts-expect-error - missing required :slug param
|
|
40
|
+
reverse("post", {});
|
|
41
|
+
// @ts-expect-error - "comment" is not a route in this map
|
|
42
|
+
reverse("comment", { id: "1" });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// 2. Positive assertions with vitest's expectTypeOf — pin an INFERRED type
|
|
47
|
+
// (loader return, parsed search schema, RouteParams) inside a normal *.test.ts.
|
|
48
|
+
import { expectTypeOf } from "vitest";
|
|
49
|
+
import type { RouteParams } from "@rangojs/router";
|
|
50
|
+
|
|
51
|
+
// RouteParams takes a route NAME and a route map (defaulting to the global map).
|
|
52
|
+
// Pass an explicit map to keep the type test self-contained.
|
|
53
|
+
expectTypeOf<
|
|
54
|
+
RouteParams<"blogPost", { blogPost: "/blog/:slug" }>
|
|
55
|
+
>().toEqualTypeOf<{ slug: string }>();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// 3. assertGeneratedRoutesMatch — a one-liner whole-app drift test. Real
|
|
60
|
+
// matching expands lazy include()d routes before the diff.
|
|
61
|
+
import { it } from "vitest";
|
|
62
|
+
import { assertGeneratedRoutesMatch } from "@rangojs/router/testing";
|
|
63
|
+
import { router } from "../src/router";
|
|
64
|
+
import generated from "../src/router.named-routes.gen";
|
|
65
|
+
|
|
66
|
+
it("generated named-routes map is in sync with the router", () => {
|
|
67
|
+
assertGeneratedRoutesMatch(router, generated);
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
For a large type-only suite, collect recipe-1/2 assertions in `*.test-d.ts` files and add a `tsconfig.types.json` that `extends` your base config and `include`s only those files, then run `tsc -p tsconfig.types.json --noEmit` in CI. This is how the repo pins its own augmentation contracts. Recipe 1 is enough for most apps; reach for the dedicated tsconfig only when inline assertions clutter runtime tests.
|
|
72
|
+
|
|
73
|
+
## Caveats
|
|
74
|
+
|
|
75
|
+
- Type tests run at TYPECHECK time (`tsc --noEmit`), NOT in the vitest runner. They are their own layer — wire them into CI as a real step (`pnpm run typecheck`). A type test nobody runs is just a comment.
|
|
76
|
+
- `@ts-expect-error` ERRORS if the line below it ever starts compiling, so a regressed guard fails the typecheck. A runtime test cannot assert "this should not type-check".
|
|
77
|
+
- `assertGeneratedRoutesMatch` force-expands lazy `include()`d routes (calls `findMatch` on a concrete path derived from each generated pattern) before diffing — otherwise every included route reads as a false `missing`. This makes the whole-app drift check work in a plain unit test. Routers without `findMatch` (a bare `{ routeMap }`) are left as-is.
|
|
78
|
+
|
|
79
|
+
## See also
|
|
80
|
+
|
|
81
|
+
- `/typesafety`, `/links` — the DSL this tests
|
|
82
|
+
- Siblings: `./client-components.md`, `./loader.md`
|
|
83
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "Type-level tests — make misuse fail to compile"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Testing a server action — runInRequestContext
|
|
2
|
+
|
|
3
|
+
**Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `"use server"` action (see `/server-actions`)
|
|
4
|
+
|
|
5
|
+
`runInRequestContext(fn, opts)` builds a real `RequestContext` (the same `createRequestContext` the RSC handler uses) AND enters it around `fn`, so an action that calls `getRequestContext()` / `cookies()` / `ctx.get(var)` runs with production fidelity. You SEED the request, env, and vars; the REAL machinery is cookie/header accumulation, location-state, and redirect/notFound throwing.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `CreateTestContextOptions<TEnv>`
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| --------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `env` | `TEnv` | Platform bindings the action reads (`ctx.env`). Default `{}`. Double them yourself (see `./bindings.md`). |
|
|
14
|
+
| `request` | `Request \| string` | The request to run under. A `string` becomes `new Request(url)`; pass a full `Request` to seed a `Cookie` header. Default origin `http://localhost/`. |
|
|
15
|
+
| `requestInit` | `RequestInit` | Init merged when `request` is a string (e.g. `{ method, headers, body }`). |
|
|
16
|
+
| `variables` | `Record<string, unknown>` | Raw backing store for `ctx.get()` / `ctx.set()`, pre-seeded from `vars`. |
|
|
17
|
+
| `vars` | `VarsInit` | Vars a prior middleware would have set (object or `[token, value]` list). |
|
|
18
|
+
| `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()` without global state. |
|
|
19
|
+
| `routeName` | `string` | Current route name (drives `ctx.reverse()` self-references). |
|
|
20
|
+
| `params` | `Record<string, string>` | Route params on `ctx.params`. |
|
|
21
|
+
| `basename` | `string` | Router basename, normalized exactly like `createRouter({ basename })`; drives `redirect()` prefixing. Default `undefined`. |
|
|
22
|
+
| `cacheStore` | `SegmentCacheStore` | Backing store for `use cache` functions (same shape as `createRouter({ cache })`). Without it, cached functions run uncached and their guards never fire. |
|
|
23
|
+
| `cacheProfiles` | `Record<string, CacheProfile>` | Profiles for `use cache: "name"`, same shape as `createRouter({ cacheProfiles })`. An unknown profile throws. |
|
|
24
|
+
| `theme` | `ThemeConfig \| true` | Theme config (same shape as `createRouter({ theme })`). Without it `ctx.theme` / `ctx.setTheme` are inert. |
|
|
25
|
+
|
|
26
|
+
### Context — `RequestContext<TEnv>` (what your code receives)
|
|
27
|
+
|
|
28
|
+
`fn` receives `ctx`, the full entered `RequestContext`; the same object resolves via `getRequestContext()` inside `fn`. Notable fields:
|
|
29
|
+
|
|
30
|
+
| Field | Type | Meaning |
|
|
31
|
+
| ------------------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| `env` | `TEnv` | The seeded platform bindings. |
|
|
33
|
+
| `request` | `Request` | The concrete request the run is bound to. |
|
|
34
|
+
| `cookies()` | `() => Record<string, string>` | @internal effective cookie view. To read or queue cookies inside the action, use the standalone `cookies()` from `@rangojs/router` (`cookies().get(name)` / `cookies().set(...)`), which returns a `CookieStore`. |
|
|
35
|
+
| `get(token)` / `set(token, v)` | accessor | Read/write request-scoped vars (seeded from `vars` / `variables`). |
|
|
36
|
+
| `params` | `Record<string, string>` | Seeded route params. |
|
|
37
|
+
| `reverse(name, params?)` | function | Build a URL from `routeMap` (when seeded). |
|
|
38
|
+
| `header(name, value)` | function | Queue a response header. |
|
|
39
|
+
| `setLocationState(...)` | function | Set the flash / location state the client reads. |
|
|
40
|
+
| `theme`/`setTheme` | — | Theme accessors, inert unless `theme` is seeded. |
|
|
41
|
+
|
|
42
|
+
### Returns — `RunInRequestContextResult<T>`
|
|
43
|
+
|
|
44
|
+
| Field | Type | Meaning |
|
|
45
|
+
| --------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
46
|
+
| `result` | `T \| undefined` | `fn`'s awaited return, or `undefined` if it threw. |
|
|
47
|
+
| `thrown` | `unknown` | What `fn` threw (a redirect / `notFound` `Response` on the success path), or `undefined`. Captured, NOT re-thrown — assert on it. |
|
|
48
|
+
| `response` | `Response` | The merged `Response` (status + headers + Set-Cookie). On a thrown redirect, that redirect's `Location` merged with the accumulated cookies/headers. |
|
|
49
|
+
| `cookies` | `Record<string, string>` | Effective cookie view: request cookies + run mutations, last-write-wins. |
|
|
50
|
+
| `headers` | `Record<string, string>` | Response headers the run set (plus a thrown redirect's `Location`), EXCLUDING `set-cookie` (use `cookies`). Names lowercased. |
|
|
51
|
+
| `locationState` | `Record<string, unknown>` | The flash set via `ctx.setLocationState()` / `redirect({ state })`, as the flat `{ key: value }` the client reads. |
|
|
52
|
+
|
|
53
|
+
Low-level variant: when you already hold a context from `createTestRequestContext(opts)`, call `runWithRequestContext(ctx, fn)` (re-exported from `@rangojs/router/testing`) to enter it directly. `runInRequestContext` is the one-call convenience over `createTestRequestContext` + `runWithRequestContext`.
|
|
54
|
+
|
|
55
|
+
## Recipe
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { it, expect } from "vitest";
|
|
59
|
+
import { runInRequestContext } from "@rangojs/router/testing";
|
|
60
|
+
import { loginAction } from "../src/actions/login"; // sets a session cookie + flash, then throw redirect("/app")
|
|
61
|
+
|
|
62
|
+
it("sets the session cookie + flash and redirects", async () => {
|
|
63
|
+
const { thrown, cookies, locationState } = await runInRequestContext(
|
|
64
|
+
() => loginAction(input),
|
|
65
|
+
{
|
|
66
|
+
env,
|
|
67
|
+
request: new Request("https://app.test/admin", {
|
|
68
|
+
headers: { Cookie: "sid=abc" },
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
expect((thrown as Response).headers.get("Location")).toBe("/app"); // redirected
|
|
73
|
+
expect(cookies.session).toBeDefined(); // cookie set before the throw, no @internal cast
|
|
74
|
+
expect(locationState).toEqual({ flash: { text: "Welcome back" } });
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Caveats
|
|
79
|
+
|
|
80
|
+
- The snapshot fires whether `fn` RETURNS or THROWS. A `throw redirect("/app")` on the success path is captured on `thrown` (NOT re-thrown), so no try/catch is needed; assert on `thrown` for a throwing action.
|
|
81
|
+
- There is no cookies / headers option. Seed a request cookie by passing a full `Request` with the `Cookie` header (as in the recipe).
|
|
82
|
+
- `runWithRequestContext(ctx, fn)` is the low-level entry when you already hold a context; `runInRequestContext` is the one-call convenience over `createTestRequestContext` + `runWithRequestContext`.
|
|
83
|
+
- Platform bindings are yours to double via `env` (see `./bindings.md`).
|
|
84
|
+
|
|
85
|
+
## See also
|
|
86
|
+
|
|
87
|
+
- `/server-actions` — the DSL this tests
|
|
88
|
+
- Siblings: `./render-handler.md`, `./middleware.md`, `./loader.md`, `./bindings.md`
|
|
89
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "runInRequestContext — the handler / server-action test primitive"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Inspecting the rendered tree — renderServerTree, findClientBoundaries, findElements
|
|
2
|
+
|
|
3
|
+
**Layer:** RSC unit (react-server project) · **Import:** `@rangojs/router/testing/flight` · **DSL it tests:** client islands across the boundary + server-rendered host content (see `/route`)
|
|
4
|
+
|
|
5
|
+
`renderServerTree` serializes the real Flight (identical bytes to `renderToFlightString`) and then deserializes it back to an inspectable React element tree you traverse — that serialize/deserialize round-trip is REAL; what you SEED is the element you render plus the request context (`request`/`headers`/`params`/`vars`/`env`). The win over the wire string: a client boundary's props come back as real JS values (a `Date` is a `Date`, not the opaque `$D...` encoding) and you can confirm a `"use client"` component actually crossed the boundary (an `I` row) instead of being inlined. There is NO hydration and NO interaction — boundaries are inert placeholders carrying props.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `RenderServerTreeOptions` (extends `RenderToFlightStringOptions`)
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| ------------------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `request` | `Request \| string` | The request the render runs under (absolute URL, path, or a `Request`). Defaults to `http://localhost/`. A server component reading `getRequestContext()` sees this url/cookies. A passed `Request`'s headers win; `headers` is then ignored. |
|
|
14
|
+
| `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) visible to the server tree, when `request` is a string. |
|
|
15
|
+
| `env` | `unknown` | Env / bindings exposed as `ctx.env`. Defaults to `{}`. |
|
|
16
|
+
| `params` | `Record<string, string>` | Route params exposed via `ctx.params` and loader contexts. |
|
|
17
|
+
| `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
|
|
18
|
+
| `vars` | `VarsInit` | Context variables visible via `ctx.get(...)`, as a prior middleware would have set them. Object form (`{ user }`) or `[key, value]` tuples. |
|
|
19
|
+
| `clientComponents` | `Record<string, unknown>` | The `"use client"` components reachable from the tree, keyed by the boundary name to register each as a client reference (in place) so it serializes as an `I` row. Omit when `rangoUseClientTransform()` auto-discovers them, or for pure server-only trees. First-wins per worker; already-registered references are left untouched. |
|
|
20
|
+
|
|
21
|
+
### Context — what your code receives
|
|
22
|
+
|
|
23
|
+
A server component rendered here runs under a real request context: `getRequestContext()` resolves, `ctx.params`/`ctx.routeName`/`ctx.env` reflect the options, `ctx.get(MyVar)` reads a seeded `var`, and cookies come off the request. Same seeding as the handler-test primitives — you render an **element** you build (`<Page />`); to run a route **handler** `(ctx) => rsc` use `renderHandler` (see `./render-handler.md`).
|
|
24
|
+
|
|
25
|
+
### Returns — `RenderServerTreeResult`
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
renderServerTree(element, opts?): Promise<{ flight: string; tree: unknown }>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Field | Type | Meaning |
|
|
32
|
+
| -------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| `flight` | `string` | The raw Flight wire string (so `toMatchFlight` assertions still apply). |
|
|
34
|
+
| `tree` | `unknown` | The deserialized React element tree. Server elements are plain React elements; each client boundary is an inert placeholder whose `props` are the real deserialized JS values that crossed the boundary. |
|
|
35
|
+
|
|
36
|
+
#### `findClientBoundaries(tree, selector?) -> ClientBoundary[]`
|
|
37
|
+
|
|
38
|
+
Every client boundary in document order; always an array (no throw on zero/many — destructure `const [tag] = ...` and assert `.length` when the count matters; no match yields `[]`). `selector` is a STRING (match by export name) or a `BoundarySelector` object, criteria AND-ed.
|
|
39
|
+
|
|
40
|
+
| `BoundarySelector` | Type | Meaning |
|
|
41
|
+
| ------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
42
|
+
| `name` | `string` | Match the boundary's export name (same as a bare string). |
|
|
43
|
+
| `testId` | `string` | Match `props["data-testid"]` exactly (a `data-testid` you passed AS A PROP to the island). |
|
|
44
|
+
| `props` | `Record<string, unknown>` | Subset deep-equal match (Date/Map/Set/array/nested-object aware); unlisted props ignored. |
|
|
45
|
+
| `where` | `(boundary: ClientBoundary) => boolean` | Arbitrary predicate. |
|
|
46
|
+
|
|
47
|
+
`ClientBoundary` = `{ id, name, props (excludes children), children, element }`.
|
|
48
|
+
|
|
49
|
+
#### `findElements(tree, selector?) -> FoundElement[]`
|
|
50
|
+
|
|
51
|
+
Every SERVER/HOST element a server component produced (`<article>`, `<h2>`), in document order; always an array. `selector` is a host TAG string (`"h2"`) or an `ElementSelector` object, criteria AND-ed.
|
|
52
|
+
|
|
53
|
+
| `ElementSelector` | Type | Meaning |
|
|
54
|
+
| ----------------- | ------------------------------------ | ---------------------------------------------------------------------------------- |
|
|
55
|
+
| `tag` | `string` | Match the host tag name (`"article"`, `"h2"`). |
|
|
56
|
+
| `testId` | `string` | Match `props["data-testid"]` exactly (on a host element). |
|
|
57
|
+
| `props` | `Record<string, unknown>` | Subset deep-equal match (Date/Map/Set/array/nested aware). |
|
|
58
|
+
| `text` | `string \| RegExp` | Match the element's text content (substring for a string, `.test()` for a RegExp). |
|
|
59
|
+
| `where` | `(element: FoundElement) => boolean` | Arbitrary predicate. |
|
|
60
|
+
|
|
61
|
+
`FoundElement` = `{ tag, props (excludes children), children, text, element }`.
|
|
62
|
+
|
|
63
|
+
#### `textContent(node) -> string`
|
|
64
|
+
|
|
65
|
+
Concatenates every string/number leaf of a node's subtree in document order — the clean way to assert rendered text, instead of `JSON.stringify(tree).toContain(...)`.
|
|
66
|
+
|
|
67
|
+
## Recipe
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { it, expect } from "vitest";
|
|
71
|
+
import {
|
|
72
|
+
renderServerTree,
|
|
73
|
+
findClientBoundaries,
|
|
74
|
+
findElements,
|
|
75
|
+
textContent,
|
|
76
|
+
} from "@rangojs/router/testing/flight";
|
|
77
|
+
import { PriceTag } from "./PriceTag.js"; // a "use client" component (any filename)
|
|
78
|
+
|
|
79
|
+
async function ProductPanel({ amount, asOf }: { amount: number; asOf: Date }) {
|
|
80
|
+
await Promise.resolve();
|
|
81
|
+
return (
|
|
82
|
+
<article>
|
|
83
|
+
<h2>Wine</h2>
|
|
84
|
+
<PriceTag amount={amount} currency="USD" asOf={asOf} />
|
|
85
|
+
</article>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
it("client props survive the serialize -> deserialize round trip", async () => {
|
|
90
|
+
const { flight, tree } = await renderServerTree(
|
|
91
|
+
<ProductPanel amount={19.5} asOf={new Date("2026-01-02T00:00:00Z")} />,
|
|
92
|
+
// Omit clientComponents when rangoUseClientTransform() is wired (see ./setup.md);
|
|
93
|
+
// otherwise register islands explicitly:
|
|
94
|
+
{ clientComponents: { PriceTag } },
|
|
95
|
+
);
|
|
96
|
+
expect(flight).toMatchFlight("PriceTag"); // wire assertions still work
|
|
97
|
+
|
|
98
|
+
const [tag] = findClientBoundaries(tree, "PriceTag");
|
|
99
|
+
expect(tag.props.amount).toBe(19.5); // a real number
|
|
100
|
+
expect(tag.props.asOf).toBeInstanceOf(Date); // a real Date, not "$D..."
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("asserts the server-rendered host content", async () => {
|
|
104
|
+
const { tree } = await renderServerTree(
|
|
105
|
+
<ProductPanel amount={19.5} asOf={new Date("2026-01-02T00:00:00Z")} />,
|
|
106
|
+
{ clientComponents: { PriceTag } },
|
|
107
|
+
);
|
|
108
|
+
const [h2] = findElements(tree, "h2");
|
|
109
|
+
expect(h2.text).toBe("Wine");
|
|
110
|
+
expect(textContent(tree)).toContain("Wine"); // instead of JSON.stringify(tree)
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Caveats
|
|
115
|
+
|
|
116
|
+
- This renders an ELEMENT you build (`<Page />`). To test a route HANDLER (a `(ctx) => rsc` function registered via `path(...)`), use `renderHandler` (see [`./render-handler.md`](./render-handler.md)) — handlers have their own util. Do NOT wrap a handler in `createElement` and render it here: a handler is not a component, so React would invoke it with `props` as its argument instead of the real `HandlerContext`, and the seeded `params`/`vars` plus `ctx.use`/`ctx.reverse`/`ctx.get`/`cookies()` would all be absent.
|
|
117
|
+
- Island auto-discovery from the server tree's imports needs `rangoUseClientTransform()` in the rsc project (see `./setup.md`). Without it a plainly-imported island is just a function the serializer renders server-side — register islands explicitly via `{ clientComponents: { PriceTag } }`.
|
|
118
|
+
- Same alias requirement as `./flight.md`: a rendered component (or handler) that reads `getRequestContext()`/`cookies()` from the `@rangojs/router` barrel needs the `index.rsc.ts` alias (see `./setup.md`), or it hits the throwing out-of-react-server stub.
|
|
119
|
+
- A client boundary's props come back as REAL JS values after deserialization (a `Date` is a `Date`, not a `$D...` encoding) — but there is NO hydration and NO interaction; boundaries are inert placeholders carrying props.
|
|
120
|
+
- Server COMPONENTS do not survive Flight as identities (they are executed during serialization), so `findElements` matches the host elements they PRODUCED, not the component function. Client islands keep identity — use `findClientBoundaries` for those.
|
|
121
|
+
- `findClientBoundaries` finds islands (`I` rows); `findElements` finds host elements. A `testId` on an island matches with `findClientBoundaries`; a `testId` on a host element matches with `findElements`. Use `textContent(node)` in place of `JSON.stringify(tree).toContain`.
|
|
122
|
+
- A true interactive, clickable DOM `renderServer` is intentionally NOT shipped: in-process happy-dom hydration re-tests React more than your app and misses server/client divergence (the only hydration bug worth a dedicated test, which needs a real browser). Test interaction at e2e.
|
|
123
|
+
|
|
124
|
+
## See also
|
|
125
|
+
|
|
126
|
+
- `/route` — the DSL this tests
|
|
127
|
+
- Siblings: `./flight.md`, `./render-handler.md`, `./setup.md`
|
|
128
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "renderServerTree — serialize then deserialize to an inspectable tree" (and the "findElements / textContent" subsection)
|