@rangojs/router 0.20.0 → 0.21.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/dist/bin/rango.js +26 -14
- package/dist/types/browser/event-controller.d.ts +32 -0
- package/dist/types/browser/history-state.d.ts +16 -4
- package/dist/types/browser/navigation-transaction.d.ts +7 -0
- package/dist/types/browser/rango-state.d.ts +3 -1
- package/dist/types/browser/react/context.d.ts +11 -0
- package/dist/types/browser/react/location-state-shared.d.ts +84 -10
- package/dist/types/browser/react/location-state.d.ts +7 -2
- package/dist/types/browser/react/subscribe-to-nothing.d.ts +7 -0
- package/dist/types/cache/cache-scope.d.ts +4 -5
- package/dist/types/cache/cf/cf-cache-store.d.ts +27 -6
- package/dist/types/cache/cf/cf-cache-types.d.ts +12 -4
- package/dist/types/cache/handle-snapshot.d.ts +36 -50
- package/dist/types/cache/index.d.ts +2 -0
- package/dist/types/cache/shell-snapshot.d.ts +19 -3
- package/dist/types/cache/types.d.ts +21 -10
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +28 -16
- package/dist/types/client-urls/navigation.d.ts +7 -1
- package/dist/types/client-urls/optimistic-location.d.ts +10 -4
- package/dist/types/handles/deferred-resolution.d.ts +11 -1
- package/dist/types/prerender/build-shell-capture.d.ts +8 -6
- package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
- package/dist/types/router/navigation-snapshot.d.ts +8 -1
- package/dist/types/router/router-interfaces.d.ts +2 -1
- package/dist/types/router/router-options.d.ts +17 -7
- package/dist/types/router/segment-resolution/loader-cache.d.ts +21 -9
- package/dist/types/router-versions.d.ts +39 -0
- package/dist/types/rsc/handler-context.d.ts +5 -0
- package/dist/types/rsc/shell-build-manifest.d.ts +3 -2
- package/dist/types/rsc/types.d.ts +4 -6
- package/dist/types/server/build-version-table.d.ts +44 -0
- package/dist/types/server/handle-store.d.ts +87 -46
- package/dist/types/server/request-context.d.ts +22 -8
- package/dist/types/testing/build-versions.d.ts +32 -0
- package/dist/types/testing/dom.entry.d.ts +1 -1
- package/dist/types/testing/flight.entry.d.ts +1 -1
- package/dist/types/testing/index.d.ts +2 -0
- package/dist/types/testing/internal/seed-vars.d.ts +7 -2
- package/dist/types/testing/location-state-key.d.ts +5 -1
- package/dist/types/testing/render-route.d.ts +77 -6
- package/dist/types/testing/serve-shell-request.d.ts +21 -0
- package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
- package/dist/types/types/handler-context.d.ts +8 -5
- package/dist/types/vite/discovery/build-versions.d.ts +393 -0
- package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
- package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
- package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
- package/dist/types/vite/discovery/state.d.ts +14 -0
- package/dist/types/vite/encryption-key.d.ts +18 -0
- package/dist/types/vite/plugin-types.d.ts +27 -0
- package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
- package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
- package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
- package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
- package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
- package/dist/types/vite/plugins/virtual-entries.d.ts +18 -1
- package/dist/types/vite/router-discovery.d.ts +6 -0
- package/dist/types/vite/utils/client-chunks.d.ts +2 -0
- package/dist/types/vite/utils/compare-strings.d.ts +7 -0
- package/dist/types/vite/utils/package-resolution.d.ts +8 -0
- package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
- package/dist/vite/index.js +1537 -444
- package/package.json +1 -2
- package/skills/cache-guide/SKILL.md +84 -20
- package/skills/caching/SKILL.md +16 -5
- package/skills/client-urls/SKILL.md +16 -0
- package/skills/cloudflare/SKILL.md +2 -1
- package/skills/comparison/references/framework-comparison.md +16 -12
- package/skills/hooks/SKILL.md +3 -0
- package/skills/hooks/handle-and-actions.md +6 -0
- package/skills/hooks/navigation.md +28 -0
- package/skills/hooks/state.md +209 -8
- package/skills/hooks/urls.md +40 -1
- package/skills/host-router/SKILL.md +13 -0
- package/skills/intercept/SKILL.md +6 -0
- package/skills/loader/SKILL.md +19 -1
- package/skills/middleware/SKILL.md +2 -0
- package/skills/ppr/SKILL.md +119 -40
- package/skills/prerender/SKILL.md +7 -3
- package/skills/rango/SKILL.md +8 -7
- package/skills/router-setup/SKILL.md +15 -1
- package/skills/testing/SKILL.md +30 -26
- package/skills/testing/client-components.md +193 -8
- package/skills/testing/handles.md +2 -0
- package/skills/testing/loader.md +1 -1
- package/skills/testing/middleware.md +1 -1
- package/skills/testing/render-handler.md +18 -18
- package/skills/testing/serve-shell-request.md +63 -14
- package/skills/testing/server-actions.md +17 -17
- package/skills/testing/server-tree.md +1 -1
- package/skills/typesafety/generated-files-and-cli.md +1 -1
- package/skills/use-cache/SKILL.md +16 -5
- package/skills/vercel/SKILL.md +3 -3
- package/src/bin/rango.ts +1 -1
- package/src/browser/event-controller.ts +75 -2
- package/src/browser/history-state.ts +39 -17
- package/src/browser/navigation-bridge.ts +41 -18
- package/src/browser/navigation-transaction.ts +15 -10
- package/src/browser/partial-update.ts +10 -2
- package/src/browser/rango-state.ts +3 -1
- package/src/browser/react/Link.tsx +2 -3
- package/src/browser/react/NavigationProvider.tsx +25 -1
- package/src/browser/react/context.ts +13 -0
- package/src/browser/react/location-state-shared.ts +200 -34
- package/src/browser/react/location-state.ts +40 -75
- package/src/browser/react/subscribe-to-nothing.ts +7 -0
- package/src/browser/react/use-handle.ts +20 -1
- package/src/browser/rsc-router.tsx +33 -11
- package/src/browser/server-action-bridge.ts +17 -1
- package/src/cache/cache-scope.ts +18 -8
- package/src/cache/cf/cf-cache-store.ts +75 -55
- package/src/cache/cf/cf-cache-types.ts +12 -4
- package/src/cache/handle-snapshot.ts +69 -87
- package/src/cache/index.ts +4 -0
- package/src/cache/shell-snapshot.ts +35 -5
- package/src/cache/types.ts +21 -10
- package/src/cache/vercel/vercel-cache-store.ts +48 -20
- package/src/client-urls/client-root.tsx +1 -0
- package/src/client-urls/navigation.ts +10 -0
- package/src/client-urls/optimistic-location.ts +10 -4
- package/src/handles/deferred-resolution.ts +31 -0
- package/src/prerender/build-shell-capture.ts +11 -9
- package/src/router/manifest.ts +2 -0
- package/src/router/match-middleware/cache-lookup.ts +11 -18
- package/src/router/navigation-snapshot.ts +25 -6
- package/src/router/request-classification.ts +15 -12
- package/src/router/router-interfaces.ts +2 -1
- package/src/router/router-options.ts +17 -7
- package/src/router/segment-resolution/loader-cache.ts +95 -60
- package/src/router-versions.ts +60 -0
- package/src/rsc/full-payload.ts +6 -2
- package/src/rsc/handler-context.ts +5 -0
- package/src/rsc/handler.ts +14 -4
- package/src/rsc/rsc-rendering.ts +12 -1
- package/src/rsc/shell-build-manifest.ts +10 -7
- package/src/rsc/shell-capture.ts +36 -26
- package/src/rsc/types.ts +4 -6
- package/src/server/build-version-table.ts +115 -0
- package/src/server/handle-store.ts +344 -127
- package/src/server/request-context.ts +62 -11
- package/src/ssr/ssr-root.tsx +16 -4
- package/src/testing/build-versions.ts +64 -0
- package/src/testing/dispatch.ts +6 -0
- package/src/testing/dom.entry.ts +1 -0
- package/src/testing/flight.entry.ts +1 -0
- package/src/testing/index.ts +3 -0
- package/src/testing/internal/context.ts +2 -1
- package/src/testing/internal/seed-vars.ts +14 -2
- package/src/testing/location-state-key.ts +18 -8
- package/src/testing/render-handler.ts +2 -1
- package/src/testing/render-route.tsx +277 -43
- package/src/testing/serve-shell-request.ts +80 -11
- package/src/testing/vitest-stubs/version.ts +4 -1
- package/src/types/handler-context.ts +8 -5
- package/src/vite/discovery/build-versions.ts +1281 -0
- package/src/vite/discovery/installed-packages.ts +159 -0
- package/src/vite/discovery/prerender-collection.ts +38 -1
- package/src/vite/discovery/router-versions-phase.ts +336 -0
- package/src/vite/discovery/shell-prerender-phase.ts +28 -38
- package/src/vite/discovery/state.ts +19 -0
- package/src/vite/discovery/virtual-module-codegen.ts +7 -2
- package/src/vite/encryption-key.ts +101 -9
- package/src/vite/plugin-types.ts +28 -0
- package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
- package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
- package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
- package/src/vite/plugins/expose-internal-ids.ts +12 -3
- package/src/vite/plugins/stable-rsc-output.ts +147 -0
- package/src/vite/plugins/version-plugin.ts +19 -24
- package/src/vite/plugins/version.d.ts +17 -2
- package/src/vite/plugins/virtual-entries.ts +31 -5
- package/src/vite/rango.ts +28 -3
- package/src/vite/router-discovery.ts +82 -13
- package/src/vite/utils/client-chunks.ts +5 -1
- package/src/vite/utils/compare-strings.ts +9 -0
- package/src/vite/utils/package-resolution.ts +9 -0
- package/src/vite/utils/prerender-utils.ts +4 -1
- package/src/vite/utils/shared-utils.ts +1 -1
|
@@ -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
|
-
`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.
|
|
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. With `hydrate: true` it renders that same tree to HTML first and hydrates it ([Hydration](#hydration)).
|
|
6
6
|
|
|
7
7
|
## API
|
|
8
8
|
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
| `outletPending` | `boolean` | Seed `useOutlet().pending` through each synthetic segment's production `OutletProvider`. Defaults to `false`; this is context seeding, not a simulated navigation/Suspense/action lifecycle. |
|
|
16
16
|
| `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`. |
|
|
17
17
|
| `params` | `Record<string, string>` | Explicit params, merged over (and overriding) params extracted from the `request` URL. |
|
|
18
|
-
| `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; keys an unkeyed `def` (like `withLocationStateKey(def)`) and writes to `history.state`. See [Location state](#location-state).
|
|
18
|
+
| `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; keys an unkeyed `def` (like `withLocationStateKey(def)`) and writes `value` to `history.state` under `def.__rsc_ls_key`. See [Location state](#location-state). |
|
|
19
19
|
| `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Seed `useHandle(handle)` by REFERENCE: `[handle, pushedValues[]]`. Accumulated GLOBALLY (not segment-scoped). |
|
|
20
|
+
| `lateHandles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Handle values that arrive AFTER hydration, as a late loader push does: `[handle, valuesAfterTheUpdate[]]` replaces that handle's `handles` seed once the root has hydrated (or mounted). See [Hydration](#hydration). |
|
|
20
21
|
| `handle` | `HandleDataSeed` | Advanced: raw wire format `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles`. Merged with it. |
|
|
21
22
|
| `routeMap` | `Record<string, string>` | Name -> pattern map (informational; client `useReverse` takes its map as an argument, so this is not consumed). |
|
|
22
23
|
| `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. |
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
| `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. |
|
|
25
26
|
| `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
27
|
| `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. |
|
|
28
|
+
| `hydrate` | `boolean` | Render the same tree to HTML first (`renderToString`, no `window`/`document`), then hydrate that HTML instead of mounting fresh. The result gains `serverHtml` and `recoverableErrors`. See [Hydration](#hydration). |
|
|
27
29
|
|
|
28
30
|
`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
31
|
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
| `useSearchParams` | Search params from the `request` URL. |
|
|
42
44
|
| `useNonce` | SEEDED CSP nonce (`options.nonce`), else `undefined` (the browser default). |
|
|
43
45
|
| `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). Held-navigation `isLoading` is modeled (below). |
|
|
44
|
-
| `useLocationState` | SEEDED `history.state` value, or the `state` a
|
|
46
|
+
| `useLocationState` | SEEDED `history.state` value, or the `state` a navigation or a `popstate` brought (below). |
|
|
45
47
|
| `useHandle` | SEEDED handle output (globally accumulated). |
|
|
46
48
|
| `Outlet` | Renders the next segment in the chain (layout nesting). |
|
|
47
49
|
| `useOutlet` | Next-segment `content` plus SEEDED `options.outletPending`. |
|
|
@@ -55,12 +57,16 @@ Extends RTL's `RenderResult` (`getByTestId`, `getByText`, `getByRole`, `containe
|
|
|
55
57
|
type RenderRouteResult = RenderResult & {
|
|
56
58
|
router: {
|
|
57
59
|
// client-only nav, re-resolves the same routes; `loaders` seeds THIS nav.
|
|
58
|
-
// A spec's transition({ when }) decides it as kind "push"
|
|
59
|
-
// `transition: false` commits urgently and calls no predicate.
|
|
60
|
+
// A spec's transition({ when }) decides it as kind "push" ("replace" with
|
|
61
|
+
// `replace`); `transition: false` commits urgently and calls no predicate.
|
|
62
|
+
// `state` / `replace` write the history entry as router.push(url, { state })
|
|
63
|
+
// / router.replace do; without either, history.state is left alone.
|
|
60
64
|
navigate(
|
|
61
65
|
url: string,
|
|
62
66
|
options?: {
|
|
63
67
|
loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
|
|
68
|
+
state?: HistoryState;
|
|
69
|
+
replace?: boolean;
|
|
64
70
|
transition?: boolean;
|
|
65
71
|
},
|
|
66
72
|
): Promise<void>;
|
|
@@ -73,6 +79,12 @@ type RenderRouteResult = RenderResult & {
|
|
|
73
79
|
eventController: EventController; // advanced
|
|
74
80
|
};
|
|
75
81
|
};
|
|
82
|
+
|
|
83
|
+
// With `hydrate: true` (see Hydration):
|
|
84
|
+
type RenderRouteHydrateResult = RenderRouteResult & {
|
|
85
|
+
serverHtml: string; // what the server pass rendered, as placed in the container
|
|
86
|
+
recoverableErrors: string[]; // messages React passed to onRecoverableError (live)
|
|
87
|
+
};
|
|
76
88
|
```
|
|
77
89
|
|
|
78
90
|
## Recipe
|
|
@@ -128,7 +140,11 @@ A `createLocationState()` definition gets its key from the rango Vite plugin, wh
|
|
|
128
140
|
- `withLocationStateKey(GridState)` keeps a key that is already set, else assigns a synthetic `__rsc_ls_test_<n>` that stays the same for that definition.
|
|
129
141
|
- The `locationState` seed option keys an unkeyed definition the same way, so a seeded definition needs no call. Key every definition a component reads WITHOUT a seed.
|
|
130
142
|
|
|
131
|
-
|
|
143
|
+
`useLocationState` reads the entry the router's provider has on screen, so a component that calls it is tested through `renderRoute`; rendered bare (RTL's `render`) it has no entry and reads `undefined`.
|
|
144
|
+
|
|
145
|
+
A `useRouter().push/replace(url, { state })` or `<Link state>` navigation writes its history entry through production's path (`resolveNavigationState` -> `buildHistoryState` -> `pushHistoryWithIdx`, then the entry's state committed on the event controller), and the provider hands it to readers with the navigation's tree, so `useLocationState(Def)` shows the new value in the commit that shows the destination. The dev check for a bare entry or an uncalled definition runs too, and, as in production, a push or `<Link>` without `state` starts an entry with no location state. The URL in `window.location` does not change (renderRoute tracks location on its event controller).
|
|
146
|
+
|
|
147
|
+
`router.navigate()` from the test writes a history entry only when asked: `state` pushes an entry carrying it, `replace: true` replaces the current entry (with `state`, or with none, which drops the entry's location state as a production replace does). With neither, history is left alone and readers keep what they show, so `navigate(url)` and `navigate(url, { replace: false })` are the same call. The click starts an async navigation, so wait for the result with RTL's `waitFor`:
|
|
132
148
|
|
|
133
149
|
```tsx
|
|
134
150
|
// @vitest-environment happy-dom
|
|
@@ -155,6 +171,175 @@ it("shows the count it pushed", async () => {
|
|
|
155
171
|
});
|
|
156
172
|
```
|
|
157
173
|
|
|
174
|
+
### State while a navigation is pending
|
|
175
|
+
|
|
176
|
+
A reader gets an entry's state in the commit that shows that entry (see `/hooks`, state.md, "When a reader sees an entry's state"). To test a component across a pending navigation, hold the navigation's loader: put `transition` on the spec (see [Held navigation](#held-navigation)) and pass `state` together with a PENDING loader seed to `router.navigate()`:
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
it("lists no product twice while the next page loads", async () => {
|
|
180
|
+
const { getAllByRole, router } = await renderRoute(
|
|
181
|
+
[{ path: "/products", Component: ProductList, transition: {} }],
|
|
182
|
+
{
|
|
183
|
+
request: "/products?page=1",
|
|
184
|
+
loaders: [[ProductsLoader, { page: 1, products: [wine] }]],
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
let land!: (data: { page: number; products: Product[] }) => void;
|
|
189
|
+
const next = new Promise<{ page: number; products: Product[] }>(
|
|
190
|
+
(resolve) => (land = resolve),
|
|
191
|
+
);
|
|
192
|
+
await router.navigate("/products?page=2", {
|
|
193
|
+
state: [CarriedProducts([wine])], // what "Load more" carries
|
|
194
|
+
loaders: [[ProductsLoader, next]],
|
|
195
|
+
});
|
|
196
|
+
// History is on page 2's entry; the list is still page 1's.
|
|
197
|
+
expect(CarriedProducts.read()).toEqual([wine]);
|
|
198
|
+
expect(getAllByRole("listitem")).toHaveLength(1);
|
|
199
|
+
|
|
200
|
+
await act(async () => land({ page: 2, products: [beer] }));
|
|
201
|
+
expect(getAllByRole("listitem")).toHaveLength(2);
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
| To bring state to a reader | Do |
|
|
206
|
+
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
207
|
+
| a push / replace, the component's own | click its `<Link state>` or the control that calls `router.push(url, { state })`, then `waitFor` |
|
|
208
|
+
| a push / replace whose loader the test holds | `router.navigate(url, { state, replace?, loaders: [[Loader, pendingPromise]] })` on a `transition` spec |
|
|
209
|
+
| back/forward onto an entry | `history.replaceState(entry, "")`, then `window.dispatchEvent(new Event("popstate"))` inside `act`. The tree has one location, so no page is restored: readers take the entry as history holds it (also how a `Def.write()` becomes visible) |
|
|
210
|
+
| a reader that mounts while a navigation is pending | hold the navigation as in the row above, then mount the reader inside `await act(async () => { ... })`: it reads the entry on screen |
|
|
211
|
+
| a server action's state, a redirect's, an intercept | e2e |
|
|
212
|
+
|
|
213
|
+
### Definitions with `clearOnReload`
|
|
214
|
+
|
|
215
|
+
The option never changes the stored value. It changes the key: it appends `~r` to the name you key the definition with (`__rsc_ls_CarriedProducts~r`, see `/hooks`, state.md). So a seed is the plain value for every definition, and a stored-state assertion uses `[CarriedProducts.__rsc_ls_key]` instead of spelling the key.
|
|
216
|
+
|
|
217
|
+
| To test | Do |
|
|
218
|
+
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
219
|
+
| `clearOnReload` state after a client navigation | plain `renderRoute(routes, { locationState })`: the tree mounts as during a navigation, the seed is applied |
|
|
220
|
+
| `clearOnReload` state after a refresh | `renderRoute(routes, { hydrate: true, locationState })`: a document load of the seeded entry. The slot is removed before hydration, by the same function production start-up calls |
|
|
221
|
+
|
|
222
|
+
In the browser the router also drops location state another app version wrote (see `/hooks`, state.md). A test has no app version, so there is nothing to set up: seeds read back on a mount and on a `hydrate: true` load, and nothing in `window.history.state` or in a server primitive's `locationState` names a version.
|
|
223
|
+
|
|
224
|
+
A name passed to `withLocationStateKey` may not contain `~`; the definition appends its own suffix.
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
// @vitest-environment happy-dom
|
|
228
|
+
import { afterEach, expect, it } from "vitest";
|
|
229
|
+
import { cleanup, fireEvent, waitFor } from "@testing-library/react";
|
|
230
|
+
import { renderRoute } from "@rangojs/router/testing/dom";
|
|
231
|
+
import { ProductList } from "../src/components/ProductList"; // the "load more" list of /hooks state.md: useLoader(ProductsLoader) + useLocationState(CarriedProducts)
|
|
232
|
+
import { ProductsLoader } from "../src/loaders";
|
|
233
|
+
import { CarriedProducts } from "../src/location-states"; // createLocationState<Product[]>({ clearOnReload: true })
|
|
234
|
+
|
|
235
|
+
afterEach(cleanup);
|
|
236
|
+
const routes = [{ path: "/products", Component: ProductList }];
|
|
237
|
+
const carried = [{ id: "p1", name: "Wine" }];
|
|
238
|
+
const loaders = [[ProductsLoader, { page: 6, products: [] }]] as const; // this page's own products: none, to count only carried ones
|
|
239
|
+
|
|
240
|
+
it("shows the carried products after a client navigation", async () => {
|
|
241
|
+
const { getAllByRole } = await renderRoute(routes, {
|
|
242
|
+
loaders,
|
|
243
|
+
locationState: [[CarriedProducts, carried]],
|
|
244
|
+
});
|
|
245
|
+
expect(getAllByRole("listitem")).toHaveLength(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("drops them on a refresh and carries again on the next navigation", async () => {
|
|
249
|
+
const { recoverableErrors, queryAllByRole, getByText } = await renderRoute(
|
|
250
|
+
routes,
|
|
251
|
+
{ loaders, hydrate: true, locationState: [[CarriedProducts, carried]] },
|
|
252
|
+
);
|
|
253
|
+
expect(recoverableErrors).toEqual([]);
|
|
254
|
+
expect(queryAllByRole("listitem")).toHaveLength(0);
|
|
255
|
+
expect(CarriedProducts.read()).toBeUndefined(); // removed from history.state
|
|
256
|
+
|
|
257
|
+
fireEvent.click(getByText("Load more"));
|
|
258
|
+
await waitFor(() => expect(CarriedProducts.read()).toBeDefined());
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Hydration
|
|
263
|
+
|
|
264
|
+
A plain `renderRoute` mounts with `createRoot`, so a component never runs its hydration render: `useSyncExternalStore` skips `getServerSnapshot`, and nothing compares the first client render with server HTML. `hydrate: true` runs the document-load sequence instead. renderRoute renders the same element (same providers, seeds, and RTL `reactStrictMode`) to HTML with `react-dom/server`'s `renderToString`, puts that HTML in the container, and hydrates it through RTL (`render(ui, { hydrate: true, onRecoverableError })`), so `cleanup()` unmounts it like any other render. `serverHtml` is what the server pass produced; `recoverableErrors` holds the message of every error React recovered from, which is where a hydration mismatch arrives.
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
// @vitest-environment happy-dom
|
|
268
|
+
import { Suspense } from "react";
|
|
269
|
+
import { afterEach, expect, it } from "vitest";
|
|
270
|
+
import { cleanup } from "@testing-library/react";
|
|
271
|
+
import { renderRoute } from "@rangojs/router/testing/dom";
|
|
272
|
+
import { Grid } from "../src/components/Grid"; // <p data-testid="count">{useLocationState(GridState)?.count ?? 0}</p>
|
|
273
|
+
import { GridState } from "../src/location-states";
|
|
274
|
+
|
|
275
|
+
afterEach(cleanup);
|
|
276
|
+
|
|
277
|
+
// A <Suspense> boundary hydrates after the root has marked the page hydrated,
|
|
278
|
+
// as a streamed boundary does in production.
|
|
279
|
+
function GridPage() {
|
|
280
|
+
return (
|
|
281
|
+
<Suspense fallback={null}>
|
|
282
|
+
<Grid />
|
|
283
|
+
</Suspense>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
it("hydrates as the server rendered it, then shows the stored count", async () => {
|
|
288
|
+
const { serverHtml, recoverableErrors, getByTestId } = await renderRoute(
|
|
289
|
+
[{ path: "/grid", Component: GridPage }],
|
|
290
|
+
{ hydrate: true, locationState: [[GridState, { count: 3 }]] },
|
|
291
|
+
);
|
|
292
|
+
expect(serverHtml).toContain('<p data-testid="count">0</p>'); // no history.state on the server
|
|
293
|
+
expect(recoverableErrors).toEqual([]); // the first client render matched it
|
|
294
|
+
expect(getByTestId("count").textContent).toBe("3"); // then the stored value
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
| In your component | Under `hydrate: true` |
|
|
299
|
+
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
300
|
+
| `typeof window` / `typeof document` in render | `"undefined"` in the server pass: both globals are removed for it and restored before hydration. A branch renders its server side; if the client side differs, React reports the mismatch. |
|
|
301
|
+
| An unguarded `window.x` / `document.x` in render | Throws `ReferenceError` in the server pass, as in SSR. Inside a `<Suspense>` the boundary is client-rendered and reported; outside one `renderRoute` rejects. |
|
|
302
|
+
| `useSyncExternalStore` | The server pass and the hydration render read `getServerSnapshot`; the render after it reads `getSnapshot`. |
|
|
303
|
+
| A text or element mismatch | React re-renders that tree on the client and reports it: `recoverableErrors` gets `"Hydration failed because ..."` with the diff. |
|
|
304
|
+
| An attribute-only mismatch (`className`, `href`) | NOT in `recoverableErrors`. React keeps the server attribute and logs `console.error` in development; assert it with `vi.spyOn(console, "error")`. |
|
|
305
|
+
| `<Suspense>` whose content renders synchronously | In `serverHtml`. It hydrates in a later pass than the tree above it, after that tree's effects, before `renderRoute` resolves. |
|
|
306
|
+
| Content that suspends in the server pass (`lazy`, a pending or plain `use()` promise) | NOT hydrated: `serverHtml` has the fallback, the client renders the content, and `recoverableErrors` reports the boundary. With no `<Suspense>` above it `renderRoute` rejects. A settled promise (Caveats, below) stays in the server HTML. |
|
|
307
|
+
| Render counts and module state | Every component renders once in the server pass before its hydration render. Both passes share one module realm, so module state written while rendering is still there at hydration. |
|
|
308
|
+
| `history`, `localStorage`, `navigator` read outside a server snapshot | FIDELITY LIMIT: still defined in the server pass (only `window` and `document` are removed), so the read renders the same in both passes here and can still mismatch in real SSR. |
|
|
309
|
+
| `data-hydrated` on `<html>` | Set after hydration, as the production root sets it from its effect, and removed on unmount. A `<Suspense>` boundary therefore hydrates with it already set. |
|
|
310
|
+
| `useHandle` with `lateHandles` | The `handles` values in the server pass and in every hydration render, a `<Suspense>` boundary's included; the `lateHandles` values on the render after. |
|
|
311
|
+
| `useLocationState` of a seeded definition | `undefined` in the server pass and the hydration render, the seeded value on the render after. A `clearOnReload` definition stays `undefined`: its seeded slot is removed from `history.state` before hydration, as on a real document load. |
|
|
312
|
+
|
|
313
|
+
A handle value can reach the page after hydration: a loader push made after an `await`, or any loader push on a PPR shell HIT (`/ppr`, "Handles on a shell HIT"). `lateHandles` models it. The server HTML is rendered from `handles`; the update is applied once the root has hydrated, so a reader inside a `<Suspense>` hydrates after it arrived, as a streamed boundary does in production:
|
|
314
|
+
|
|
315
|
+
```tsx
|
|
316
|
+
function NotesPage() {
|
|
317
|
+
return (
|
|
318
|
+
<Suspense fallback={null}>
|
|
319
|
+
<NoteList /> {/* <li> per useHandle(Notes) value */}
|
|
320
|
+
</Suspense>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
it("hydrates with the document's notes, then shows the late one", async () => {
|
|
325
|
+
const { serverHtml, recoverableErrors, getAllByRole } = await renderRoute(
|
|
326
|
+
[{ path: "/notes", Component: NotesPage }],
|
|
327
|
+
{
|
|
328
|
+
hydrate: true,
|
|
329
|
+
handles: [[Notes, ["from-handler"]]],
|
|
330
|
+
lateHandles: [[Notes, ["from-handler", "from-loader"]]],
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
expect(serverHtml).not.toContain("from-loader");
|
|
334
|
+
expect(recoverableErrors).toEqual([]); // useHandle hydrated with the document's data
|
|
335
|
+
expect(getAllByRole("listitem")).toHaveLength(2);
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
- Needs `@testing-library/react` 16.2.0 or newer. 16.0 and 16.1 never pass `onRecoverableError` to `hydrateRoot`, so a mismatch would be invisible; `renderRoute` throws there instead.
|
|
340
|
+
- `recoverableErrors` is live: React appends to it for as long as the root is mounted.
|
|
341
|
+
- The server pass is the HTML render of the CLIENT tree. It runs no handler, loader, middleware, or Flight, and it is not the streamed document: streaming order, client-reference identity, and the browser's HTML parser stay at e2e.
|
|
342
|
+
|
|
158
343
|
## Held navigation
|
|
159
344
|
|
|
160
345
|
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.
|
|
@@ -205,7 +390,7 @@ it("dims the held price while the next product streams", async () => {
|
|
|
205
390
|
|
|
206
391
|
## Caveats
|
|
207
392
|
|
|
208
|
-
- 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.
|
|
393
|
+
- 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. `hydrate: true` adds an HTML pass of this same client tree ([Hydration](#hydration)), not a render of your routes on a server.
|
|
209
394
|
- `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)).
|
|
210
395
|
- `outletPending` seeds only the production-shaped outlet context. It is useful
|
|
211
396
|
for the two settled render states of a layout that reads `useOutlet()`, but it
|
|
@@ -215,7 +400,7 @@ it("dims the held price while the next product streams", async () => {
|
|
|
215
400
|
- 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")`.
|
|
216
401
|
- `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`).
|
|
217
402
|
- 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.
|
|
218
|
-
- Needs a DOM env (`// @vitest-environment happy-dom`, or jsdom) and `@testing-library/react` (optional peers).
|
|
403
|
+
- Needs a DOM env (`// @vitest-environment happy-dom`, or jsdom) and `@testing-library/react` (optional peers; `hydrate` needs 16.2.0 or newer).
|
|
219
404
|
- 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.
|
|
220
405
|
- MULTI-APP `href` typing. When a `renderRoute` suite imports client components across apps, the global `Rango.GeneratedRouteMap` augmentations collide and `href()` stops typechecking (app A's route union rejects app B's name). Runtime is unaffected — it is purely the global `href` typing. Keep the suite single-app, or split tsconfig programs per app (see [`./reverse-and-types.md`](./reverse-and-types.md) and `/typesafety`).
|
|
221
406
|
|
|
@@ -28,6 +28,8 @@ A handle's `collect`/accumulator (the `createHandle(collect)` argument that maps
|
|
|
28
28
|
| --------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
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
30
|
|
|
31
|
+
`lateHandles` takes the same pairs for values that arrive after hydration (a late loader push): each replaces that handle's `handles` seed once the root has hydrated or mounted. With `hydrate: true` it pins that a reader hydrates with the document's values; see [`./client-components.md`](./client-components.md), "Hydration".
|
|
32
|
+
|
|
31
33
|
**Shape contrast:** `renderRoute` feeds the barrier INPUT (the pushes, `unknown[]`); `runLoader` feeds its OUTPUT (the single accumulated value, `unknown`).
|
|
32
34
|
|
|
33
35
|
**Across navigation:** seeded `handles` are applied once at the initial render and PERSIST across `router.navigate()` within the same test (like `loaderData`) — unlike a real navigation, which re-runs handlers. A layout/page reading `useHandle` still resolves the seeded values after `navigate()`.
|
package/skills/testing/loader.md
CHANGED
|
@@ -121,7 +121,7 @@ it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
|
|
|
121
121
|
- 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.
|
|
122
122
|
- 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.
|
|
123
123
|
- 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).
|
|
124
|
-
- Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run. In the node project (`NODE_ENV` is not `production`) an unkeyed `Flash(value)` throws, so a loader's `throw redirect(url, { state: [Flash(value)] })` surfaces on `runLoaderResult`'s `thrown` as that missing-key error, not the redirect; in the react-server project (`NODE_ENV=production`) the value lands on `locationState["undefined"]`. Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `locationState` as `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix).
|
|
124
|
+
- Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run. In the node project (`NODE_ENV` is not `production`) an unkeyed `Flash(value)` throws, so a loader's `throw redirect(url, { state: [Flash(value)] })` surfaces on `runLoaderResult`'s `thrown` as that missing-key error, not the redirect; in the react-server project (`NODE_ENV=production`) the value lands on `locationState["undefined"]`. Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `locationState` as `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix). A definition with `clearOnReload` appends a suffix to its key (`__rsc_ls_Flash~r`), so `{ [Flash.__rsc_ls_key]: value }` is the form that holds for any definition.
|
|
125
125
|
- 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.)
|
|
126
126
|
- `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.)
|
|
127
127
|
- `ctx.theme`/`ctx.setTheme` are NOT on the loader context — they are handler/middleware-only. The `theme` option seeds the request context's theme config, so a loader body reads the visitor's theme with `getRequestContext().theme` (seed the cookie through `request`). That read is guarded like `cookies()` (#971): allowed in a loader body, but it throws inside a `"use cache"` function. `ctx.request.headers` is guarded the same way. `redirect()` does no basename prefixing unless you seed `basename`.
|
|
@@ -95,7 +95,7 @@ Pass an array to run several in order. Cookies set inside middleware via the sta
|
|
|
95
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.
|
|
96
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).
|
|
97
97
|
- `ctx.theme` is `undefined` unless `theme` is passed; `redirect()` does no basename prefixing unless `basename` is seeded.
|
|
98
|
-
- Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run, and outside production an unkeyed `Flash(value)` throws inside the middleware (before `redirect()` or `ctx.setLocationState()` runs). Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `locationState` as `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix).
|
|
98
|
+
- Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run, and outside production an unkeyed `Flash(value)` throws inside the middleware (before `redirect()` or `ctx.setLocationState()` runs). Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `locationState` as `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix). A definition with `clearOnReload` appends a suffix to its key (`__rsc_ls_Flash~r`), so `{ [Flash.__rsc_ls_key]: value }` is the form that holds for any definition.
|
|
99
99
|
- Platform bindings are yours to double via `env` (see `./bindings.md`).
|
|
100
100
|
|
|
101
101
|
## See also
|
|
@@ -8,23 +8,23 @@ A Rango route handler is a pure function `(ctx) => rsc` — the function you pas
|
|
|
8
8
|
|
|
9
9
|
### Options — `RenderHandlerOptions`
|
|
10
10
|
|
|
11
|
-
| Field | Type | Meaning
|
|
12
|
-
| ---------------------- | ------------------------------------------------------ |
|
|
13
|
-
| `params` | `Record<string, string>` | Route params surfaced as `ctx.params`.
|
|
14
|
-
| `env` | `TEnv` | Environment bindings surfaced as `ctx.env`.
|
|
15
|
-
| `request` | `Request \| string` | Backing Request (string or `Request`); defaults to a localhost GET.
|
|
16
|
-
| `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) the handler reads via `cookies()`. Used only when `request` is a string or omitted; a passed `Request` keeps its own headers.
|
|
17
|
-
| `build` | `boolean` | Seed `ctx.build` (default `false`) to test a handler that branches on the build-time pass, including calling `ctx.dynamic()` on a miss. Assert the opt-out via `result.dynamic`.
|
|
18
|
-
| `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape). Without it `ctx.theme`/`ctx.setTheme` are inert; pass one to test a handler that reads the theme or writes the theme cookie.
|
|
19
|
-
| `vars` | `VarsInit` (object or `[[Var, value]]` tuples) | Variables a prior middleware set, read via `ctx.get(...)`.
|
|
20
|
-
| `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse).
|
|
21
|
-
| `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`.
|
|
22
|
-
| `loaders` | `ReadonlyArray<readonly [LoaderDefinition, unknown]>` | Seed the data `ctx.use(SomeLoader)` returns. Matched by loader reference; NO real loader runs.
|
|
23
|
-
| `clientComponents` | `Record<string, unknown>` | `"use client"` components in the handler's RSC, so they serialize as real boundaries when `rangoUseClientTransform()` is not wired. Keyed by name.
|
|
24
|
-
| `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie a handler calling `invalidateClientCache()` rotates. The name is ALWAYS seeded (default `rango-state_router_0`) so the rotation `Set-Cookie` fires like production rather than no-opping; override `prefix`/`routerId` to match your `createRouter({ stateCookiePrefix, id })`, or `version` (the value is `{version}:{timestamp}`, default `"0"`). |
|
|
25
|
-
| `cacheStore` | `SegmentCacheStore` | Segment cache store backing a `"use cache"` function the handler invokes (e.g. `new MemorySegmentCacheStore()`). WITHOUT it, `registerCachedFunction` takes the uncached bypass and the cached path is NOT exercised (the runtime emits a one-time warning under the test runner). Pair with `cacheProfiles`.
|
|
26
|
-
| `cacheProfiles` | `Record<string, CacheProfile>` | Cache profiles in the `createRouter({ cacheProfiles })` shape, required for `"use cache: profileName"` resolution once a `cacheStore` is wired.
|
|
27
|
-
| `inActionRevalidation` | `boolean` | Render as if inside a server action's revalidation render (production sets this in `revalidateAfterAction`). A stale `"use cache"` entry whose profile sets `foregroundOnAction: true` then re-executes in the FOREGROUND (fresh result in this render) instead of SWR. Pair with `cacheStore` + `cacheProfiles` to exercise the opt-in.
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| ---------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `params` | `Record<string, string>` | Route params surfaced as `ctx.params`. |
|
|
14
|
+
| `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. |
|
|
15
|
+
| `request` | `Request \| string` | Backing Request (string or `Request`); defaults to a localhost GET. |
|
|
16
|
+
| `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) the handler reads via `cookies()`. Used only when `request` is a string or omitted; a passed `Request` keeps its own headers. |
|
|
17
|
+
| `build` | `boolean` | Seed `ctx.build` (default `false`) to test a handler that branches on the build-time pass, including calling `ctx.dynamic()` on a miss. Assert the opt-out via `result.dynamic`. |
|
|
18
|
+
| `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape). Without it `ctx.theme`/`ctx.setTheme` are inert; pass one to test a handler that reads the theme or writes the theme cookie. |
|
|
19
|
+
| `vars` | `VarsInit` (object or `[[Var, value]]` tuples) | Variables a prior middleware set, read via `ctx.get(...)`. |
|
|
20
|
+
| `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
|
|
21
|
+
| `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`. |
|
|
22
|
+
| `loaders` | `ReadonlyArray<readonly [LoaderDefinition, unknown]>` | Seed the data `ctx.use(SomeLoader)` returns. Matched by loader reference; NO real loader runs. |
|
|
23
|
+
| `clientComponents` | `Record<string, unknown>` | `"use client"` components in the handler's RSC, so they serialize as real boundaries when `rangoUseClientTransform()` is not wired. Keyed by name. |
|
|
24
|
+
| `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie a handler calling `invalidateClientCache()` rotates. The name is ALWAYS seeded (default `rango-state_router_0`) so the rotation `Set-Cookie` fires like production rather than no-opping; override `prefix`/`routerId` to match your `createRouter({ stateCookiePrefix, id })`, or `version` (the app's version, as `createRouter({ version })` would set it; the value is `{version}:{timestamp}`, default `"0"`). |
|
|
25
|
+
| `cacheStore` | `SegmentCacheStore` | Segment cache store backing a `"use cache"` function the handler invokes (e.g. `new MemorySegmentCacheStore()`). WITHOUT it, `registerCachedFunction` takes the uncached bypass and the cached path is NOT exercised (the runtime emits a one-time warning under the test runner). Pair with `cacheProfiles`. |
|
|
26
|
+
| `cacheProfiles` | `Record<string, CacheProfile>` | Cache profiles in the `createRouter({ cacheProfiles })` shape, required for `"use cache: profileName"` resolution once a `cacheStore` is wired. |
|
|
27
|
+
| `inActionRevalidation` | `boolean` | Render as if inside a server action's revalidation render (production sets this in `revalidateAfterAction`). A stale `"use cache"` entry whose profile sets `foregroundOnAction: true` then re-executes in the FOREGROUND (fresh result in this render) instead of SWR. Pair with `cacheStore` + `cacheProfiles` to exercise the opt-in. |
|
|
28
28
|
|
|
29
29
|
### Context — `HandlerContext` (what your handler receives)
|
|
30
30
|
|
|
@@ -159,7 +159,7 @@ Without the plugin, wrap a function yourself with the call the transform emits,
|
|
|
159
159
|
- An unseeded `ctx.use(loader)` REJECTS with a setup error — seed every dependency via `{ loaders: [[OtherLoader, data]] }`, matched by reference. Loaders are SEEDED, not executed (same as `runLoader`).
|
|
160
160
|
- Same alias requirement as flight tests: without the `@rangojs/router -> index.rsc.ts` alias (see [`./setup.md`](./setup.md)), a handler reading `getRequestContext()`/`cookies()` hits the throwing out-of-react-server stub. `renderHandler` detects this and REJECTS with a setup error naming `rangoTestAliases` (it is not captured on `thrown`).
|
|
161
161
|
- A `throw redirect()` is captured on `thrown` (with `tree` undefined, since it produced a `Response`) — assert on `thrown`/`response`, no try/catch needed.
|
|
162
|
-
- Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run. This react-server project runs with `NODE_ENV=production`, so an unkeyed `ctx.setLocationState(Flash(value))` does not throw; the value lands on `locationState["undefined"]`. Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix; `{ [Flash.__rsc_ls_key]: value }` works for any key).
|
|
162
|
+
- Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run. This react-server project runs with `NODE_ENV=production`, so an unkeyed `ctx.setLocationState(Flash(value))` does not throw; the value lands on `locationState["undefined"]`. Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix; `{ [Flash.__rsc_ls_key]: value }` works for any key, including one that carries the `clearOnReload` suffix, `__rsc_ls_Flash~r`).
|
|
163
163
|
- No hydration and no interaction — for clicks, forms, and navigation use e2e.
|
|
164
164
|
- `renderHandler` runs a handler FUNCTION `(ctx) => rsc`; for a plain ELEMENT `<Page/>` use `renderServerTree` (see [`./server-tree.md`](./server-tree.md)).
|
|
165
165
|
- A handler that calls a `"use cache"` function runs UNCACHED unless you seed `cacheStore` (and `cacheProfiles` for a named profile). With nothing seeded the runtime bypasses to the live body and warns once under the test runner. With a store seeded, the body runs once across renders and a render after the background write reads the store — see [A `"use cache"` hit](#a-use-cache-hit), including `rangoUseCacheTransform()`, without which Vitest leaves the directive unwrapped.
|
|
@@ -27,16 +27,17 @@ So `prelude` is the shell as captured (Flight text, not HTML): a value the shell
|
|
|
27
27
|
|
|
28
28
|
### Returns — `ServeShellRequestResult`
|
|
29
29
|
|
|
30
|
-
| Field | Type
|
|
31
|
-
| -------------- |
|
|
32
|
-
| `shellStatus` | `"HIT" \| "MISS" \| null`
|
|
33
|
-
| `replayStatus` | `PprReplayStatus \| null`
|
|
34
|
-
| `prelude` | `string \| undefined`
|
|
35
|
-
| `flight` | `string \| undefined`
|
|
36
|
-
| `key` | `string`
|
|
37
|
-
| `readEntry` | `() => Promise<ShellCacheEntry \| null>`
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
30
|
+
| Field | Type | Meaning |
|
|
31
|
+
| -------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| `shellStatus` | `"HIT" \| "MISS" \| null` | `x-rango-shell`; null when the serve path did not consider the request (not a `ppr` route, a nonce'd request, a partial request). |
|
|
33
|
+
| `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
|
|
34
|
+
| `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
|
|
35
|
+
| `flight` | `string \| undefined` | The Flight payload this request rendered: a HIT's tail, a document render, or a partial response. `undefined` when no Flight rendered (a redirect, a middleware response). |
|
|
36
|
+
| `key` | `string` | The shell key the serve path resolved for a document it read (MISS or HIT), request partition included. Otherwise (no `ppr`, a partial request) the URL's key without a partition. |
|
|
37
|
+
| `readEntry` | `() => Promise<ShellCacheEntry \| null>` | Reads the document entry under `key` from the request's store (a passive `getShell`). A read: on a store with a shell memo it warms the memo, so call it after the reads you count. |
|
|
38
|
+
| `readHandles` | `() => Promise<ShellRequestHandles \| undefined>` | Decodes the response's handle data as the browser reads it: `{ hydration, late }` (see "Handles and loader data"). `undefined` when no Flight rendered. |
|
|
39
|
+
| `response` | `Response` | Status and headers. Its body is already read. |
|
|
40
|
+
| `body` | `string` | The body text. |
|
|
40
41
|
|
|
41
42
|
### `resetShellTestState(): Promise<void>`
|
|
42
43
|
|
|
@@ -99,6 +100,35 @@ it("replays the captured segments on a client navigation", async () => {
|
|
|
99
100
|
|
|
100
101
|
A store built per request from a function `cache` config gets the request's execution context (`ctx`), so its own `waitUntil` writes (`CFCacheStore({ ctx })`) settle with the call. A store built outside the request with its own `waitUntil` (`VercelCacheStore`'s option) is not settled by the call. For `CFCacheStore`, stub the `caches` global and pass a KV double in `env`; for `VercelCacheStore`, pass a `cache` double. A store's shell memo then behaves as in production (count your double's reads across two HITs).
|
|
101
102
|
|
|
103
|
+
## Simulating a deploy: setBuildVersions
|
|
104
|
+
|
|
105
|
+
A stored shell is stamped with the router's document version and a HIT requires the stamp to match. In a build, `vite build` computes that version per `createRouter()`; under test there is no build, so the primitives run unversioned. `setBuildVersions` (from `@rangojs/router/testing`) installs the versions a build would ship, and calling it again with a different `document` is a deploy that changed the router:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { setBuildVersions } from "@rangojs/router/testing";
|
|
109
|
+
|
|
110
|
+
afterEach(() => setBuildVersions()); // remove the versions: unversioned again
|
|
111
|
+
|
|
112
|
+
it("a deploy that changes the document version retires the shell", async () => {
|
|
113
|
+
const cacheStore = new MemorySegmentCacheStore();
|
|
114
|
+
setBuildVersions({ data: "d1", document: "h1" });
|
|
115
|
+
|
|
116
|
+
expect(
|
|
117
|
+
(await serveShellRequest(router, "/product/4", { cacheStore })).shellStatus,
|
|
118
|
+
).toBe("MISS");
|
|
119
|
+
expect(
|
|
120
|
+
(await serveShellRequest(router, "/product/4", { cacheStore })).shellStatus,
|
|
121
|
+
).toBe("HIT");
|
|
122
|
+
|
|
123
|
+
setBuildVersions({ data: "d1", document: "h2" }); // new SSR output or client assets
|
|
124
|
+
expect(
|
|
125
|
+
(await serveShellRequest(router, "/product/4", { cacheStore })).shellStatus,
|
|
126
|
+
).toBe("MISS");
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`setBuildVersions({ data, document, routers?: { [routerId]: { data, document } } })` takes the whole-build pair and, optionally, a pair per router id (a router with no entry gets the whole-build pair). `setBuildVersions()` with no argument removes them. The types `BuildVersions` and `RouterVersions` are exported too. `dispatch` sees the same versions.
|
|
131
|
+
|
|
102
132
|
## A HIT runs no handler
|
|
103
133
|
|
|
104
134
|
A HIT replays what the capture's handlers produced — their elements, the promises they passed or pushed, their handle values — and runs only middleware and loaders. Count runs to pin it:
|
|
@@ -169,12 +199,31 @@ A partial request has no HTML step, so its `key` is the URL's key without a part
|
|
|
169
199
|
|
|
170
200
|
## Handles and loader data
|
|
171
201
|
|
|
172
|
-
|
|
202
|
+
`readHandles()` decodes the handle data the response carries, the way the browser reads it, with deferred values resolved. Each field is the raw handle data (`{ [handleId]: { [segmentId]: values[] } }`):
|
|
203
|
+
|
|
204
|
+
| Field | What it is |
|
|
205
|
+
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
206
|
+
| `hydration` | What the document hydrates with (`metadata.handles`, read to its end before hydration starts). For a `partial` request, the last state it streamed. |
|
|
207
|
+
| `late` | The states that arrived on the late channel (`metadata.handlesLate`), in order. Each replaces the client's handle data after hydration. Often `[]`. |
|
|
208
|
+
|
|
209
|
+
On a HIT the client hydrates with exactly what the shell was rendered from, so `hydration` is also what the shell's HTML holds, and whatever this request's loaders push, replace or drop is in `late` (see `/ppr`, "Handles on a shell HIT"):
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// The handler pushed ctx.use(Notes)("from-handler"); a live loader pushes
|
|
213
|
+
// ctx.use(Notes)("in stock").
|
|
214
|
+
const values = (data) =>
|
|
215
|
+
Object.values(data ?? {}).flatMap((s) => Object.values(s).flat());
|
|
216
|
+
|
|
217
|
+
const hit = await serveShellRequest(router, "/product/1", { cacheStore });
|
|
218
|
+
const handles = await hit.readHandles();
|
|
219
|
+
expect(values(handles.hydration)).toEqual(["from-handler"]); // the shell's HTML, hydrates clean
|
|
220
|
+
expect(values(handles.late.at(-1))).toEqual(["from-handler", "in stock"]); // after hydration
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Loader data has no decoder: it rides the payload, so assert it as text in `prelude` and `flight`. `normalizeFlight` and the `flightMatchers` (`toMatchFlight`) work on the text too. A live loader's data is only in the tail.
|
|
173
224
|
|
|
174
225
|
```ts
|
|
175
|
-
|
|
176
|
-
expect(hit.prelude).toContain('"title":"Widget - Shop"');
|
|
177
|
-
expect(hit.flight).toContain('"title":"Widget - Shop"');
|
|
226
|
+
expect(hit.flight).toContain('"stock":"in stock"');
|
|
178
227
|
```
|
|
179
228
|
|
|
180
229
|
## Caveats
|
|
@@ -8,22 +8,22 @@
|
|
|
8
8
|
|
|
9
9
|
### Options — `CreateTestContextOptions<TEnv>`
|
|
10
10
|
|
|
11
|
-
| Field | Type | Meaning
|
|
12
|
-
| --------------- | ------------------------------------------------------ |
|
|
13
|
-
| `env` | `TEnv` | Platform bindings the action reads (`ctx.env`). Default `{}`. Double them yourself (see `./bindings.md`).
|
|
14
|
-
| `request` | `Request \| string` | The request to run under. A `string` becomes `new Request(url)`; pass a full `Request` to seed a `Cookie` header. Default origin `http://localhost/`.
|
|
15
|
-
| `requestInit` | `RequestInit` | Init merged when `request` is a string (e.g. `{ method, headers, body }`).
|
|
16
|
-
| `variables` | `Record<string, unknown>` | Raw backing store for `ctx.get()` / `ctx.set()`, pre-seeded from `vars`.
|
|
17
|
-
| `vars` | `VarsInit` | Vars a prior middleware would have set (object or `[token, value]` list).
|
|
18
|
-
| `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()` without global state.
|
|
19
|
-
| `routeName` | `string` | Current route name (drives `ctx.reverse()` self-references).
|
|
20
|
-
| `params` | `Record<string, string>` | Route params on `ctx.params`.
|
|
21
|
-
| `basename` | `string` | Router basename, normalized exactly like `createRouter({ basename })`; drives `redirect()` prefixing. Default `undefined`.
|
|
22
|
-
| `cacheStore` | `SegmentCacheStore` | Backing store for `use cache` functions (same shape as `createRouter({ cache })`). Without it, cached functions run uncached and their guards never fire.
|
|
23
|
-
| `cacheProfiles` | `Record<string, CacheProfile>` | Profiles for `use cache: "name"`, same shape as `createRouter({ cacheProfiles })`. An unknown profile throws.
|
|
24
|
-
| `theme` | `ThemeConfig \| true` | Theme config (same shape as `createRouter({ theme })`). Without it `ctx.theme` / `ctx.setTheme` are inert.
|
|
25
|
-
| `build` | `boolean` | Seed `ctx.build` (default `false`) for code that branches on the build-time PPR shell-capture pass. With `true`, `ctx.waitUntil()` is inert, as at build time.
|
|
26
|
-
| `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie an action calling `invalidateClientCache()` rotates. The name is ALWAYS seeded (default `rango-state_router_0`) so the rotation `Set-Cookie` fires like production; override `prefix`/`routerId` to match `createRouter({ stateCookiePrefix, id })`, or `version` (value is `{version}:{timestamp}`, default `"0"`). |
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| --------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `env` | `TEnv` | Platform bindings the action reads (`ctx.env`). Default `{}`. Double them yourself (see `./bindings.md`). |
|
|
14
|
+
| `request` | `Request \| string` | The request to run under. A `string` becomes `new Request(url)`; pass a full `Request` to seed a `Cookie` header. Default origin `http://localhost/`. |
|
|
15
|
+
| `requestInit` | `RequestInit` | Init merged when `request` is a string (e.g. `{ method, headers, body }`). |
|
|
16
|
+
| `variables` | `Record<string, unknown>` | Raw backing store for `ctx.get()` / `ctx.set()`, pre-seeded from `vars`. |
|
|
17
|
+
| `vars` | `VarsInit` | Vars a prior middleware would have set (object or `[token, value]` list). |
|
|
18
|
+
| `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()` without global state. |
|
|
19
|
+
| `routeName` | `string` | Current route name (drives `ctx.reverse()` self-references). |
|
|
20
|
+
| `params` | `Record<string, string>` | Route params on `ctx.params`. |
|
|
21
|
+
| `basename` | `string` | Router basename, normalized exactly like `createRouter({ basename })`; drives `redirect()` prefixing. Default `undefined`. |
|
|
22
|
+
| `cacheStore` | `SegmentCacheStore` | Backing store for `use cache` functions (same shape as `createRouter({ cache })`). Without it, cached functions run uncached and their guards never fire. |
|
|
23
|
+
| `cacheProfiles` | `Record<string, CacheProfile>` | Profiles for `use cache: "name"`, same shape as `createRouter({ cacheProfiles })`. An unknown profile throws. |
|
|
24
|
+
| `theme` | `ThemeConfig \| true` | Theme config (same shape as `createRouter({ theme })`). Without it `ctx.theme` / `ctx.setTheme` are inert. |
|
|
25
|
+
| `build` | `boolean` | Seed `ctx.build` (default `false`) for code that branches on the build-time PPR shell-capture pass. With `true`, `ctx.waitUntil()` is inert, as at build time. |
|
|
26
|
+
| `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie an action calling `invalidateClientCache()` rotates. The name is ALWAYS seeded (default `rango-state_router_0`) so the rotation `Set-Cookie` fires like production; override `prefix`/`routerId` to match `createRouter({ stateCookiePrefix, id })`, or `version` (the app's version, as `createRouter({ version })` would set it; value is `{version}:{timestamp}`, default `"0"`). |
|
|
27
27
|
|
|
28
28
|
### Context — `RequestContext<TEnv>` (what your code receives)
|
|
29
29
|
|
|
@@ -110,7 +110,7 @@ it("asserts the client-cache directives an action issued", async () => {
|
|
|
110
110
|
## Caveats
|
|
111
111
|
|
|
112
112
|
- The snapshot fires whether `fn` RETURNS or THROWS. A `throw redirect("/app")` on the success path is captured on `thrown` (NOT re-thrown), so no try/catch is needed; assert on `thrown` for a throwing action.
|
|
113
|
-
- Location state needs a key. A `createLocationState()` definition gets it from the Vite plugin, which a unit-test project does not run, and outside production an unkeyed `Flash(value)` throws before `redirect()` runs: `thrown` is that missing-key error, not the redirect. Call `withLocationStateKey(Flash, "Flash")` once per definition; `locationState` then holds `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix; `{ [Flash.__rsc_ls_key]: value }` works for any key).
|
|
113
|
+
- Location state needs a key. A `createLocationState()` definition gets it from the Vite plugin, which a unit-test project does not run, and outside production an unkeyed `Flash(value)` throws before `redirect()` runs: `thrown` is that missing-key error, not the redirect. Call `withLocationStateKey(Flash, "Flash")` once per definition; `locationState` then holds `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix; `{ [Flash.__rsc_ls_key]: value }` works for any key, including one that carries the `clearOnReload` suffix, `__rsc_ls_Flash~r`).
|
|
114
114
|
- There is no cookies / headers option. Seed a request cookie by passing a full `Request` with the `Cookie` header (as in the recipe).
|
|
115
115
|
- An action has no loader context, so `runLoader` is the wrong shape for it even when the action reads cookies or vars; use `runInRequestContext`.
|
|
116
116
|
- Platform bindings are yours to double via `env` (see `./bindings.md`).
|