@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,121 @@
|
|
|
1
|
+
# Testing a client component — renderRoute
|
|
2
|
+
|
|
3
|
+
**Layer:** unit (DOM) · **Import:** `@rangojs/router/testing/dom` · **DSL it tests:** a client component reading router context (see `/hooks`)
|
|
4
|
+
|
|
5
|
+
RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter`). It mounts the router's REAL `NavigationProvider` plus a synthetic segment tree built from the `routes` you pass, so client hooks resolve against production context — no server, no Vite build, no Flight round-trip. Loader data, location state, and handle output are SEEDED into client context; nothing is executed.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `RenderRouteOptions`
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| --------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `request` | `Request \| string` | Initial location. Only the URL is read (client render — headers/method ignored). Defaults to the leaf spec's static prefix or `"/"`. |
|
|
14
|
+
| `loaderData` | `Record<string, unknown>` | Loader data keyed by loader `$$id`. `useLoader(L)` reads `loaderData[L.$$id]`. |
|
|
15
|
+
| `loaders` | `ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>` | Seed by REFERENCE: `[loader, data]` pairs. Robust for real `createLoader()` handles whose `$$id` is empty in a bare test. Prefer over `loaderData`. |
|
|
16
|
+
| `params` | `Record<string, string>` | Explicit params, merged over (and overriding) params extracted from the `request` URL. |
|
|
17
|
+
| `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; written to `history.state`. |
|
|
18
|
+
| `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Seed `useHandle(handle)` by REFERENCE: `[handle, pushedValues[]]`. Accumulated GLOBALLY (not segment-scoped). |
|
|
19
|
+
| `handle` | `HandleDataSeed` | Advanced: raw wire format `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles`. Merged with it. |
|
|
20
|
+
| `routeMap` | `Record<string, string>` | Name -> pattern map (informational; client `useReverse` takes its map as an argument, so this is not consumed). |
|
|
21
|
+
| `basename` | `string` | `createRouter({ basename })` value. Wired into `NavigationProvider` so `useRouter().basename`, `<Link>` prefixing, `useMount`/`useHref` resolve against the mount. Normalized like `createRouter`. Defaults to root. |
|
|
22
|
+
| `mount` | `string` | `include()` mount prefix. Wraps the segment chain in a `MountContext` so `useMount()` returns the prefix. Normalized like a path prefix. Defaults to `"/"`. |
|
|
23
|
+
| `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape) to wrap the tree in a `ThemeProvider`. Defaults to no provider. A component calling `useTheme()` REQUIRES one. |
|
|
24
|
+
|
|
25
|
+
`RenderRouteSpec = { path, Component, layout?, loaderIds?, name? }` — one node of the route definition. The array is the layout chain root-to-leaf; the LAST entry is the leaf route (its pattern is matched against `request` to extract params; layout patterns are informational). `loaderIds` attaches seeded loaders to THIS node's segment; `layout` on the leaf wraps it; `name` is informational.
|
|
26
|
+
|
|
27
|
+
### Context — client hooks it makes resolve (what your code receives)
|
|
28
|
+
|
|
29
|
+
| Hook | Meaning |
|
|
30
|
+
| ------------------------------ | --------------------------------------------------------------------------------------------- |
|
|
31
|
+
| `useParams` | Params from the matched leaf pattern, with `options.params` merged over. |
|
|
32
|
+
| `useReverse` | Reverse a name->pattern map to a URL; merges `useParams()` and the `mount`/`basename` prefix. |
|
|
33
|
+
| `useHref` | Resolve an href against the mount/basename. |
|
|
34
|
+
| `useMount` | The `include()` mount prefix (`options.mount`), else `"/"`. |
|
|
35
|
+
| `useNavigation` | Navigation controller state — stays `idle` (see caveat). |
|
|
36
|
+
| `useRouter` | The router handle, including `.basename`. |
|
|
37
|
+
| `usePathname` | Current committed pathname. |
|
|
38
|
+
| `useSearchParams` | Search params from the `request` URL. |
|
|
39
|
+
| `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). |
|
|
40
|
+
| `useLocationState` | SEEDED `history.state` value. |
|
|
41
|
+
| `useHandle` | SEEDED handle output (globally accumulated). |
|
|
42
|
+
| `Outlet` | Renders the next segment in the chain (layout nesting). |
|
|
43
|
+
| `useTheme` | Theme; throws without `options.theme` (see caveat). |
|
|
44
|
+
|
|
45
|
+
### Returns — `RenderRouteResult`
|
|
46
|
+
|
|
47
|
+
Extends RTL's `RenderResult` (`getByTestId`, `getByText`, `getByRole`, `container`, ...) with:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
type RenderRouteResult = RenderResult & {
|
|
51
|
+
router: {
|
|
52
|
+
navigate(url: string): Promise<void>; // client-only nav, re-resolves the same routes
|
|
53
|
+
pathname(): string;
|
|
54
|
+
params(): Record<string, string>;
|
|
55
|
+
store: NavigationStore; // advanced
|
|
56
|
+
eventController: EventController; // advanced
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Recipe
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
// @vitest-environment happy-dom
|
|
65
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
66
|
+
import { cleanup } from "@testing-library/react";
|
|
67
|
+
import { renderRoute } from "@rangojs/router/testing/dom";
|
|
68
|
+
import { Outlet, useParams, useReverse } from "@rangojs/router/client";
|
|
69
|
+
|
|
70
|
+
afterEach(cleanup);
|
|
71
|
+
|
|
72
|
+
function Layout() {
|
|
73
|
+
return (
|
|
74
|
+
<div>
|
|
75
|
+
<span data-testid="shell">shell</span>
|
|
76
|
+
<Outlet />
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
function Product() {
|
|
81
|
+
const { productId } = useParams<{ productId: string }>();
|
|
82
|
+
const reverse = useReverse({ product: "/products/:productId" });
|
|
83
|
+
return (
|
|
84
|
+
<a data-testid="link" href={reverse("product", { productId: "2" })}>
|
|
85
|
+
{productId}
|
|
86
|
+
</a>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
it("resolves params + reverse + Outlet through the layout chain", async () => {
|
|
91
|
+
const { getByTestId, router } = await renderRoute(
|
|
92
|
+
[
|
|
93
|
+
{ path: "/products", Component: Layout }, // layout (root)
|
|
94
|
+
{ path: "/products/:productId", Component: Product }, // leaf (last)
|
|
95
|
+
],
|
|
96
|
+
{ request: "/products/1" },
|
|
97
|
+
);
|
|
98
|
+
expect(getByTestId("shell").textContent).toBe("shell");
|
|
99
|
+
expect(getByTestId("link").getAttribute("href")).toBe("/products/2");
|
|
100
|
+
|
|
101
|
+
await router.navigate("/products/2"); // client-only nav, re-resolves the same routes
|
|
102
|
+
expect(router.pathname()).toBe("/products/2");
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Caveats
|
|
107
|
+
|
|
108
|
+
- Client tree ONLY. Does NOT catch server/client boundary reference-identity remount bugs, real Flight serialization errors, loader execution, middleware, or handler ordering — those are `renderServerTree` / `renderHandler` / e2e territory. Loader data is SEEDED, never run.
|
|
109
|
+
- `router.navigate()` bypasses the navigation lifecycle, so the controller never leaves `idle`. `useNavigation()` / `useLinkStatus()` / `useAction()` non-idle states (loading/streaming/pending, action result/error) are NOT reachable — test those at e2e.
|
|
110
|
+
- CATCH — streaming `use(promise)` Suspense content (e.g. an async breadcrumb `content: Promise<ReactNode>`): a plain `Promise.resolve(node)` does NOT flush its Suspense retry in RTL/happy-dom, so the DOM stays on the fallback. Assert the PENDING fallback with `new Promise(() => {})`; for the ARRIVED state pass an already-settled promise so `use()` reads it synchronously: `const p = Promise.resolve(node) as any; p.status = "fulfilled"; p.value = node;`. The real pending->resolved transition is an e2e concern.
|
|
111
|
+
- ARIA gotcha — an explicit `role` on a `<Link>` (e.g. `<Link role="tab">` in a tablist) OVERRIDES the implicit `link` role, so `getByRole("link")` finds nothing. Query the explicit role (`getByRole("tab")`) or fall back to `getByText` / `getByTestId` and assert `getAttribute("href")`.
|
|
112
|
+
- `ctx.theme` is undefined unless `theme` is passed; the typed `ctx.search` defaults to `{}` (seed `searchData` on `runLoader`, not here).
|
|
113
|
+
- Use `mount` only for an `include()` prefix. An OPTIONAL param in the matched pattern (`/:locale?/c/:group` at `/en/c/wine`) auto-fills `locale` from the match — production parity, `useReverse` merges `useParams()` — so no `mount` is needed; a locale "dropping" from a reversed URL is usually a missing `mount` seed, not an auto-fill gap.
|
|
114
|
+
- Needs a DOM env (`// @vitest-environment happy-dom`, or jsdom) and `@testing-library/react` (optional peers).
|
|
115
|
+
- Don't hand-roll a `NavigationProvider`/router-context mock to test a client component — `renderRoute` mounts the REAL provider, so a hand-mock both duplicates effort and drifts from the production context shape.
|
|
116
|
+
|
|
117
|
+
## See also
|
|
118
|
+
|
|
119
|
+
- `/hooks` — the DSL this tests
|
|
120
|
+
- Siblings: `./handles.md`, `./reverse-and-types.md`, `./render-handler.md`, `./e2e-parity.md`
|
|
121
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "Reverse and components" (and the "Catch: streaming `use(promise)` Suspense content" subsection)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# E2E with dev/prod and PE parity — createRangoE2E
|
|
2
|
+
|
|
3
|
+
**Layer:** e2e (Playwright) · **Import:** `@rangojs/router/testing/e2e` · **DSL it tests:** navigation, hydration, server actions + revalidation, view transitions, PE parity (see `/hooks`, `/view-transitions`)
|
|
4
|
+
|
|
5
|
+
This is full-stack: the harness builds and serves your real app (`pnpm dev` or `pnpm build` + `pnpm preview`) and drives a real browser. Nothing is seeded — you SEED only the URL you navigate to and the form data you submit; everything else (SSR, hydration, the RSC stream, actions, revalidation) is the real machinery.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
`createRangoE2E({ test, expect, defaultRoot })` takes your Playwright `test`/`expect` and returns `{ useFixture, parityDescribe, expectParity, rangoMatchers, testNoJs, ...pageHelpers }`. The factory never imports `@playwright/test` at runtime — the helpers run on the objects you pass, so this entry is loadable in a plain Playwright runner.
|
|
10
|
+
|
|
11
|
+
### Factory options — `createRangoE2E({ ... })`
|
|
12
|
+
|
|
13
|
+
| Field | Type | Meaning |
|
|
14
|
+
| ------------- | ---------- | ------------------------------------------------------------------------ |
|
|
15
|
+
| `test` | `TestType` | Your Playwright `test` (drives `describe`/`beforeAll`/`afterAll`). |
|
|
16
|
+
| `expect` | `Expect` | Your Playwright `expect` (used by helpers + matchers). |
|
|
17
|
+
| `defaultRoot` | `string?` | Fallback app root for `parityDescribe` when a call omits `options.root`. |
|
|
18
|
+
|
|
19
|
+
### Fixture options — `FixtureOptions` (`useFixture` / `parityDescribe` 3rd arg)
|
|
20
|
+
|
|
21
|
+
| Field | Type | Meaning |
|
|
22
|
+
| ---------------- | ------------------ | ------------------------------------------------------------------------------ |
|
|
23
|
+
| `root` | `string` | App path under test (abs or cwd-relative). Required here or via `defaultRoot`. |
|
|
24
|
+
| `mode` | `"dev" \| "build"` | Server mode. `parityDescribe` sets this for you (dev + build). |
|
|
25
|
+
| `command` | `string?` | Override server command (default `pnpm dev` / `pnpm preview`). |
|
|
26
|
+
| `buildCommand` | `string?` | Override build command (default `pnpm build`). |
|
|
27
|
+
| `cliOptions` | `SpawnOptions?` | Extra spawn options (`env`, etc.). |
|
|
28
|
+
| `isolatedServer` | `boolean?` | Per-suite server with an isolated Vite cache dir (warms dep optimizer; dev). |
|
|
29
|
+
| `readyPath` | `string?` | Readiness poll path (default `/`); use when a basename moves routes off `/`. |
|
|
30
|
+
| `skipBuild` | `boolean?` | Skip the production build (assumes an existing build). |
|
|
31
|
+
|
|
32
|
+
### Parity intent — `ParityIntent` (what `expectParity` applies)
|
|
33
|
+
|
|
34
|
+
| Shape | Meaning |
|
|
35
|
+
| ------------------------------- | ----------------------------------------------------------------------------- |
|
|
36
|
+
| `{ navigate: string }` | Go to a URL (resolved against `opts.baseURL` if relative). |
|
|
37
|
+
| `{ submit: { testId, data? } }` | Fill `data` into named inputs under `[data-testid=testId]`, click its submit. |
|
|
38
|
+
|
|
39
|
+
### expectParity options — `ExpectParityOptions`
|
|
40
|
+
|
|
41
|
+
| Field | Type | Meaning |
|
|
42
|
+
| --------- | -------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
43
|
+
| `observe` | `string[]` | data-testid values whose text must match across JS and no-JS. |
|
|
44
|
+
| `baseURL` | `string?` | Base URL for a relative `navigate` intent. |
|
|
45
|
+
| `waitFor` | `(page) => Promise<void>?` | Post-intent settle hook on BOTH transports; for `submit` it REPLACES the generic change/stability wait. |
|
|
46
|
+
|
|
47
|
+
### Returns
|
|
48
|
+
|
|
49
|
+
`createRangoE2E(...)` -> `RangoE2E`:
|
|
50
|
+
|
|
51
|
+
- `useFixture(options)` -> `Fixture` (`{ mode, root, url(path?), proc() }`). `url(path)` resolves against the running server.
|
|
52
|
+
- `parityDescribe(name, (f) => { ... }, options?)` -> registers a dev describe `name` AND a production describe `` `${name} (production)` ``. Body runs once per describe with that describe's `Fixture`.
|
|
53
|
+
- `expectParity(page, intent, { observe }) => Promise<void>` — runs `intent` over the JS page and a fresh no-JS context, asserts observed testids' text + pathname/search/hash + `document.cookie` are equal.
|
|
54
|
+
- `rangoMatchers` — `{ toHaveRangoPathname }` only (pass to `expect.extend`).
|
|
55
|
+
- `testNoJs` — a `test` variant with JavaScript disabled.
|
|
56
|
+
- Page helpers: `waitForHydration`, `expectNoReload`, `expectNoPageError`, `testId`, `waitForNavigation`, `waitForElement`, `goBack`/`goForward`, `getHistoryState`, `waitForTextChange`/`waitForNumericChange`, timing helpers.
|
|
57
|
+
|
|
58
|
+
## Recipe
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// helper.ts — wire the harness once around your Playwright test/expect.
|
|
62
|
+
import { test, expect } from "@playwright/test";
|
|
63
|
+
import { createRangoE2E } from "@rangojs/router/testing/e2e";
|
|
64
|
+
|
|
65
|
+
export const { parityDescribe, expectParity, rangoMatchers, useFixture } =
|
|
66
|
+
createRangoE2E({ test, expect, defaultRoot: "." });
|
|
67
|
+
export { test, expect };
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// nav.test.ts — one body -> dev describe AND `(production)` describe.
|
|
72
|
+
import {
|
|
73
|
+
test,
|
|
74
|
+
expect,
|
|
75
|
+
parityDescribe,
|
|
76
|
+
expectParity,
|
|
77
|
+
rangoMatchers,
|
|
78
|
+
} from "./helper";
|
|
79
|
+
expect.extend(rangoMatchers);
|
|
80
|
+
|
|
81
|
+
parityDescribe("product navigation", (f) => {
|
|
82
|
+
test("client-navigates without a reload", async ({ page }) => {
|
|
83
|
+
await page.goto(f.url("/"));
|
|
84
|
+
await page.getByTestId("product-link").click();
|
|
85
|
+
await page.waitForURL("**/products/1");
|
|
86
|
+
await expect(page).toHaveRangoPathname("/products/1"); // typed via the shipped augmentation
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
parityDescribe("add to cart parity", (f) => {
|
|
91
|
+
test("JS and no-JS produce the same observable result", async ({ page }) => {
|
|
92
|
+
await page.goto(f.url("/products/1"));
|
|
93
|
+
await expectParity(
|
|
94
|
+
page,
|
|
95
|
+
{ submit: { testId: "add-to-cart-form", data: { qty: "2" } } },
|
|
96
|
+
{ observe: ["cart-count", "flash-message"] },
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
This add-to-cart example only works because the cart is **session-scoped**. A `submit` intent runs against the one live server TWICE — once on the JS page, once in a fresh no-JS context — so `cart-count` lands on the same value on both transports only if each context has its own cart (two distinct sessions, each going 0 -> 2). If the cart were a single global counter shared across contexts, the no-JS pass would observe the JS pass's mutation too (2 then 4) and the snapshots would diverge. See the submit caveats below before reaching for a `submit` intent.
|
|
103
|
+
|
|
104
|
+
## Caveats
|
|
105
|
+
|
|
106
|
+
- Every e2e covers BOTH dev and production — a dev-only e2e is not acceptable. `parityDescribe` enforces it structurally: one body registers the dev describe AND the `(production)` describe.
|
|
107
|
+
- Bucketing footgun: a `useFixture({ mode: "build" })` describe whose title omits `(production)` silently lands in the DEV bucket — prod coverage lost, no error. Never hand-title a build describe; the bucketing matches the literal `(production)`, so `(prod)`, `-build`, `-prod` do NOT count. Use `parityDescribe`.
|
|
108
|
+
- `expectParity` contract: PE parity only holds if the submit target is a real `<form>` (with JS off the browser does a native POST). Cookie observation is `document.cookie` — non-HttpOnly cookies only in v1; an HttpOnly (session/auth) divergence is NOT caught here.
|
|
109
|
+
|
|
110
|
+
### `submit`-intent parity — two scar-tissue hazards
|
|
111
|
+
|
|
112
|
+
A `submit` intent does NOT replay against a snapshot of the server — it submits for real, twice, against the one running instance, and then compares two whole browser jars. Both of these have bitten before; read them before you write a `submit` parity test.
|
|
113
|
+
|
|
114
|
+
- **Double execution.** The JS path submits on the page you handed `expectParity`. The no-JS pass then reloads the SAME `originUrl` in a fresh, scripting-disabled context and submits AGAIN. So a non-idempotent action (anything that mutates server state) runs twice against one server, and the no-JS snapshot sees BOTH mutations — UNLESS the mutated state is per-session / per-context. The add-to-cart example above only passes because the cart is session-scoped: each context owns its own cart and independently goes 0 -> 2, so both snapshots read 2. A globally-shared counter would read 2 after the JS submit and 4 after the no-JS submit, and the equality assertion would fail with no obvious cause. Increment-shaped actions are the trap; make the observable state session-scoped, or assert the submit path outside `expectParity`.
|
|
115
|
+
- **Whole jar, not the delta.** The cookie assertion compares `document.cookie` of the JS context against `document.cookie` of the fresh no-JS context (`parity.ts`, the final `cookies` equality). The JS context carries every cookie it accumulated before the intent — consent banners, analytics, cookies set during earlier navigation in the same test. The no-JS context starts empty and only picks up what THIS submit sets. So a pre-existing, intent-unrelated cookie in the JS context false-mismatches: the helper is diffing two jars, not the per-submit cookie delta. Keep the JS context's pre-intent cookie state minimal (a fresh page goto, no prior cookie-setting steps), or assert the specific Set-Cookie in a dedicated test.
|
|
116
|
+
- `rangoMatchers` ships `toHaveRangoPathname` only. `toHaveSegments`/`toHaveParams` are a documented future addition — they need a client-emitted signal that does not exist yet; do not assume them.
|
|
117
|
+
- Subset run: add `--no-deps`. `--grep` does NOT filter dependency projects, so grepping one production test otherwise pulls in the whole dev suite. `--grep` is a regex: a pasted title containing `(production)` / `:locale?` / `[...]` mis-matches — grep a metacharacter-free fragment (or escape it). Example: `pnpm exec playwright test --project=production --no-deps --grep "add to cart parity"`.
|
|
118
|
+
- Import the harness from the `/e2e` entry — the unit barrel (`@rangojs/router/testing`) is not loadable in a plain Playwright runner (it pulls a build-only virtual). The helpers take your `test`/`expect`, so this entry never imports `@playwright/test` at runtime.
|
|
119
|
+
|
|
120
|
+
## See also
|
|
121
|
+
|
|
122
|
+
- `/hooks`, `/view-transitions` — the DSL this tests
|
|
123
|
+
- Siblings: [`./cache-prerender.md`](./cache-prerender.md), [`./client-components.md`](./client-components.md)
|
|
124
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "E2E with dev/prod and PE parity" (and "Running a subset locally")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Testing an async Server Component — renderToFlightString
|
|
2
|
+
|
|
3
|
+
**Layer:** RSC unit (react-server project) · **Import:** `@rangojs/router/testing/flight` + `@rangojs/router/testing/flight-matchers` · **DSL it tests:** an async Server Component / Flight output (see `/route`)
|
|
4
|
+
|
|
5
|
+
`renderToFlightString` runs the REAL react-server-dom serializer the router uses at runtime — your async Server Component genuinely renders to its Flight wire string in plain node, with a request context active for the render. What you SEED is the request, headers, env, params, routeName, and vars that context exposes.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `RenderToFlightStringOptions`
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| ----------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `request` | `Request \| string` | The request the render runs under: a `Request`, or a URL string (absolute or path). Defaults to `http://localhost/`. A component reading `getRequestContext()` sees this request's url/cookies. When a `Request` is passed, its headers are used and `headers` is ignored. |
|
|
14
|
+
| `headers` | `HeadersInit` | Request headers (e.g. Cookie) visible to the server tree, used only 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` | Variables a prior middleware would have set, visible via `ctx.get(...)`. Object form (`{ user }`) or `[key, value]` tuples (`[[userVar, u]]`). |
|
|
19
|
+
|
|
20
|
+
### Context — `RequestContext` (what your component receives)
|
|
21
|
+
|
|
22
|
+
A request context is active for the whole render, so an async Server Component can read it via `getRequestContext()` / the router's server APIs. The notable surfaces seeded from the options above:
|
|
23
|
+
|
|
24
|
+
| Field | Type | Meaning |
|
|
25
|
+
| ----------- | ----------------------------------------- | --------------------------------------------------------------------- |
|
|
26
|
+
| `request` | `Request` | The backing request (from `request`/`headers`). |
|
|
27
|
+
| `url` | `URL` | The request URL. |
|
|
28
|
+
| `env` | `unknown` | Env / bindings (from `env`). |
|
|
29
|
+
| `params` | `Record<string, string>` | Route params (from `params`). |
|
|
30
|
+
| `routeName` | `string \| undefined` | Matched route name (from `routeName`). |
|
|
31
|
+
| `get` | `<T>(v: ContextVar<T>) => T \| undefined` | Read a var seeded via `vars` (by `createVar()` handle or string key). |
|
|
32
|
+
| `cookies` | reader | Cookies parsed from the request's Cookie header. |
|
|
33
|
+
|
|
34
|
+
### Returns — `Promise<string>`
|
|
35
|
+
|
|
36
|
+
The Flight wire string for the rendered tree. Assert on it with the matchers (register via `expect.extend(flightMatchers)`):
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
expect(await renderToFlightString(<C />)).toMatchFlight("substring"); // containment
|
|
40
|
+
expect(await renderToFlightString(<C />)).toMatchFlightSnapshot(); // normalized snapshot
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`toMatchFlight(substring)` is containment (not equality) on the normalized payload; `toMatchFlightSnapshot()` snapshots the normalized payload. Both matchers live at `@rangojs/router/testing/flight-matchers` and run ONLY under the react-server vitest project (see `./setup.md`).
|
|
44
|
+
|
|
45
|
+
## Recipe
|
|
46
|
+
|
|
47
|
+
Name the file `*.rsc-test.{ts,tsx}` and run `pnpm test:unit:rsc`:
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { it, expect } from "vitest";
|
|
51
|
+
import { renderToFlightString } from "@rangojs/router/testing/flight";
|
|
52
|
+
import { flightMatchers } from "@rangojs/router/testing/flight-matchers";
|
|
53
|
+
expect.extend(flightMatchers);
|
|
54
|
+
|
|
55
|
+
// Pure leaf server components: data comes in as props, not getRequestContext.
|
|
56
|
+
async function Greeting({ name }: { name: string }) {
|
|
57
|
+
const who = await Promise.resolve(name);
|
|
58
|
+
return <h1>Hello {who}</h1>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function ItemView({ id }: { id: string }) {
|
|
62
|
+
const item = await Promise.resolve({ id, label: `Item ${id}` });
|
|
63
|
+
return <article>{item.label}</article>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it("renders an async server component to Flight", async () => {
|
|
67
|
+
const flight = await renderToFlightString(<Greeting name="Ada" />);
|
|
68
|
+
expect(flight).toMatchFlight("Ada");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("snapshots the normalized payload", async () => {
|
|
72
|
+
const flight = await renderToFlightString(<ItemView id="7" />);
|
|
73
|
+
expect(flight).toMatchFlightSnapshot();
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Caveats
|
|
78
|
+
|
|
79
|
+
- Leaf / server-only: a client island in the tree emits an un-hydratable `I[...]` import row against the empty client manifest. Keep Flight tests to leaf server components; test full pages at e2e.
|
|
80
|
+
- Requires the react-server vitest project (see `./setup.md`): `resolve.conditions` includes `react-server`, the `@rangojs/router -> index.rsc.ts` alias, `NODE_ENV=production`, and the worker `execArgv`. Name files `*.rsc-test.{ts,tsx}` and run `pnpm test:unit:rsc`. The main vitest project must NOT set `react-server` (it would flip React to the no-hooks server build).
|
|
81
|
+
- A component that imports a server API (`getRequestContext`, `cookies`) from the bare `@rangojs/router` barrel works ONLY with the `index.rsc.ts` alias wired (see `./setup.md`); without it the bare import resolves to the throwing out-of-react-server stub. Pure-leaf components that take all data as props need no barrel import and are the simplest case.
|
|
82
|
+
- `toMatchFlight` is containment (substring), not equality — the row framing (prefixes/quoting) is an internal serializer detail, so pin the rendered text/shape, not the framing. `toMatchFlightSnapshot()` snapshots the normalized payload; run under `NODE_ENV=production` for the cleanest, most stable bytes.
|
|
83
|
+
- No hydration / no interaction here — that is the e2e tier. For typed assertions on a client boundary's props (a `Date` back as a `Date`), or to confirm an island actually crossed the boundary, use `renderServerTree` (see `./server-tree.md`).
|
|
84
|
+
|
|
85
|
+
## See also
|
|
86
|
+
|
|
87
|
+
- `/route` — the DSL this tests
|
|
88
|
+
- Siblings: `./setup.md`, `./server-tree.md`, `./render-handler.md`
|
|
89
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "renderToFlightString — real async Server Components"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Testing a handle — collectHandle, plus the loader and client read paths
|
|
2
|
+
|
|
3
|
+
**Layer:** unit (node + DOM) · **Import:** `@rangojs/router/testing` (collectHandle), `@rangojs/router/testing/dom` (renderRoute) · **DSL it tests:** a handle e.g. Breadcrumbs/Meta (see `/handler-use`, `/breadcrumbs`)
|
|
4
|
+
|
|
5
|
+
A handle's `collect`/accumulator (the `createHandle(collect)` argument that maps per-segment pushed values into one accumulated result) is otherwise unreachable — `createHandle` keeps it in a private registry keyed by `$$id`. These three primitives test it from different angles: `collectHandle` runs the REAL registered collect on per-segment values you SEED; `runLoader` seeds the POST-collect accumulated value a loader reads after the barrier; `renderRoute` seeds the RAW pushed values for a client component reading `useHandle`. None of them run the real push -> accumulate -> barrier wiring (that stays e2e).
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### `collectHandle(handle, segments)` — `src/testing/collect-handle.ts`
|
|
10
|
+
|
|
11
|
+
| Param | Type | Meaning |
|
|
12
|
+
| ---------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `handle` | `Handle<TData, TAccumulated>` | The handle whose registered collect to run. |
|
|
14
|
+
| `segments` | `ReadonlyArray<ReadonlyArray<TData>>` | Per-segment pushed values, one inner array per route segment, in **parent -> child** order. Empty inner arrays are filtered before the collect runs (matching production `collectHandleData` — a segment that pushed nothing is not passed through). |
|
|
15
|
+
|
|
16
|
+
**Returns** `TAccumulated` — exactly what the handle's collect produces (a default-flatten array, or a custom accumulator's value). If the handle's module was never imported (collect unregistered), it warns and falls back to `segments.flat()`.
|
|
17
|
+
|
|
18
|
+
### runLoader option — `handles` — `src/testing/run-loader.ts`
|
|
19
|
+
|
|
20
|
+
| Field | Type | Meaning |
|
|
21
|
+
| ---------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
22
|
+
| `handles` | `ReadonlyArray<readonly [Handle, unknown]>` | Seeds the value `ctx.use(SomeHandle)` returns — the POST-collect **ACCUMULATED** value (singular `unknown`), what a loader reads after `await ctx.rendered()`. Matched by handle reference. Pair with `rendered`. |
|
|
23
|
+
| `rendered` | `boolean \| (() => void \| Promise<void>)` | Mocks the `ctx.rendered()` barrier (throws by default). `true` resolves it immediately; a function controls timing/side effects. A `ctx.use(handle)` read before the barrier settles throws, exactly as in production. |
|
|
24
|
+
|
|
25
|
+
### renderRoute option — `handles` — `src/testing/render-route.tsx`
|
|
26
|
+
|
|
27
|
+
| Field | Type | Meaning |
|
|
28
|
+
| --------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
|
+
| `handles` | `ReadonlyArray<readonly [Handle, unknown[]]>` | Seeds the CLIENT read path for `useHandle(handle)` — the RAW **pushed values array** (`unknown[]`), the values a route's handlers would have pushed. Attached to the leaf route segment under the handle's `$$id`, so `useHandle` runs the handle's REAL collect on them. |
|
|
30
|
+
|
|
31
|
+
**Shape contrast:** `renderRoute` feeds the barrier INPUT (the pushes, `unknown[]`); `runLoader` feeds its OUTPUT (the single accumulated value, `unknown`).
|
|
32
|
+
|
|
33
|
+
## Recipe
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// collectHandle.test.ts — the pure collect, no route match
|
|
37
|
+
import { describe, it, expect } from "vitest";
|
|
38
|
+
import { collectHandle } from "@rangojs/router/testing";
|
|
39
|
+
import { createHandle } from "@rangojs/router";
|
|
40
|
+
|
|
41
|
+
const Breadcrumbs = createHandle<{ label: string; href: string }>(); // default flatten
|
|
42
|
+
|
|
43
|
+
it("flattens per-segment crumbs in parent->child order", () => {
|
|
44
|
+
const home = { label: "Home", href: "/" };
|
|
45
|
+
const post = { label: "P", href: "/p" };
|
|
46
|
+
expect(collectHandle(Breadcrumbs, [[home], [post]])).toEqual([home, post]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("runs a custom 'last wins' collect", () => {
|
|
50
|
+
const PageTitle = createHandle<string, string>((s) => s.flat().at(-1) ?? "");
|
|
51
|
+
expect(collectHandle(PageTitle, [["Home"], ["Products"], ["Shoes"]])).toBe(
|
|
52
|
+
"Shoes",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// loader-reads-handle.test.ts — a loader reading accumulated handle data after the barrier
|
|
59
|
+
import { it, expect } from "vitest";
|
|
60
|
+
import { runLoader } from "@rangojs/router/testing";
|
|
61
|
+
import { RenderedProducts } from "../src/handles"; // a createHandle(...)
|
|
62
|
+
|
|
63
|
+
const livePricesBody = async (ctx) => {
|
|
64
|
+
await ctx.rendered(); // barrier: handle data is now readable
|
|
65
|
+
const ids = ctx.use(RenderedProducts) as string[];
|
|
66
|
+
return ids.map((id) => ({ id, price: 9.99 }));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
it("reads the accumulated handle value (seed the OUTPUT, mock the barrier)", async () => {
|
|
70
|
+
const data = await runLoader(livePricesBody, {
|
|
71
|
+
rendered: true,
|
|
72
|
+
handles: [[RenderedProducts, ["widget-a", "widget-b"]]], // singular accumulated value
|
|
73
|
+
});
|
|
74
|
+
expect(data).toEqual([
|
|
75
|
+
{ id: "widget-a", price: 9.99 },
|
|
76
|
+
{ id: "widget-b", price: 9.99 },
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
// breadcrumb-trail.test.tsx — a client component reading useHandle
|
|
83
|
+
// @vitest-environment happy-dom
|
|
84
|
+
import { it, expect, afterEach } from "vitest";
|
|
85
|
+
import { cleanup } from "@testing-library/react";
|
|
86
|
+
import { renderRoute } from "@rangojs/router/testing/dom";
|
|
87
|
+
import { useHandle } from "@rangojs/router/client";
|
|
88
|
+
import { Breadcrumbs } from "../src/handles";
|
|
89
|
+
|
|
90
|
+
afterEach(cleanup);
|
|
91
|
+
|
|
92
|
+
function BreadcrumbTrail() {
|
|
93
|
+
const crumbs = useHandle(Breadcrumbs); // accumulated client-side via the real collect
|
|
94
|
+
return <nav>{crumbs.map((c) => c.label).join(" / ")}</nav>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
it("renders the seeded trail (seed the INPUT pushes, the collect runs)", async () => {
|
|
98
|
+
const { getByText } = await renderRoute(
|
|
99
|
+
[{ path: "/p", Component: BreadcrumbTrail }],
|
|
100
|
+
{
|
|
101
|
+
handles: [
|
|
102
|
+
[
|
|
103
|
+
Breadcrumbs,
|
|
104
|
+
[
|
|
105
|
+
{ label: "Home", href: "/" },
|
|
106
|
+
{ label: "P", href: "/p" },
|
|
107
|
+
],
|
|
108
|
+
],
|
|
109
|
+
], // raw pushes array
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
expect(getByText("Home / P")).toBeTruthy();
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Caveats
|
|
117
|
+
|
|
118
|
+
- `collectHandle` tests the pure collect/accumulator in ISOLATION (parent -> child segment order, empty arrays filtered to match production). It does NOT run the real push -> accumulate -> barrier wiring — that stays e2e.
|
|
119
|
+
- renderRoute `handles` seeds the CLIENT read path with the RAW pushed values array (`unknown[]`), attached to the leaf segment. Handle data accumulates GLOBALLY (not segment-scoped like loaders), so a LAYOUT reading the same handle sees the seeded values too, not just the leaf route.
|
|
120
|
+
- runLoader `handles` seeds the POST-collect ACCUMULATED value (singular `unknown`) a loader reads after `await ctx.rendered()`; pair with `{ rendered: true }`. Shape contrast: renderRoute feeds the barrier INPUT (pushes[]), runLoader feeds its OUTPUT (the accumulated value).
|
|
121
|
+
- The renderRoute path is the CLIENT tree only: it does NOT catch server/client boundary remount bugs, real Flight serialization errors, or loader execution.
|
|
122
|
+
|
|
123
|
+
## See also
|
|
124
|
+
|
|
125
|
+
- `/handler-use`, `/breadcrumbs` — the DSL this tests
|
|
126
|
+
- Siblings: `./loader.md`, `./client-components.md`, `./render-handler.md`
|
|
127
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "Testing a handle's collect/accumulator"
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Testing a loader — runLoader
|
|
2
|
+
|
|
3
|
+
**Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `loader()` (see `/loader`)
|
|
4
|
+
|
|
5
|
+
`runLoader` runs a loader against a real `RequestContext` (cookies, headers, `ctx.get`, `ctx.reverse` all resolve) in plain node — that machinery is REAL; what you SEED is the params, env, vars, search, route map, and any `ctx.use` dependency data. Pass a registered `createLoader()` handle (its fn is recovered from the registry) or the raw async body `(ctx) => ...`.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `RunLoaderOptions<TEnv>`
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| --------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `params` | `Record<string, string>` | Route params; surfaced as `ctx.params` and `ctx.routeParams`. |
|
|
14
|
+
| `search` | `Record<string, string>` | Search params; merged into the request URL so `ctx.searchParams` reflects them. |
|
|
15
|
+
| `searchData` | `Record<string, unknown>` | The TYPED `ctx.search` object a route's search schema would produce. Distinct from `search` (which sets the raw `ctx.searchParams`). |
|
|
16
|
+
| `basename` | `string` | Router basename surfaced on the context; drives `redirect()` prefixing. |
|
|
17
|
+
| `theme` | `ThemeConfig \| true` | Theme config in the `createRouter({ theme })` shape (e.g. `true` or `{ themes: [...] }`). Without it `ctx.theme`/`ctx.setTheme` are inert. |
|
|
18
|
+
| `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. |
|
|
19
|
+
| `request` | `Request \| string` | Override the backing Request. Defaults to a localhost GET. |
|
|
20
|
+
| `vars` | `VarsInit` | Variables a prior middleware would have set (object `{ key: value }`, or `[key, value]` tuples where the key may be a `createVar()` handle). |
|
|
21
|
+
| `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`. |
|
|
22
|
+
| `routeName` | `string` | Matched route name for scoped `.name` reverse resolution. |
|
|
23
|
+
| `method` | `string` | HTTP method surfaced as `ctx.method`. Defaults to `"GET"`. |
|
|
24
|
+
| `body` | `unknown` | Request body surfaced as `ctx.body`. |
|
|
25
|
+
| `formData` | `FormData` | Form data surfaced as `ctx.formData` (exposed verbatim; no multipart parsing). |
|
|
26
|
+
| `loaders` | `ReadonlyArray<readonly [LoaderDefinition<any, any>, unknown]>` | Seed `ctx.use(OtherLoader)` by REFERENCE as `[[OtherLoader, data]]` tuples (same shape as `renderHandler`/`renderRoute`). Checked before `use`. |
|
|
27
|
+
| `use` | `UseResolver` | Dynamic resolver for `ctx.use(OtherLoader)` composition. `loaders` wins when both match. |
|
|
28
|
+
| `cacheStore` | `SegmentCacheStore` | Cache store backing `use cache` functions. Without one, a cached function bypasses and runs uncached (its taint/profile guards never fire). |
|
|
29
|
+
| `cacheProfiles` | `Record<string, CacheProfile>` | Cache profiles, the `createRouter({ cacheProfiles })` shape. |
|
|
30
|
+
| `rendered` | `boolean \| (() => void \| Promise<void>)` | Mock the `ctx.rendered()` render barrier so a loader that `await ctx.rendered()`s can be unit-tested. By default `ctx.rendered()` throws. `true` resolves immediately; a function controls timing/side effects. |
|
|
31
|
+
| `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown]>` | Seed the values `ctx.use(SomeHandle)` returns — the ACCUMULATED handle data read after `await ctx.rendered()`. Matched by handle reference. |
|
|
32
|
+
|
|
33
|
+
### Context — `TestLoaderContext<TEnv>` (what your loader receives)
|
|
34
|
+
|
|
35
|
+
| Field | Type | Meaning |
|
|
36
|
+
| ------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
37
|
+
| `params` | `Record<string, string>` | Route params (from `opts.params`). |
|
|
38
|
+
| `routeParams` | `Record<string, string>` | Same values as `params`. |
|
|
39
|
+
| `request` | `Request` | The backing request. |
|
|
40
|
+
| `searchParams` | `URLSearchParams` | Raw search params (from `opts.search` baked into the URL). |
|
|
41
|
+
| `search` | `Record<string, unknown>` | The TYPED search object (from `opts.searchData`); defaults to `{}`. |
|
|
42
|
+
| `pathname` | `string` | Request pathname. |
|
|
43
|
+
| `url` | `URL` | Request URL. |
|
|
44
|
+
| `originalUrl` | `URL` | Pre-basename-rewrite URL. |
|
|
45
|
+
| `env` | `TEnv` | Environment bindings (from `opts.env`). |
|
|
46
|
+
| `get` | `<T>(contextVar: ContextVar<T>) => T \| undefined` / `<T>(key: string) => T \| undefined` | Read a var seeded via `opts.vars` (by `createVar()` handle or string key). |
|
|
47
|
+
| `use` | `(dep) => ...` | Resolve `ctx.use(OtherLoader)`/`ctx.use(SomeHandle)`: handle seeds first, then loader seeds, then the `use` resolver, then the real context `use()`. |
|
|
48
|
+
| `method` | `string` | HTTP method (from `opts.method`, default `"GET"`). |
|
|
49
|
+
| `body` | `unknown` | Request body (from `opts.body`). |
|
|
50
|
+
| `formData` | `FormData \| undefined` | Form data (from `opts.formData`). |
|
|
51
|
+
| `reverse` | `(name, params?, search?) => string` | Build a URL; throws unless `opts.routeMap` was passed. |
|
|
52
|
+
| `rendered` | `() => Promise<void>` | The render barrier; throws by default, mocked via `opts.rendered`. |
|
|
53
|
+
| `waitUntil` | `(p: Promise<unknown>) => void` | Register background work (no-op accounting in tests). |
|
|
54
|
+
| `executionContext` | `ExecutionContext \| undefined` | Platform execution context from the backing request; pairs with `waitUntil`. |
|
|
55
|
+
|
|
56
|
+
### Returns — `Promise<T>`
|
|
57
|
+
|
|
58
|
+
The loader data DIRECTLY (no envelope). `T` is the loader's return type.
|
|
59
|
+
|
|
60
|
+
## Recipe
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { runLoader } from "@rangojs/router/testing";
|
|
64
|
+
import { createLoader, createVar } from "@rangojs/router";
|
|
65
|
+
|
|
66
|
+
const User = createVar<{ name: string }>();
|
|
67
|
+
// The registered loader — no separate body export needed for testability:
|
|
68
|
+
const ProductLoader = createLoader(async (ctx) => ({
|
|
69
|
+
id: ctx.params.id,
|
|
70
|
+
region: ctx.env.REGION,
|
|
71
|
+
user: ctx.get(User),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
it("reads params, env, and seeded vars", async () => {
|
|
75
|
+
const data = await runLoader(ProductLoader, {
|
|
76
|
+
params: { id: "42" },
|
|
77
|
+
env: { REGION: "eu" },
|
|
78
|
+
vars: [[User, { name: "Ada" }]],
|
|
79
|
+
});
|
|
80
|
+
expect(data).toEqual({ id: "42", region: "eu", user: { name: "Ada" } });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("builds a self link via reverse", async () => {
|
|
84
|
+
// runLoader(async (ctx) => ({ ... }), opts) — the bare body — works identically.
|
|
85
|
+
const data = await runLoader(
|
|
86
|
+
async (ctx) => ({ self: ctx.reverse("product", { id: ctx.params.id }) }),
|
|
87
|
+
{ params: { id: "42" }, routeMap: { product: "/products/:id" } },
|
|
88
|
+
);
|
|
89
|
+
expect(data.self).toBe("/products/42");
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Caveats
|
|
94
|
+
|
|
95
|
+
- `ctx.reverse(...)` throws unless you pass `routeMap` (and `routeName` for scoped `.name` resolution). It does NOT fall back to the global route map.
|
|
96
|
+
- `ctx.rendered()` throws by default (the render barrier only exists in a full match); pass `{ rendered: true }` to mock it for post-barrier logic, and `{ handles: [[SomeHandle, data]] }` to seed `ctx.use(SomeHandle)`. `ctx.isAction(...)` is unavailable — cover those at e2e.
|
|
97
|
+
- Dependency data from `loaders`/`use` is SEEDED, never executed; real loader execution and side-effects are e2e-only. `loaders` (by-reference tuples) is checked before the dynamic `use` resolver.
|
|
98
|
+
- A handle imported through the CLIENT build has its body dropped — `runLoader` throws a clear error pointing to the `rangoTestConfig()` preset or the raw body. A router using `Prerender()`/`createLoader()`/`Static()` now constructs in a bare test (each assigns a runtime fallback `$$id`); only the whole router _file_ may still need the plugin (its page modules pull app deps / `virtual:` modules).
|
|
99
|
+
- No `cookies`/`headers` option: seed a cookie by passing a full Request with a Cookie header — `{ request: new Request(url, { headers: { Cookie: "sid=abc" } }) }`. (`search`/`method` are baked onto this request for you.)
|
|
100
|
+
- `ctx.search` (typed) defaults to `{}`; `opts.search` only sets the raw `ctx.searchParams`. Seed the typed object with `searchData`.
|
|
101
|
+
- `ctx.theme`/`ctx.setTheme` are inert unless you pass `theme` (the `createRouter({ theme })` shape). `redirect()` does no basename prefixing unless you seed `basename`.
|
|
102
|
+
- Platform bindings are yours to double via `env` (see `./bindings.md`).
|
|
103
|
+
|
|
104
|
+
## See also
|
|
105
|
+
|
|
106
|
+
- `/loader` — the DSL this tests
|
|
107
|
+
- Siblings: `./handles.md`, `./reverse-and-types.md`, `./bindings.md`, `./server-actions.md`
|
|
108
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "Loaders — the raw body or a registered createLoader"
|