@rangojs/router 0.16.0 → 0.17.0
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 +254 -89
- package/dist/testing/vitest.js +252 -0
- package/dist/types/browser/navigation-transaction.d.ts +6 -0
- package/dist/types/browser/partial-update.d.ts +11 -0
- package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
- package/dist/types/browser/rsc-router.d.ts +1 -1
- package/dist/types/browser/scroll-restoration.d.ts +7 -0
- package/dist/types/cache/cache-exec-scope.d.ts +18 -1
- package/dist/types/cache/cache-scope.d.ts +34 -5
- package/dist/types/cache/handle-capture.d.ts +41 -3
- package/dist/types/cache/handle-snapshot.d.ts +53 -13
- package/dist/types/cache/segment-codec.d.ts +10 -3
- package/dist/types/cache/taint.d.ts +1 -1
- package/dist/types/cache/types.d.ts +15 -3
- package/dist/types/client.d.ts +4 -4
- package/dist/types/client.rsc.d.ts +4 -5
- package/dist/types/components/DefaultDocument.d.ts +5 -2
- package/dist/types/context-var.d.ts +6 -5
- package/dist/types/escape-script.d.ts +1 -1
- package/dist/types/handles/MetaTags.d.ts +5 -4
- package/dist/types/handles/Scripts.d.ts +8 -7
- package/dist/types/handles/meta.d.ts +2 -2
- package/dist/types/handles/script.d.ts +17 -16
- package/dist/types/href-client.d.ts +1 -1
- package/dist/types/html.d.ts +12 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.rsc.d.ts +3 -2
- package/dist/types/loader-store.d.ts +7 -4
- package/dist/types/reverse.d.ts +36 -4
- package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
- package/dist/types/route-definition/helpers-types.d.ts +33 -9
- package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
- package/dist/types/route-types.d.ts +1 -1
- package/dist/types/router/debug-manifest.d.ts +3 -2
- package/dist/types/router/error-handling.d.ts +20 -0
- package/dist/types/router/handler-context.d.ts +6 -0
- package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
- package/dist/types/router/middleware-types.d.ts +3 -2
- package/dist/types/router/router-context.d.ts +3 -1
- package/dist/types/router/router-interfaces.d.ts +24 -2
- package/dist/types/router/router-options.d.ts +8 -5
- package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
- package/dist/types/router/segment-resolution/loader-cache.d.ts +37 -1
- package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +3 -3
- package/dist/types/router/segment-resolution/mask-nested.d.ts +7 -7
- package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
- package/dist/types/router/segment-wrappers.d.ts +1 -1
- package/dist/types/rsc/response-route-handler.d.ts +1 -1
- package/dist/types/rsc/rsc-rendering.d.ts +1 -2
- package/dist/types/rsc/shell-capture-constants.d.ts +8 -0
- package/dist/types/rsc/shell-capture.d.ts +5 -4
- package/dist/types/rsc/ssr-setup.d.ts +6 -6
- package/dist/types/rsc/types.d.ts +10 -0
- package/dist/types/server/context.d.ts +33 -3
- package/dist/types/server/handle-store.d.ts +26 -2
- package/dist/types/server/request-context.d.ts +24 -21
- package/dist/types/ssr/index.d.ts +15 -0
- package/dist/types/static-handler.d.ts +4 -1
- package/dist/types/testing/generated-routes.d.ts +2 -2
- package/dist/types/testing/internal/context.d.ts +1 -1
- package/dist/types/testing/render-route.d.ts +32 -6
- package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
- package/dist/types/testing/vitest.d.ts +35 -1
- package/dist/types/theme/ThemeScript.d.ts +3 -3
- package/dist/types/types/cache-types.d.ts +14 -10
- package/dist/types/types/handler-context.d.ts +14 -0
- package/dist/types/types/loader-types.d.ts +8 -6
- package/dist/types/urls/response-types.d.ts +3 -12
- package/dist/types/use-loader.d.ts +12 -3
- package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
- package/dist/types/vite/rango.d.ts +1 -1
- package/dist/vite/index.js +1381 -1346
- package/package.json +2 -2
- package/skills/api-client/SKILL.md +22 -10
- package/skills/breadcrumbs/SKILL.md +65 -22
- package/skills/bundle-analysis/SKILL.md +22 -22
- package/skills/cache-guide/SKILL.md +207 -97
- package/skills/caching/SKILL.md +295 -93
- package/skills/catalog.json +16 -16
- package/skills/client-urls/SKILL.md +53 -31
- package/skills/cloudflare/SKILL.md +72 -20
- package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
- package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
- package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
- package/skills/comparison/SKILL.md +5 -0
- package/skills/comparison/references/framework-comparison.md +39 -33
- package/skills/composability/SKILL.md +38 -29
- package/skills/css/SKILL.md +10 -13
- package/skills/debug-manifest/SKILL.md +74 -31
- package/skills/defer-hydration/SKILL.md +2 -1
- package/skills/deployment-caching/SKILL.md +6 -1
- package/skills/document-cache/SKILL.md +92 -28
- package/skills/fonts/SKILL.md +16 -8
- package/skills/handler-use/SKILL.md +53 -17
- package/skills/hooks/SKILL.md +28 -20
- package/skills/hooks/data.md +23 -3
- package/skills/hooks/handle-and-actions.md +31 -16
- package/skills/hooks/navigation.md +39 -10
- package/skills/hooks/outlets.md +13 -6
- package/skills/hooks/state.md +37 -15
- package/skills/hooks/urls.md +7 -5
- package/skills/host-router/SKILL.md +64 -12
- package/skills/i18n/SKILL.md +60 -34
- package/skills/intercept/SKILL.md +161 -26
- package/skills/layout/SKILL.md +106 -50
- package/skills/links/SKILL.md +51 -19
- package/skills/loader/SKILL.md +242 -106
- package/skills/middleware/SKILL.md +119 -23
- package/skills/migrate-nextjs/SKILL.md +100 -45
- package/skills/migrate-nextjs/backend-host-swap.md +5 -2
- package/skills/migrate-react-router/SKILL.md +49 -38
- package/skills/migrate-react-router/component-migration.md +25 -6
- package/skills/migrate-react-router/data-and-actions.md +18 -5
- package/skills/migrate-react-router/route-mapping.md +12 -6
- package/skills/mime-routes/SKILL.md +12 -7
- package/skills/observability/SKILL.md +28 -14
- package/skills/parallel/SKILL.md +75 -39
- package/skills/ppr/SKILL.md +345 -150
- package/skills/prerender/SKILL.md +189 -100
- package/skills/rango/SKILL.md +65 -34
- package/skills/react-compiler/SKILL.md +6 -5
- package/skills/response-routes/SKILL.md +133 -25
- package/skills/route/SKILL.md +165 -60
- package/skills/router-setup/SKILL.md +418 -240
- package/skills/scripts/SKILL.md +31 -20
- package/skills/server-actions/SKILL.md +220 -59
- package/skills/shell-manifest/SKILL.md +12 -7
- package/skills/streams-and-websockets/SKILL.md +32 -14
- package/skills/tailwind/SKILL.md +37 -33
- package/skills/testing/SKILL.md +68 -32
- package/skills/testing/cache-prerender.md +55 -29
- package/skills/testing/client-components.md +59 -6
- package/skills/testing/e2e-parity.md +12 -2
- package/skills/testing/flight.md +19 -11
- package/skills/testing/handles.md +8 -5
- package/skills/testing/loader.md +8 -5
- package/skills/testing/middleware.md +8 -3
- package/skills/testing/predicates.md +120 -0
- package/skills/testing/render-handler.md +70 -22
- package/skills/testing/response-routes.md +11 -4
- package/skills/testing/reverse-and-types.md +20 -14
- package/skills/testing/server-actions.md +12 -3
- package/skills/testing/server-tree.md +9 -0
- package/skills/testing/setup.md +9 -5
- package/skills/theme/SKILL.md +106 -29
- package/skills/typesafety/SKILL.md +20 -9
- package/skills/typesafety/env-and-bindings.md +126 -47
- package/skills/typesafety/generated-files-and-cli.md +26 -14
- package/skills/typesafety/params-and-search.md +44 -19
- package/skills/typesafety/route-types.md +50 -14
- package/skills/use-cache/SKILL.md +200 -82
- package/skills/vercel/SKILL.md +21 -11
- package/skills/view-transitions/SKILL.md +41 -20
- package/src/__augment-tests__/augmented.check.ts +26 -1
- package/src/browser/index.ts +2 -2
- package/src/browser/navigation-bridge.ts +3 -4
- package/src/browser/navigation-transaction.ts +22 -2
- package/src/browser/partial-update.ts +35 -25
- package/src/browser/react/ScrollRestoration.tsx +6 -6
- package/src/browser/rsc-router.tsx +1 -1
- package/src/browser/scroll-restoration.ts +53 -6
- package/src/cache/cache-exec-scope.ts +37 -4
- package/src/cache/cache-key-utils.ts +1 -6
- package/src/cache/cache-runtime.ts +230 -61
- package/src/cache/cache-scope.ts +76 -12
- package/src/cache/document-cache.ts +15 -0
- package/src/cache/handle-capture.ts +90 -10
- package/src/cache/handle-snapshot.ts +132 -28
- package/src/cache/segment-codec.ts +26 -7
- package/src/cache/tag-invalidation.ts +4 -3
- package/src/cache/taint.ts +1 -1
- package/src/cache/types.ts +16 -3
- package/src/client.rsc.tsx +10 -9
- package/src/client.tsx +5 -4
- package/src/components/DefaultDocument.tsx +7 -2
- package/src/context-var.ts +6 -5
- package/src/escape-script.ts +1 -1
- package/src/handles/MetaTags.tsx +13 -11
- package/src/handles/Scripts.tsx +11 -10
- package/src/handles/meta.ts +2 -2
- package/src/handles/script.ts +17 -16
- package/src/href-client.ts +1 -1
- package/src/html.ts +12 -0
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +1 -0
- package/src/loader-store.ts +7 -4
- package/src/prerender/build-shell-capture.ts +8 -11
- package/src/reverse.ts +44 -4
- package/src/route-definition/dsl-helpers.ts +117 -30
- package/src/route-definition/helpers-types.ts +33 -9
- package/src/route-definition/resolve-handler-use.ts +121 -6
- package/src/route-types.ts +0 -3
- package/src/router/debug-manifest.ts +10 -2
- package/src/router/error-handling.ts +51 -15
- package/src/router/handler-context.ts +34 -25
- package/src/router/intercept-resolution.ts +125 -39
- package/src/router/loader-resolution.ts +56 -11
- package/src/router/match-api.ts +12 -17
- package/src/router/match-middleware/background-revalidation.ts +161 -96
- package/src/router/match-middleware/cache-lookup.ts +93 -10
- package/src/router/match-middleware/cache-store.ts +17 -90
- package/src/router/middleware-types.ts +3 -5
- package/src/router/middleware.ts +52 -9
- package/src/router/prerender-match.ts +53 -19
- package/src/router/route-snapshot.ts +11 -1
- package/src/router/router-context.ts +2 -0
- package/src/router/router-interfaces.ts +25 -2
- package/src/router/router-options.ts +8 -5
- package/src/router/segment-resolution/fresh.ts +44 -12
- package/src/router/segment-resolution/loader-cache.ts +205 -45
- package/src/router/segment-resolution/loader-mask.ts +12 -22
- package/src/router/segment-resolution/loader-snapshot.ts +6 -6
- package/src/router/segment-resolution/mask-nested.ts +7 -7
- package/src/router/segment-resolution/revalidation.ts +34 -6
- package/src/router/segment-wrappers.ts +4 -0
- package/src/rsc/render-pipeline.ts +3 -0
- package/src/rsc/response-route-handler.ts +11 -3
- package/src/rsc/rsc-rendering.ts +8 -45
- package/src/rsc/shell-capture-constants.ts +13 -0
- package/src/rsc/shell-capture.ts +111 -90
- package/src/rsc/ssr-setup.ts +14 -8
- package/src/rsc/types.ts +11 -0
- package/src/server/context.ts +88 -11
- package/src/server/cookie-store.ts +5 -4
- package/src/server/handle-store.ts +232 -7
- package/src/server/request-context.ts +35 -30
- package/src/ssr/index.tsx +40 -7
- package/src/static-handler.ts +4 -1
- package/src/testing/flight-runtime.d.ts +21 -0
- package/src/testing/generated-routes.ts +2 -2
- package/src/testing/internal/context.ts +1 -1
- package/src/testing/internal/flight-client-globals.ts +7 -5
- package/src/testing/render-route.tsx +85 -24
- package/src/testing/run-loader.ts +5 -1
- package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
- package/src/testing/vitest.ts +62 -1
- package/src/theme/ThemeScript.tsx +3 -3
- package/src/types/cache-types.ts +14 -10
- package/src/types/handler-context.ts +14 -0
- package/src/types/loader-types.ts +8 -6
- package/src/urls/response-types.ts +3 -23
- package/src/use-loader.tsx +12 -3
- package/src/vite/plugins/use-cache-transform.ts +118 -74
- package/src/vite/rango.ts +7 -3
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Layer:** unit (DOM) · **Import:** `@rangojs/router/testing/dom` · **DSL it tests:** a client component reading router context (see `/hooks`)
|
|
4
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.
|
|
5
|
+
`renderRoute(routes, options?)` is an RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter`), and it is async — `await` it. 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
6
|
|
|
7
7
|
## API
|
|
8
8
|
|
|
@@ -25,7 +25,7 @@ RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter
|
|
|
25
25
|
| `nonce` | `string` | CSP nonce to seed via `NonceContext`, so a component calling `useNonce()` (e.g. an analytics/GTM head script) sees it — mirroring SSR. Defaults to `undefined` (the browser default). |
|
|
26
26
|
| `defaultPrefetch` | `PrefetchStrategy` | Router default for `<Link>` and eligible plain anchors. `data-prefetch="false"`/`"none"` opts out one anchor; ancestor `data-prefetch-scope="false"`/`"none"` hard-disables the subtree; `"true"` permits routed resource suffixes elsewhere. |
|
|
27
27
|
|
|
28
|
-
`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.
|
|
28
|
+
`RenderRouteSpec = { path, Component, layout?, loaderIds?, name?, transition? }` — 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. `transition` is the `transition()` config this node declares (`{}` for a bare `transition()`), attached to its segment exactly as the DSL does — see [Held navigation](#held-navigation).
|
|
29
29
|
|
|
30
30
|
### Context — client hooks it makes resolve (what your code receives)
|
|
31
31
|
|
|
@@ -40,7 +40,7 @@ RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter
|
|
|
40
40
|
| `usePathname` | Current committed pathname. |
|
|
41
41
|
| `useSearchParams` | Search params from the `request` URL. |
|
|
42
42
|
| `useNonce` | SEEDED CSP nonce (`options.nonce`), else `undefined` (the browser default). |
|
|
43
|
-
| `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path).
|
|
43
|
+
| `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). Held-navigation `isLoading` is modeled (below). |
|
|
44
44
|
| `useLocationState` | SEEDED `history.state` value. |
|
|
45
45
|
| `useHandle` | SEEDED handle output (globally accumulated). |
|
|
46
46
|
| `Outlet` | Renders the next segment in the chain (layout nesting). |
|
|
@@ -54,7 +54,13 @@ Extends RTL's `RenderResult` (`getByTestId`, `getByText`, `getByRole`, `containe
|
|
|
54
54
|
```ts
|
|
55
55
|
type RenderRouteResult = RenderResult & {
|
|
56
56
|
router: {
|
|
57
|
-
|
|
57
|
+
// client-only nav, re-resolves the same routes; `loaders` seeds THIS nav
|
|
58
|
+
navigate(
|
|
59
|
+
url: string,
|
|
60
|
+
options?: {
|
|
61
|
+
loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
|
|
62
|
+
},
|
|
63
|
+
): Promise<void>;
|
|
58
64
|
pathname(): string;
|
|
59
65
|
params(): Record<string, string>;
|
|
60
66
|
store: NavigationStore; // advanced
|
|
@@ -108,17 +114,64 @@ it("resolves params + reverse + Outlet through the layout chain", async () => {
|
|
|
108
114
|
});
|
|
109
115
|
```
|
|
110
116
|
|
|
117
|
+
## Held navigation
|
|
118
|
+
|
|
119
|
+
A stale indicator driven by `useLoader().isLoading` (see `/hooks` data.md, "A held navigation flags the data it keeps on screen") IS unit-testable. Put `transition` on the spec that declares `transition()` in your `urls()`, and pass the next navigation's loader data to `router.navigate(url, { loaders })` as a PENDING Promise. navigate() then commits through production's `commitInTransition` (browser/partial-update.ts), which calls `loaderStore.announcePendingStreams` inside the `startTransition`. The harness does not fake the flag: React holds the reader on screen and the real `useLoader` pin reports `isLoading: true` until the promise settles.
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
import { act } from "@testing-library/react";
|
|
123
|
+
import { useLoader } from "@rangojs/router/client";
|
|
124
|
+
import { ProductLoader } from "../loaders/product"; // path("/products/:id", ..., () => [loader(ProductLoader), transition()])
|
|
125
|
+
|
|
126
|
+
function ProductPrice() {
|
|
127
|
+
const { data, isLoading } = useLoader(ProductLoader);
|
|
128
|
+
return (
|
|
129
|
+
<p data-testid="price" aria-busy={isLoading}>
|
|
130
|
+
{data.price}
|
|
131
|
+
</p>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
it("dims the held price while the next product streams", async () => {
|
|
136
|
+
const { getByTestId, router } = await renderRoute(
|
|
137
|
+
[{ path: "/products/:id", Component: ProductPrice, transition: {} }],
|
|
138
|
+
{ request: "/products/1", loaders: [[ProductLoader, { price: 10 }]] },
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
let resolve!: (value: { price: number }) => void;
|
|
142
|
+
const next = new Promise<{ price: number }>((r) => (resolve = r));
|
|
143
|
+
await router.navigate("/products/2", { loaders: [[ProductLoader, next]] });
|
|
144
|
+
expect(getByTestId("price").getAttribute("aria-busy")).toBe("true"); // old price, stale
|
|
145
|
+
|
|
146
|
+
await act(async () => resolve({ price: 12 })); // settle INSIDE act to flush the commit
|
|
147
|
+
expect(getByTestId("price").getAttribute("aria-busy")).toBe("false");
|
|
148
|
+
expect(getByTestId("price").textContent).toBe("12");
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
| Pending state | Under `renderRoute` |
|
|
153
|
+
| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
154
|
+
| `useLoader().isLoading` on the reader a `transition` navigate() holds, pending seed | Modeled: `true` on the OLD data until the promise settles, then `false` with the new data in the same commit. |
|
|
155
|
+
| `transition` navigate(), settled seed (a plain value) | `false`; the data swaps in the commit. |
|
|
156
|
+
| navigate() without `transition`, pending seed | Urgent commit (production's cold-navigation lane): the read suspends to the nearest `<Suspense>` fallback (with none, React keeps the previous tree). No flag. |
|
|
157
|
+
| Production's other hold lanes (same-structure search/filter nav, fully-prefetched, optimistic) | NOT modeled — without `transition` navigate() stays urgent. Assert those at e2e. |
|
|
158
|
+
| `useNavigation().state`, `useLinkStatus().pending`, `useAction().state` | Stay `idle` — navigate() never starts the navigation lifecycle. e2e. |
|
|
159
|
+
|
|
160
|
+
- Per-navigation seeds merge over the render-time `loaders`/`loaderData` for THAT navigation only; a later `navigate()` without `loaders` falls back to the render-time data.
|
|
161
|
+
- Pass settled data as a plain value, not `Promise.resolve(value)`: React has not observed a plain promise yet, so it counts as pending and renders one `stale` frame. (A Flight chunk that has already settled carries its status and is skipped, which a plain value mirrors.)
|
|
162
|
+
- `loading()` is not a spec field: a `<Suspense>` in your layout component stands in for it.
|
|
163
|
+
|
|
111
164
|
## Caveats
|
|
112
165
|
|
|
113
166
|
- 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.
|
|
114
|
-
- `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.
|
|
167
|
+
- `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. The one modeled pending state is held-navigation `useLoader().isLoading` ([Held navigation](#held-navigation)).
|
|
115
168
|
- `outletPending` seeds only the production-shaped outlet context. It is useful
|
|
116
169
|
for the two settled render states of a layout that reads `useOutlet()`, but it
|
|
117
170
|
does not prove the hydrated `clientUrls()` transition that toggles the value;
|
|
118
171
|
keep that transition in dev + production e2e.
|
|
119
172
|
- 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.
|
|
120
173
|
- 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")`.
|
|
121
|
-
- `
|
|
174
|
+
- `useTheme()` throws unless `theme` is passed (it needs the `ThemeProvider` that option mounts). Search state comes only from the `request` URL — there is no typed-search seed here (that is `runLoader`'s `searchData`).
|
|
122
175
|
- 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.
|
|
123
176
|
- Needs a DOM env (`// @vitest-environment happy-dom`, or jsdom) and `@testing-library/react` (optional peers).
|
|
124
177
|
- 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.
|
|
@@ -16,7 +16,7 @@ This is full-stack: the harness builds and serves your real app (`pnpm dev` or `
|
|
|
16
16
|
| `expect` | `Expect` | Your Playwright `expect` (used by helpers + matchers). |
|
|
17
17
|
| `defaultRoot` | `string?` | Fallback app root for `parityDescribe` when a call omits `options.root`. |
|
|
18
18
|
|
|
19
|
-
### Fixture options — `FixtureOptions` (`useFixture
|
|
19
|
+
### Fixture options — `FixtureOptions` (`useFixture`; `parityDescribe`'s 3rd arg takes the same fields minus `mode`)
|
|
20
20
|
|
|
21
21
|
| Field | Type | Meaning |
|
|
22
22
|
| ---------------- | ------------------ | ------------------------------------------------------------------------------ |
|
|
@@ -54,7 +54,17 @@ This is full-stack: the harness builds and serves your real app (`pnpm dev` or `
|
|
|
54
54
|
- `expectParity(page, intent, opts) => 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. `opts` is the required `observe` plus optional `baseURL`, `waitFor`, and `ignoreCookies` (the rango state cookie is excluded automatically).
|
|
55
55
|
- `rangoMatchers` — `{ toHaveRangoPathname }` only (pass to `expect.extend`).
|
|
56
56
|
- `testNoJs` — a `test` variant with JavaScript disabled.
|
|
57
|
-
- Page helpers:
|
|
57
|
+
- Page helpers:
|
|
58
|
+
- navigation/state: `waitForHydration(page)`, `waitForNavigation(page, url)`, `goBack(page)` / `goForward(page)`, `getHistoryState(page)`;
|
|
59
|
+
- elements: `testId(page, id)`, `waitForElement`, `isVisibleInViewport`, `parseNumber(text)`, `getNumericContent(locator)`;
|
|
60
|
+
- assertions bound to your `expect`: `expectNoReload(page)` (use with `await using`), `expectNoPageError(page)` (use with `using`), `waitForTextChange`, `waitForNumericChange`;
|
|
61
|
+
- timing: `createStopwatch()`, `measureTime(fn)`, `expectTiming`, `expectMinTiming`, `expectMaxTiming`.
|
|
62
|
+
|
|
63
|
+
The entry also exports standalone functions that are NOT on the factory result:
|
|
64
|
+
|
|
65
|
+
- Prefetch control: `isPrefetchRequest(req)` (true for requests carrying `X-Rango-Prefetch`), `blockPrefetch(page)` / `unblockPrefetch(page)` (abort speculative prefetches — install before `page.goto` when a test needs the click's own navigation fetch, since a warmed Link adopts its prefetch and issues none).
|
|
66
|
+
- The building blocks `createRangoE2E` composes: `createUseFixture(test)`, `createParity({ test, expect, useFixture, defaultRoot? })`, `createPageHelpers(expect)`, `createRangoMatchers(expect)`.
|
|
67
|
+
- The cache/shell status helpers (`assertCacheStatus`, `assertShellStatus`, ... — see `./cache-prerender.md`), re-exported so a plain Playwright runner can use them.
|
|
58
68
|
|
|
59
69
|
## Recipe
|
|
60
70
|
|
package/skills/testing/flight.md
CHANGED
|
@@ -4,21 +4,24 @@
|
|
|
4
4
|
|
|
5
5
|
> **Prefer `renderServerTree` (see [`./server-tree.md`](./server-tree.md)) for assertions on a Flight render** — it deserializes to a traversable tree with TYPED boundary props (a `Date` is a `Date`, not the opaque `$D...` encoding). Reach for `renderToFlightString` + the wire matchers (`toMatchFlight`/`toMatchFlightSnapshot`) only to pin the raw wire payload SHAPE — a `toMatchFlightSnapshot` drift snapshot. That is the niche/escape-hatch case; for "testing an async Server Component (assert what it rendered)" start at `./server-tree.md`.
|
|
6
6
|
|
|
7
|
-
`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,
|
|
7
|
+
`renderToFlightString(element, opts?)` 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, route map, vars, theme, and cache store that context exposes.
|
|
8
8
|
|
|
9
9
|
## API
|
|
10
10
|
|
|
11
11
|
### Options — `RenderToFlightStringOptions`
|
|
12
12
|
|
|
13
|
-
| Field
|
|
14
|
-
|
|
|
15
|
-
| `request`
|
|
16
|
-
| `headers`
|
|
17
|
-
| `env`
|
|
18
|
-
| `params`
|
|
19
|
-
| `routeName`
|
|
20
|
-
| `routeMap`
|
|
21
|
-
| `vars`
|
|
13
|
+
| Field | Type | Meaning |
|
|
14
|
+
| --------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| `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. |
|
|
16
|
+
| `headers` | `HeadersInit` | Request headers (e.g. Cookie) visible to the server tree, used only when `request` is a string. |
|
|
17
|
+
| `env` | `unknown` | Env / bindings exposed as `ctx.env`. Defaults to `{}`. |
|
|
18
|
+
| `params` | `Record<string, string>` | Route params exposed via `ctx.params` and loader contexts. |
|
|
19
|
+
| `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
|
|
20
|
+
| `routeMap` | `Record<string, string>` | Route name -> pattern map scoping `ctx.reverse()` (like `renderHandler`). Without it, a component that reverses resolves against the GLOBAL route map and is order-dependent on whatever router registered last. Pass the router-under-test's map for deterministic reverse. |
|
|
21
|
+
| `vars` | `VarsInit` | Variables a prior middleware would have set, visible via `ctx.get(...)`. Object form (`{ user }`) or `[key, value]` tuples (`[[userVar, u]]`). |
|
|
22
|
+
| `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape). Without it `getRequestContext().theme` is `undefined` and `setTheme` is inert. |
|
|
23
|
+
| `cacheStore` | `SegmentCacheStore` | Store backing a `"use cache"` function the tree calls. Without it the cached path is bypassed (the function runs uncached). |
|
|
24
|
+
| `cacheProfiles` | `Record<string, CacheProfile>` | Profiles for `"use cache: name"`, the `createRouter({ cacheProfiles })` shape. Pair with `cacheStore`. |
|
|
22
25
|
|
|
23
26
|
### Context — `RequestContext` (what your component receives)
|
|
24
27
|
|
|
@@ -42,7 +45,12 @@ expect(await renderToFlightString(<C />)).toMatchFlight("substring"); // contain
|
|
|
42
45
|
expect(await renderToFlightString(<C />)).toMatchFlightSnapshot(); // normalized snapshot
|
|
43
46
|
```
|
|
44
47
|
|
|
45
|
-
`toMatchFlight(substring)` is containment (not equality) on the normalized payload; `toMatchFlightSnapshot()` snapshots the normalized payload. Both matchers live at `@rangojs/router/testing/flight-matchers`
|
|
48
|
+
`toMatchFlight(substring)` is containment (not equality) on the normalized payload; `toMatchFlightSnapshot()` snapshots the normalized payload. Both matchers live at `@rangojs/router/testing/flight-matchers`, which imports `vitest` but not the Flight serializer — so it also loads under the plain node condition (a shared `setupFiles` can `expect.extend(flightMatchers)` for both projects). The Flight strings they assert still come from `renderToFlightString`, which runs only under the react-server project (see `./setup.md`). Importing the entry also adds the matcher types to vitest's `Assertion`.
|
|
49
|
+
|
|
50
|
+
### Helpers
|
|
51
|
+
|
|
52
|
+
- `normalizeFlight(flight)` — the normalization both matchers apply: strips the dev-only leading `:N<timestamp>` row and rewrites absolute `file://` paths to `file://<path>`. Use it when asserting on the raw string yourself.
|
|
53
|
+
- `assertFlightRuntimeAvailable()` — throws a descriptive error when the vendored react-server-dom serializer is missing (for example after a `@vitejs/plugin-rsc` upgrade moved it). Call it in a `beforeAll` to fail fast with a clear message.
|
|
46
54
|
|
|
47
55
|
## Recipe
|
|
48
56
|
|
|
@@ -61,10 +61,10 @@ it("runs a custom 'last wins' collect", () => {
|
|
|
61
61
|
```ts
|
|
62
62
|
// loader-reads-handle.test.ts — a loader reading accumulated handle data after the barrier
|
|
63
63
|
import { it, expect } from "vitest";
|
|
64
|
-
import { runLoader } from "@rangojs/router/testing";
|
|
64
|
+
import { runLoader, type TestLoaderContext } from "@rangojs/router/testing";
|
|
65
65
|
import { RenderedProducts } from "../src/handles"; // a createHandle(...)
|
|
66
66
|
|
|
67
|
-
const livePricesBody = async (ctx) => {
|
|
67
|
+
const livePricesBody = async (ctx: TestLoaderContext) => {
|
|
68
68
|
await ctx.rendered(); // barrier: handle data is now readable
|
|
69
69
|
const ids = ctx.get(RenderedProducts) as string[];
|
|
70
70
|
return ids.map((id) => ({ id, price: 9.99 }));
|
|
@@ -85,10 +85,13 @@ it("reads the accumulated handle value (seed the OUTPUT, mock the barrier)", asy
|
|
|
85
85
|
```ts
|
|
86
86
|
// loader-writes-handle.test.ts — a loader PUSHING meta/breadcrumbs (handler parity)
|
|
87
87
|
import { it, expect } from "vitest";
|
|
88
|
-
import {
|
|
89
|
-
|
|
88
|
+
import {
|
|
89
|
+
runLoaderResult,
|
|
90
|
+
type TestLoaderContext,
|
|
91
|
+
} from "@rangojs/router/testing";
|
|
92
|
+
import { Meta } from "@rangojs/router";
|
|
90
93
|
|
|
91
|
-
const productBody = async (ctx) => {
|
|
94
|
+
const productBody = async (ctx: TestLoaderContext) => {
|
|
92
95
|
const product = { name: "Widget", slug: "widget" };
|
|
93
96
|
ctx.use(Meta)({ title: `${product.name} — Shop` });
|
|
94
97
|
return product;
|
package/skills/testing/loader.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `loader()` (see `/loader`)
|
|
4
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()`
|
|
5
|
+
`runLoader(loader, opts?)` 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()` definition (its fn is recovered from the registry) or the raw async body `(ctx) => ...`; type a raw body's argument as `TestLoaderContext` (exported from `@rangojs/router/testing`).
|
|
6
6
|
|
|
7
7
|
## API
|
|
8
8
|
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
| `formData` | `FormData \| undefined` | Form data (from `opts.formData`). |
|
|
52
52
|
| `reverse` | `(name, params?, search?) => string` | Build a URL; throws unless `opts.routeMap` was passed. |
|
|
53
53
|
| `rendered` | `() => Promise<void>` | The render barrier; throws by default, mocked via `opts.rendered`. |
|
|
54
|
-
| `waitUntil` | `(
|
|
54
|
+
| `waitUntil` | `(fn: () => Promise<void>) => void` | Register background work; takes a function, not a promise (no-op accounting in tests). |
|
|
55
55
|
| `executionContext` | `ExecutionContext \| undefined` | Platform execution context from the backing request; pairs with `waitUntil`. |
|
|
56
56
|
|
|
57
57
|
### Returns — `Promise<T>`
|
|
@@ -70,8 +70,10 @@ order). `runLoader` discards those effects.
|
|
|
70
70
|
## Recipe
|
|
71
71
|
|
|
72
72
|
```ts
|
|
73
|
+
import { it, expect } from "vitest";
|
|
73
74
|
import { runLoader, runLoaderResult } from "@rangojs/router/testing";
|
|
74
75
|
import { createLoader, createVar } from "@rangojs/router";
|
|
76
|
+
import { AuthLoader } from "../src/loaders/auth"; // sets a `session` cookie, then throw redirect("/")
|
|
75
77
|
|
|
76
78
|
const User = createVar<{ name: string }>();
|
|
77
79
|
// The registered loader — no separate body export needed for testability:
|
|
@@ -100,7 +102,6 @@ it("builds a self link via reverse", async () => {
|
|
|
100
102
|
});
|
|
101
103
|
|
|
102
104
|
it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
|
|
103
|
-
// AuthLoader validates, sets a `session` cookie, then `throw redirect("/")`.
|
|
104
105
|
const { thrown, response, cookies } = await runLoaderResult(AuthLoader, {
|
|
105
106
|
request: new Request("https://app.test/login?token=ok"),
|
|
106
107
|
});
|
|
@@ -115,12 +116,14 @@ it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
|
|
|
115
116
|
## Caveats
|
|
116
117
|
|
|
117
118
|
- `ctx.reverse(...)` throws unless you pass `routeMap` (and `routeName` for scoped `.name` resolution). It does NOT fall back to the global route map.
|
|
118
|
-
- `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 the `ctx.get(SomeHandle)` read.
|
|
119
|
+
- `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 the `ctx.get(SomeHandle)` read.
|
|
120
|
+
- A loader has no `isAction()`: whether it re-runs after an action is decided by `revalidate()` on its registration, not inside the loader body. Assert post-action re-runs at e2e.
|
|
119
121
|
- Seeded `loaders` (by-reference tuples) are NOT executed — `ctx.use(OtherLoader)` returns the seeded value. The dynamic `use` resolver, by contrast, IS executed (it is a function called to compute the value). Either way the REAL loader body is not run; real loader execution and side-effects are e2e-only. `loaders` is checked before the `use` resolver.
|
|
120
|
-
- A
|
|
122
|
+
- A `createLoader()` definition 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).
|
|
121
123
|
- 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.)
|
|
122
124
|
- `ctx.search` (typed) defaults to `{}`; `opts.search` only sets the raw `ctx.searchParams`. Seed the typed object with `searchData`. (The harness seeds `searchData` verbatim — it does NOT run a typed-search SCHEMA, so schema parsing/validation is e2e.)
|
|
123
125
|
- `ctx.theme`/`ctx.setTheme` are NOT on the loader context — theme accessors are handler-only. (The `theme` option seeds the underlying request context for `use cache` theme resolution, but a loader body cannot read theme.) `redirect()` does no basename prefixing unless you seed `basename`.
|
|
126
|
+
- A `"use cache"` hit needs real Flight, which loads only under the react-server condition. In the react-server project (a `*.rsc-test.ts` file, see [`./setup.md`](./setup.md)) with `cacheStore` seeded, the body runs once across `runLoader` calls and the value round-trips. The write is a background (`waitUntil`) task `runLoader` does not await: a call before it lands joins the still-running execution, a call after it reads the store (wait with `vi.waitFor` on a `cacheStore.setItem` spy). In the node project a call looks up the store but never writes, so every call runs the body. A function written with the directive needs `rangoUseCacheTransform()` in the project's `plugins` (see [`./render-handler.md`](./render-handler.md#a-use-cache-hit)).
|
|
124
127
|
- Platform bindings are yours to double via `env` (see `./bindings.md`).
|
|
125
128
|
|
|
126
129
|
## See also
|
|
@@ -2,14 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
**Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `middleware()` (see `/middleware`)
|
|
4
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.
|
|
5
|
+
`runMiddleware(mw | mw[], opts)` 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
6
|
|
|
7
7
|
## API
|
|
8
8
|
|
|
9
9
|
### Options — `RunMiddlewareOptions<TEnv>`
|
|
10
10
|
|
|
11
|
+
`opts` is a required argument (unlike `runLoader`); pass `{}` when you need no seeds.
|
|
12
|
+
|
|
11
13
|
| Field | Type | Meaning |
|
|
12
14
|
| --------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| `build` | `boolean` | Seed `ctx.build` (default `false`) to test a middleware that branches on the build-time PPR shell-capture pass (e.g. `if (ctx.build) ctx.dynamic()`). With `true`, `ctx.waitUntil()` is inert, as at build time. Assert the opt-out via `result.dynamic`. |
|
|
13
16
|
| `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
17
|
| `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. Your seam for doubling platform bindings (see `./bindings.md`). |
|
|
15
18
|
| `params` | `Record<string, string>` | Route params surfaced as `ctx.params`. |
|
|
@@ -33,10 +36,11 @@ The `ctx` your middleware reads. Notable fields:
|
|
|
33
36
|
| `env` | `TEnv` | Bindings from `opts.env`. |
|
|
34
37
|
| `get` / `set` | fns | Read/write context vars (shared with handlers); `get` resolves what `vars` seeded. |
|
|
35
38
|
| `header(name, value)` | fn | Queue a response header before `next()`, or set it directly after. |
|
|
36
|
-
| `reverse` | `
|
|
39
|
+
| `reverse` | `GlobalReverseFunction` | Global names only: no auto-fill, `.name` is a type error. Needs `routeMap`. |
|
|
37
40
|
| `setLocationState(entries)` | fn | Attach flash/location state to the response. |
|
|
38
41
|
| `theme` / `setTheme` | `Theme` / fn | Current theme; `undefined` unless `theme` is passed. |
|
|
39
42
|
| `routeName` | `string` | Matched route name (from `opts.routeName`). |
|
|
43
|
+
| `build` / `dynamic()` | `boolean` / fn | Build-pass flag (from `opts.build`) and the PPR shell opt-out (`result.dynamic`). |
|
|
40
44
|
|
|
41
45
|
### Returns — `RunMiddlewareResult<TEnv>`
|
|
42
46
|
|
|
@@ -45,6 +49,7 @@ The `ctx` your middleware reads. Notable fields:
|
|
|
45
49
|
| `response` | `Response` | The final Response: the downstream response, or a middleware short-circuit. |
|
|
46
50
|
| `ctx` | `RequestContext<TEnv>` | The underlying RequestContext (NOT a per-middleware `MiddlewareContext`). Use `ctx.get(...)` for anything the envelope above doesn't surface. |
|
|
47
51
|
| `nextCalled` | `number` | Times the terminal handler ran: `0` on short-circuit, `1` on pass-through. |
|
|
52
|
+
| `dynamic` | `boolean` | Whether the chain called `ctx.dynamic()` (the PPR shell opt-out). |
|
|
48
53
|
| `cookies` | `Record<string, string>` | Effective cookie view: request cookies merged with chain sets/deletes (last-write-wins), as `{ name: value }`. |
|
|
49
54
|
| `headers` | `Record<string, string>` | Final response headers as `{ name: value }`, lowercased, EXCLUDING `set-cookie` (use `cookies`). |
|
|
50
55
|
| `locationState` | `Record<string, unknown>` | Flat `{ key: value }` state set via `setLocationState()` / `redirect({ state })` (empty when none). |
|
|
@@ -88,7 +93,7 @@ Pass an array to run several in order. Cookies set inside middleware via the sta
|
|
|
88
93
|
|
|
89
94
|
- No `handles`/`rendered` option by design: the middleware context has no handle APIs in production — no push (`ctx.use(Handle)` is a handler/loader API) and no post-barrier read (`ctx.get(handle)` is loader-only, and middleware runs BEFORE the render barrier anyway). Read handle data in a loader and test it with `runLoader`; assert loader pushes via `runLoaderResult(...).handlePushes` (see `./handles.md`).
|
|
90
95
|
- 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.
|
|
91
|
-
- 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).
|
|
96
|
+
- Middleware-phase `ctx.reverse` is map-only (global names 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, and `MiddlewareContext["reverse"]` rejects `.name` at compile time).
|
|
92
97
|
- `ctx.theme` is `undefined` unless `theme` is passed; `redirect()` does no basename prefixing unless `basename` is seeded.
|
|
93
98
|
- Platform bindings are yours to double via `env` (see `./bindings.md`).
|
|
94
99
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Testing navigation predicates — runTransitionWhen, runClientRevalidate
|
|
2
|
+
|
|
3
|
+
**Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `transition({ when })` (see `/view-transitions`) and `clientUrls()` `revalidate()` (see `/client-urls`)
|
|
4
|
+
|
|
5
|
+
Both primitives are synchronous and run the router's own evaluation code on arguments you seed, so a predicate sees the same fields it sees at runtime. Neither renders anything: they answer "would this gate keep the transition?" and "would this `clientUrls()` loader re-run?".
|
|
6
|
+
|
|
7
|
+
## runTransitionWhen(config, opts?)
|
|
8
|
+
|
|
9
|
+
Runs a `transition()` config through the production gate (`gateTransitions`, plus the PPR pre-handler evaluator when `ppr: true`) and reports whether the transition survives this request.
|
|
10
|
+
|
|
11
|
+
### Options — `RunTransitionWhenOptions<TEnv>`
|
|
12
|
+
|
|
13
|
+
Every field is optional. Omitted navigation fields model "source unavailable" (an initial document load); omitted `action*` fields model a plain navigation.
|
|
14
|
+
|
|
15
|
+
| Field | Type | Meaning |
|
|
16
|
+
| --------------- | ------------------------ | ------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| `request` | `Request \| string` | Navigation TARGET (drives `nextUrl`). Defaults to `http://localhost/`. |
|
|
18
|
+
| `params` | `Record<string, string>` | Target params (`nextParams`). |
|
|
19
|
+
| `toRouteName` | `string` | Target route name (`toRouteName`). |
|
|
20
|
+
| `currentUrl` | `string \| URL` | Navigation SOURCE (`currentUrl`). |
|
|
21
|
+
| `currentParams` | `Record<string, string>` | Source params (`currentParams`). |
|
|
22
|
+
| `fromRouteName` | `string` | Source route name (`fromRouteName`). |
|
|
23
|
+
| `actionId` | `string` | Id of the action that triggered the render. |
|
|
24
|
+
| `actionUrl` | `string \| URL` | URL the action was submitted from. |
|
|
25
|
+
| `actionResult` | `unknown` | The action's return value. |
|
|
26
|
+
| `formData` | `FormData` | Form data from a form action. |
|
|
27
|
+
| `env` | `TEnv` | Bindings surfaced as `env`. |
|
|
28
|
+
| `vars` | `VarsInit` | Values the predicate reads through `get()`. With `ppr`, these model pre-handler middleware state. |
|
|
29
|
+
| `onError` | `OnErrorCallback` | Receives an error the predicate throws (the gate reports it with phase `"rendering"`). |
|
|
30
|
+
| `ppr` | `boolean` | Model a `ppr` route, where the predicate runs before route handlers and the cache lookup. |
|
|
31
|
+
|
|
32
|
+
### Returns — `RunTransitionWhenResult<TEnv>`
|
|
33
|
+
|
|
34
|
+
| Field | Type | Meaning |
|
|
35
|
+
| ------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
|
36
|
+
| `kept` | `boolean` | True when the transition applies (the predicate did not return `false`, or there is no `when`). |
|
|
37
|
+
| `dropped` | `boolean` | `!kept`. |
|
|
38
|
+
| `whenContext` | `TransitionWhenContext \| undefined` | The context the predicate received; `undefined` when the config has no `when`. |
|
|
39
|
+
| `ctx` | `RequestContext<TEnv>` | The underlying request context, for extra assertions. |
|
|
40
|
+
|
|
41
|
+
### Recipe
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { it, expect } from "vitest";
|
|
45
|
+
import { runTransitionWhen } from "@rangojs/router/testing";
|
|
46
|
+
import type { TransitionConfig } from "@rangojs/router";
|
|
47
|
+
|
|
48
|
+
const slide: TransitionConfig = {
|
|
49
|
+
when: (c) => c.fromRouteName === "products",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
it("animates only when coming from the product list", () => {
|
|
53
|
+
expect(
|
|
54
|
+
runTransitionWhen(slide, {
|
|
55
|
+
request: "/products/1",
|
|
56
|
+
fromRouteName: "products",
|
|
57
|
+
}).kept,
|
|
58
|
+
).toBe(true);
|
|
59
|
+
expect(runTransitionWhen(slide, { request: "/products/1" }).dropped).toBe(
|
|
60
|
+
true,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## runClientRevalidate(fn | fn[], opts?)
|
|
66
|
+
|
|
67
|
+
Evaluates one `clientUrls()` loader `revalidate()` predicate, or a chain in declaration order, through the production chain evaluator: the locked default, boolean short-circuit, soft verdicts (`{ defaultShouldRevalidate }`), and a throwing predicate deferring to the default all behave as in the browser. Returns the final `boolean` (the locked default when every predicate defers or throws).
|
|
68
|
+
|
|
69
|
+
### Options — `RunClientRevalidateOptions`
|
|
70
|
+
|
|
71
|
+
Defaults model a same-URL navigation with no action.
|
|
72
|
+
|
|
73
|
+
| Field | Type | Meaning |
|
|
74
|
+
| --------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
75
|
+
| `currentUrl` | `string \| URL` | Source URL. Defaults to `http://localhost/`. |
|
|
76
|
+
| `nextUrl` | `string \| URL` | Target URL. Defaults to `currentUrl`. |
|
|
77
|
+
| `currentParams` | `Record<string, string>` | Source params. Defaults to `{}`. |
|
|
78
|
+
| `nextParams` | `Record<string, string>` | Target params. Defaults to `currentParams`. |
|
|
79
|
+
| `stale` | `boolean` | The `stale` arg. Defaults to `false`. |
|
|
80
|
+
| `action` | `(...args) => unknown \| string` | The triggering action: one imported server action (its id is read from the build-injected `$id`/`$$id`) or a raw action id string. A namespace object is rejected. Omit for a plain navigation. |
|
|
81
|
+
| `actionRequest` | `boolean` | Model the action-triggered refetch GET: `isAction()` stays true but the locked default is the navigation default. Defaults to treating `action` as the action POST itself. |
|
|
82
|
+
|
|
83
|
+
Outside a built app, an imported action carries no id, so pass the id string your predicate matches (for example `"src/actions/cart.ts#addToCart"`); `runClientRevalidate` throws a clear error otherwise.
|
|
84
|
+
|
|
85
|
+
### Recipe
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { it, expect } from "vitest";
|
|
89
|
+
import { runClientRevalidate } from "@rangojs/router/testing";
|
|
90
|
+
import type { ClientRevalidateFn } from "@rangojs/router/client";
|
|
91
|
+
|
|
92
|
+
const onlyOnTabChange: ClientRevalidateFn = ({ currentUrl, nextUrl }) =>
|
|
93
|
+
currentUrl.searchParams.get("tab") !== nextUrl.searchParams.get("tab");
|
|
94
|
+
|
|
95
|
+
it("revalidates only when the tab changes", () => {
|
|
96
|
+
expect(
|
|
97
|
+
runClientRevalidate(onlyOnTabChange, {
|
|
98
|
+
currentUrl: "/settings?tab=profile",
|
|
99
|
+
nextUrl: "/settings?tab=billing",
|
|
100
|
+
}),
|
|
101
|
+
).toBe(true);
|
|
102
|
+
expect(
|
|
103
|
+
runClientRevalidate(onlyOnTabChange, {
|
|
104
|
+
currentUrl: "/settings?tab=profile",
|
|
105
|
+
nextUrl: "/settings?tab=profile&sort=asc",
|
|
106
|
+
}),
|
|
107
|
+
).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Caveats
|
|
112
|
+
|
|
113
|
+
- Server-tree `revalidate()` predicates have no dedicated primitive: they are plain functions, so call them with a hand-built args object, or assert the post-action result at e2e.
|
|
114
|
+
- `runTransitionWhen` proves the GATE decision only. Whether the browser actually runs a view transition is e2e territory (see `./e2e-parity.md`).
|
|
115
|
+
- Both are synchronous; a predicate returning a Promise is a bug in the predicate, not something to await here.
|
|
116
|
+
|
|
117
|
+
## See also
|
|
118
|
+
|
|
119
|
+
- `/view-transitions`, `/client-urls` — the DSL these test
|
|
120
|
+
- Siblings: `./client-components.md`, `./e2e-parity.md`
|