@rangojs/router 0.31.0 → 0.32.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.
Files changed (52) hide show
  1. package/dist/bin/rango.js +37 -7
  2. package/dist/types/browser/history-state.d.ts +13 -2
  3. package/dist/types/browser/react/Link.d.ts +10 -13
  4. package/dist/types/browser/react/location-state-shared.d.ts +2 -1
  5. package/dist/types/cache/document-cache-policy.d.ts +1 -1
  6. package/dist/types/route-definition/redirect.d.ts +5 -0
  7. package/dist/types/router/middleware-types.d.ts +6 -0
  8. package/dist/types/rsc/helpers.d.ts +5 -3
  9. package/dist/types/rsc/request-stages.d.ts +2 -0
  10. package/dist/types/server/document-location-state.d.ts +56 -0
  11. package/dist/types/server/request-context.d.ts +23 -6
  12. package/dist/types/testing/render-route.d.ts +17 -0
  13. package/dist/types/testing/serve-shell-request.d.ts +9 -0
  14. package/dist/types/types/handler-context.d.ts +18 -3
  15. package/dist/types/types/segments.d.ts +6 -0
  16. package/dist/types/vite/utils/forward-user-plugins.d.ts +32 -2
  17. package/dist/vite/index.js +3 -3
  18. package/package.json +1 -1
  19. package/skills/comparison/references/framework-comparison.md +3 -2
  20. package/skills/document-cache/SKILL.md +5 -0
  21. package/skills/hooks/state.md +110 -6
  22. package/skills/intercept/SKILL.md +2 -1
  23. package/skills/ppr/SKILL.md +12 -0
  24. package/skills/server-actions/SKILL.md +8 -0
  25. package/skills/testing/client-components.md +44 -19
  26. package/skills/testing/serve-shell-request.md +38 -13
  27. package/skills/ux-architecture/SKILL.md +6 -3
  28. package/src/browser/history-state.ts +24 -2
  29. package/src/browser/react/Link.tsx +10 -13
  30. package/src/browser/react/location-state-shared.ts +8 -1
  31. package/src/browser/rsc-router.tsx +5 -2
  32. package/src/build/runtime-discovery.ts +46 -8
  33. package/src/cache/document-cache-policy.ts +10 -1
  34. package/src/cache/document-cache.ts +7 -0
  35. package/src/route-definition/redirect.ts +5 -0
  36. package/src/router/match-middleware/cache-lookup.ts +23 -6
  37. package/src/router/match-result.ts +9 -1
  38. package/src/router/middleware-types.ts +6 -0
  39. package/src/router/revalidation.ts +23 -3
  40. package/src/rsc/helpers.ts +13 -4
  41. package/src/rsc/progressive-enhancement.ts +5 -0
  42. package/src/rsc/request-stages.ts +28 -1
  43. package/src/rsc/rsc-rendering.ts +39 -9
  44. package/src/server/document-location-state.ts +313 -0
  45. package/src/server/request-context.ts +24 -5
  46. package/src/testing/dispatch.ts +3 -0
  47. package/src/testing/render-route.tsx +40 -6
  48. package/src/testing/run-revalidate.ts +11 -1
  49. package/src/testing/serve-shell-request.ts +16 -0
  50. package/src/types/handler-context.ts +18 -3
  51. package/src/types/segments.ts +6 -0
  52. package/src/vite/utils/forward-user-plugins.ts +13 -5
@@ -101,6 +101,7 @@ Back/forward and state the server sets on a navigation
101
101
  | `<Link state>`, `router.push()` / `router.replace()` | in the commit that shows the destination |
102
102
  | back/forward | in the commit that restores the entry (cached or refetched) |
103
103
  | the server, on a navigation (`setLocationState`, a redirect) | in the commit that shows the destination |
104
+ | the server, on a document load (a page load, a no-JS post) | once the page has hydrated, as stored state |
104
105
  | a navigation the server answers with nothing to re-render | with the new location: the page on screen is the entry's |
105
106
  | `router.push(url, { revalidate: false })` (no server fetch) | with the new location: the page does not change |
106
107
  | a server action's `setLocationState` (no navigation) | when the action's response arrives, added to what readers show |
@@ -279,8 +280,8 @@ import { FlashMessage } from "../location-states";
279
280
  ```
280
281
 
281
282
  Or via `ctx.setLocationState()` from a handler or middleware. It is delivered
282
- with a client navigation's or an action's response; a document response
283
- carries no location state (there is no history entry to write it to yet):
283
+ with a client navigation's or an action's response, and with a document
284
+ response too (see "State on a document response" below):
284
285
 
285
286
  ```tsx
286
287
  (ctx) => {
@@ -289,6 +290,109 @@ carries no location state (there is no history entry to write it to yet):
289
290
  };
290
291
  ```
291
292
 
293
+ ### Several writes to one definition
294
+
295
+ Entries accumulate across definitions, but one definition is one slot in
296
+ `history.state`: for the same definition the last write wins. Two
297
+ `ctx.setLocationState(Toasts(...))` calls in one response, or
298
+ `redirect(url, { state: [Toasts(a), Toasts(b)] })`, deliver only the later
299
+ value, without a warning. A server action, a handler and the middleware
300
+ around them all write to the same response, so a shared helper that writes a
301
+ definition replaces what an earlier call wrote.
302
+
303
+ To combine several messages, make the state an array and write it once, for
304
+ example by collecting them in a request variable and writing the definition
305
+ at the end of the action:
306
+
307
+ ```ts
308
+ export const Toasts = createLocationState<ToastMessage[]>({ flash: true });
309
+
310
+ // One write per response, with every toast in it.
311
+ getRequestContext().setLocationState(Toasts([...savedToasts, loginToast]));
312
+ ```
313
+
314
+ ### State on a document response
315
+
316
+ A document request has no history entry yet, so the router carries what the
317
+ server set to the client and writes it there when the client starts. This
318
+ covers the mutations that most need a flash message: a `<form action={fn}>`
319
+ posted before hydration or without JS, a payment provider redirecting the
320
+ shopper back, a link from an email.
321
+
322
+ | The document request | Where the state travels |
323
+ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------- |
324
+ | renders a page (`setLocationState` in a handler, middleware or no-JS action) | the document's bootstrap payload |
325
+ | redirects (`redirect(url, { state })`, or `setLocationState` and a redirect) | a one-shot cookie the target's document request reads and clears |
326
+ | redirects with `{ external: true }` or off-origin | nowhere: the cookie would not reach the target |
327
+
328
+ ```tsx
329
+ // A payment provider sends the shopper back to /checkout/callback.
330
+ path("/checkout/callback", async (ctx) => {
331
+ const ok = await completeOrder(ctx.searchParams.get("token"));
332
+ return ok
333
+ ? redirect("/orders/confirmed", { state: [Flash({ text: "Paid" })] })
334
+ : redirect("/checkout", { state: [Flash({ text: "Payment declined" })] });
335
+ });
336
+ ```
337
+
338
+ - **When a reader gets it.** `useLocationState` returns `undefined` while the
339
+ document streams and hydrates, then the value, at the moment it would get
340
+ state stored in the entry. A flash message shows just after hydration.
341
+ - **Without JS, no flash.** The state rides to the client, and only the
342
+ client writes it into the entry and hands it to readers. A visitor with JS
343
+ disabled gets the page as the server rendered it, where every reader of the
344
+ state shows `undefined`: the post and its redirect work, but the message
345
+ never shows. If such visitors must see it, render it from the server, for
346
+ example from a search param or a cookie your handler reads.
347
+ - **Flash and refresh.** Flash state is cleared once read, as always. The
348
+ cookie is cleared by the response that read it, so a refresh of the target
349
+ renders without it. A page whose handler sets state on every request
350
+ delivers it again on a refresh, because the handler ran again. A no-JS post
351
+ answered with a page (no redirect) is re-posted by the browser on refresh;
352
+ redirect after the post (`redirect(url, { status: 303, state })`) to avoid
353
+ that.
354
+ - **Caching.** A document that carries state, and the redirect carrying it
355
+ on, are sent `Cache-Control: private, no-store`, after every middleware.
356
+ The document cache neither stores such a document nor answers a request
357
+ that arrives with the cookie from a stored one. A shared PPR shell never
358
+ holds state: on a `ppr` route, state set before the render (by middleware,
359
+ or the cookie) rides the live payload of a shell HIT or MISS, and the shell
360
+ is still served and captured; state a handler sets keeps that route from
361
+ being captured, so it renders in full. `no-store` also keeps the
362
+ page out of the browser's back/forward cache (Chrome's bfcache), so Back to
363
+ it loads it again. That costs little for a one-off flash; a handler that
364
+ sets state on every request pays it on every page view.
365
+ - **`ppr` and cached routes.** A shell HIT and a `cache()` hit run no
366
+ handler, so a handler there cannot set state on that request. Set it from
367
+ middleware, which runs on every request; the HIT still serves, with the
368
+ state in its live payload.
369
+ - **The cookie.** It is named after the router's state cookie
370
+ (`<stateCookieName>-ls`, `__Host-` prefixed on https), `HttpOnly`,
371
+ `SameSite=Lax`, `Path=/`, and lives 60 seconds. Only the next GET document
372
+ request of the redirect's target pathname that navigates reads it: where
373
+ the browser sends `Sec-Fetch-Dest`, it must be `document`, `iframe` or
374
+ `frame`, so a `<link rel=prefetch>` or a `fetch()` of that path leaves it
375
+ to the navigation. A form POST to it, a client navigation and a Flight
376
+ fetch leave it too, as does a request of another path. A
377
+ target that redirects again carries the state on to the next target. There
378
+ is one such cookie per router: two document redirects within a moment (two
379
+ tabs) keep only the later one's state. Without https there is no `__Host-`
380
+ prefix, so a sibling subdomain could set the cookie: treat state that
381
+ arrives this way as a message to show, not as a fact to act on.
382
+ - **Known limit: a redirect from one router to another.** The cookie is
383
+ named after the redirecting router's state cookie, which holds its router
384
+ id (`<stateCookiePrefix>_<routerId>`), and a router reads only its own. On
385
+ one origin served by several routers (a host router, or several
386
+ `createRouter()` apps behind one worker), a document redirect from one
387
+ router to a path another serves loses its state, without a warning. Carry
388
+ such a message another way, such as a search param.
389
+ - **JSON only across a redirect.** The cookie holds JSON: a `Date` arrives as
390
+ a string, a `Map` or `Set` as `{}`, `undefined` is dropped. Development
391
+ warns, naming the slot. A value JSON cannot encode (a `bigint`), or state
392
+ over the cookie's 4000-byte limit, is dropped from the redirect with a
393
+ `console.warn`. Carry an id and load the rest on the target page.
394
+ Navigation and action payloads are not limited this way.
395
+
292
396
  ### State from another deploy
293
397
 
294
398
  History entries survive reloads and back/forward, so a tab left open across a
@@ -419,10 +523,10 @@ as the server rendered it:
419
523
  where it sits (a `<Suspense>` boundary that hydrates late included), or
420
524
  whether the definition's module is loaded at all. Other slots on the entry
421
525
  are left alone.
422
- - State the server sets is never dropped by this: a document response carries
423
- no location state, and `ctx.setLocationState()` / `redirect(url, { state })`
424
- reach `history.state` only through navigations and actions in the running
425
- app, after start-up.
526
+ - State the server sets is never dropped by this: the state a document
527
+ response carries is written after the `~r` keys are removed, so a
528
+ `clearOnReload` slot the server set for this load is applied on this load
529
+ (and removed by the next one). Navigations and actions write after start-up.
426
530
  - `.write()` stores under the same key, so it does not survive a refresh
427
531
  either.
428
532
  - Adding the option to an existing definition moves it to the `~r` key: state
@@ -316,7 +316,8 @@ or not the intercept applies from that page (`rsc-rendering.ts`
316
316
  `x-rsc-prefetch-scope: source`, set from `MatchResult.interceptTargeted`). A
317
317
  full page prefetched from a page where `when` is false never serves a click
318
318
  from a page where it is true. Routes no intercept targets keep one shared
319
- prefetch entry.
319
+ prefetch entry, unless a `revalidate()` that read the source page kept a
320
+ segment (`MatchResult.sourceHeld`, same header).
320
321
 
321
322
  ```typescript
322
323
  // Intercept only when opened from the shop index
@@ -500,6 +500,18 @@ Rango warns with the shell key and backs capture off instead of rendering the
500
500
  same doomed generation on every request. Move a deterministic self-invalidation
501
501
  out of render code if you want the shell to persist.
502
502
 
503
+ ### Location state on a ppr route
504
+
505
+ A shared shell never holds location state: the capture builds its own
506
+ payload, without it. State set before the render (from middleware, or the
507
+ one-shot cookie a document redirect left for this request) rides the live
508
+ payload: a HIT serves the shell with the state in its tail's payload, a MISS
509
+ carries it and still captures. Either response leaves `private, no-store`.
510
+ State a handler sets skips the capture of that MISS, so that route renders in
511
+ full: a HIT runs no handler, so a handler's `ctx.setLocationState()` does not
512
+ happen on a HIT. Set it from middleware (see `/hooks` `state.md`, "State on a
513
+ document response").
514
+
503
515
  ### Opting out per request with `ctx.dynamic()`
504
516
 
505
517
  Middleware and handlers can call `ctx.dynamic()` to force this request onto a
@@ -645,6 +645,14 @@ Redirects from actions render the **target** route tree's matched segments
645
645
  source page's — the target is what the user sees next. See `/hooks
646
646
  useLocationState` for reading flash state on the target page.
647
647
 
648
+ The same holds for a form posted without JS (or before hydration): an action
649
+ that redirects with `state` carries it to the target in a one-shot cookie,
650
+ and one that sets it with `getRequestContext().setLocationState()` and does
651
+ not redirect carries it in the re-rendered page. Either way the target reads
652
+ it with `useLocationState` once it has hydrated. Prefer a `303` redirect, so a
653
+ refresh does not post the form again. State that crosses a no-JS redirect
654
+ travels as JSON (see `/hooks` `state.md`, "State on a document response").
655
+
648
656
  Build the target by name rather than a literal path:
649
657
  `throw redirect(getRequestContext().reverse("account.settings"))`. It fills
650
658
  mount params (`:accountId`, `:locale`) from the page URL the action was called
@@ -8,25 +8,26 @@
8
8
 
9
9
  ### Options — `RenderRouteOptions`
10
10
 
11
- | Field | Type | Meaning |
12
- | ----------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
- | `request` | `Request \| string` | Initial location. Only the URL is read (client render — headers/method ignored). Defaults to the leaf spec's static prefix or `"/"`. |
14
- | `loaderData` | `Record<string, unknown>` | Loader data keyed by loader `$$id`. `useLoader(L)` reads `loaderData[L.$$id]`. |
15
- | `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
- | `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
- | `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 `value` to `history.state` under `def.__rsc_ls_key`. See [Location state](#location-state). |
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). |
21
- | `handle` | `HandleDataSeed` | Advanced: raw wire format `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles`. Merged with it. |
22
- | `routeMap` | `Record<string, string>` | Name -> pattern map (informational; client `useReverse` takes its map as an argument, so this is not consumed). |
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
- | `mount` | `string` | `include()` mount prefix. Wraps the segment chain in a `MountContext` so `useMount()` returns the prefix. Normalized like a path prefix. Defaults to `"/"`. |
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. |
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). |
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`, `recoverableErrors` and `shellError`. See [Hydration](#hydration). |
29
- | `rewrite` | `string` | The path middleware rewrote `request` to (`throw rewrite()`): the leaf is matched against it for params, while the location (`usePathname`, `useSearchParams`) stays `request`, as a rewritten document renders and hydrates. |
11
+ | Field | Type | Meaning |
12
+ | ----------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
13
+ | `request` | `Request \| string` | Initial location. Only the URL is read (client render — headers/method ignored). Defaults to the leaf spec's static prefix or `"/"`. |
14
+ | `loaderData` | `Record<string, unknown>` | Loader data keyed by loader `$$id`. `useLoader(L)` reads `loaderData[L.$$id]`. |
15
+ | `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
+ | `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
+ | `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 `value` to `history.state` under `def.__rsc_ls_key`. See [Location state](#location-state). |
19
+ | `documentLocationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | The location state the document response carried (a handler, middleware or no-JS form action set it, or a document redirect's cookie brought it), as `[def, value]` pairs. Merged at start-up after `locationState` and the `clearOnReload` pass. See [State a document carried](#state-a-document-carried). |
20
+ | `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Seed `useHandle(handle)` by REFERENCE: `[handle, pushedValues[]]`. Accumulated GLOBALLY (not segment-scoped). |
21
+ | `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). |
22
+ | `handle` | `HandleDataSeed` | Advanced: raw wire format `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles`. Merged with it. |
23
+ | `routeMap` | `Record<string, string>` | Name -> pattern map (informational; client `useReverse` takes its map as an argument, so this is not consumed). |
24
+ | `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. |
25
+ | `mount` | `string` | `include()` mount prefix. Wraps the segment chain in a `MountContext` so `useMount()` returns the prefix. Normalized like a path prefix. Defaults to `"/"`. |
26
+ | `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. |
27
+ | `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). |
28
+ | `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. |
29
+ | `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`, `recoverableErrors` and `shellError`. See [Hydration](#hydration). |
30
+ | `rewrite` | `string` | The path middleware rewrote `request` to (`throw rewrite()`): the leaf is matched against it for params, while the location (`usePathname`, `useSearchParams`) stays `request`, as a rewritten document renders and hydrates. |
30
31
 
31
32
  `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).
32
33
 
@@ -265,6 +266,30 @@ it("drops them on a refresh and carries again on the next navigation", async ()
265
266
  });
266
267
  ```
267
268
 
269
+ ### State a document carried
270
+
271
+ A page load, a no-JS form post that renders a page, and the target of a document redirect carry the location state the server set for them (see `/hooks`, state.md, "State on a document response"). Start-up merges it into the entry after the `clearOnReload` pass, so a reader gets it once the root has hydrated, `clearOnReload` slots included. Seed it with `documentLocationState`, not `locationState`: `locationState` is the entry's stored state, which `hydrate: true` passes through the `clearOnReload` pass first.
272
+
273
+ | To test | Do |
274
+ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
275
+ | a flash a document carried, as the page hydrates | `renderRoute(routes, { hydrate: true, documentLocationState })` |
276
+ | a document's `clearOnReload` state replacing a stale entry | `hydrate: true` with the stale value in `locationState`, the new one in `documentLocationState` |
277
+ | what the server put in the document | `serveShellRequest(router, url).readLocationState()` (see [`./serve-shell-request.md`](./serve-shell-request.md)) |
278
+
279
+ ```tsx
280
+ it("shows the flash the declined-payment redirect carried", async () => {
281
+ const { serverHtml, getByRole } = await renderRoute(
282
+ [{ path: "/checkout", Component: FlashBanner }],
283
+ {
284
+ hydrate: true,
285
+ documentLocationState: [[FlashMessage, { text: "Payment declined" }]],
286
+ },
287
+ );
288
+ expect(serverHtml).not.toContain("Payment declined"); // useLocationState is undefined until hydration
289
+ expect(getByRole("alert")).toHaveTextContent("Payment declined");
290
+ });
291
+ ```
292
+
268
293
  ## Hydration
269
294
 
270
295
  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.
@@ -30,19 +30,20 @@ A `Prerender` route is served as in production: from the artifact `router.matchF
30
30
 
31
31
  ### Returns — `ServeShellRequestResult`
32
32
 
33
- | Field | Type | Meaning |
34
- | -------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
- | `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). |
36
- | `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
37
- | `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
38
- | `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). |
39
- | `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. |
40
- | `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. |
41
- | `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. |
42
- | `readDeferred` | `() => Promise<string[] \| undefined>` | The ids of the segments the payload marks deferred (a prefetch skipped them; the fill renders them), in payload order. `[]` when nothing was deferred, `undefined` when no Flight rendered. |
43
- | `readAction` | `() => Promise<ShellRequestAction \| undefined>` | For an `action` request, the call's answer as the browser reads it: `{ ok, result, revalidationError }` (see "A server action called from the page"). `undefined` when no Flight rendered or the payload carries no action result. |
44
- | `response` | `Response` | Status and headers. Its body is already read. |
45
- | `body` | `string` | The body text. |
33
+ | Field | Type | Meaning |
34
+ | ------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `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). |
36
+ | `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
37
+ | `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
38
+ | `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). |
39
+ | `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. |
40
+ | `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. |
41
+ | `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. |
42
+ | `readDeferred` | `() => Promise<string[] \| undefined>` | The ids of the segments the payload marks deferred (a prefetch skipped them; the fill renders them), in payload order. `[]` when nothing was deferred, `undefined` when no Flight rendered. |
43
+ | `readAction` | `() => Promise<ShellRequestAction \| undefined>` | For an `action` request, the call's answer as the browser reads it: `{ ok, result, revalidationError }` (see "A server action called from the page"). `undefined` when no Flight rendered or the payload carries no action result. |
44
+ | `readLocationState` | `() => Promise<Record<string, unknown> \| undefined>` | The payload's location state as the browser reads it: `{ [Def.__rsc_ls_key]: value }` (see "Location state on a document"). `undefined` when it carries none or no Flight rendered. |
45
+ | `response` | `Response` | Status and headers. Its body is already read. |
46
+ | `body` | `string` | The body text. |
46
47
 
47
48
  ### `resetShellTestState(): Promise<void>`
48
49
 
@@ -415,6 +416,30 @@ expect(await result.readPayloadFormState()).toEqual(result.formState);
415
416
  - Request headers come from `headers`. No `Origin` is sent by default, which the origin guard allows.
416
417
  - Not with `partial`. The hydration itself (`hydrateRoot` taking the state, the hook not resetting) is e2e.
417
418
 
419
+ ## Location state on a document
420
+
421
+ A document carries the location state its handler, middleware or no-JS form action set, in its bootstrap payload; `readLocationState()` reads it as the browser does. A document request that redirects carries its state to the target in a one-shot cookie instead (`Set-Cookie`, `<stateCookieName>-ls`, `__Host-` on https): pass that cookie on the target's request, as the browser does when it follows the redirect, and read what the target's payload carries.
422
+
423
+ ```tsx
424
+ const redirected = await serveShellRequest(router, "/payment/callback");
425
+ const setCookie = redirected.response.headers
426
+ .getSetCookie()
427
+ .find((c) => /-ls=/.test(c.split(";")[0]!))!;
428
+
429
+ const target = await serveShellRequest(router, "/checkout", {
430
+ headers: { cookie: setCookie.split(";")[0]!, "sec-fetch-dest": "document" },
431
+ });
432
+ expect(await target.readLocationState()).toEqual({
433
+ [FlashMessage.__rsc_ls_key]: { text: "Payment declined" },
434
+ });
435
+ expect(target.response.headers.get("cache-control")).toBe("private, no-store");
436
+ ```
437
+
438
+ - Only a GET document request of the target's pathname reads the cookie, and clears it (`Max-Age=0` on its response). Where the request sends `Sec-Fetch-Dest`, it must be `document`, `iframe` or `frame`: a prefetch or `fetch()` (`empty`) leaves the cookie to the navigation. Without the header the request counts as a navigation.
439
+ - A document that carries state, and the redirect carrying it on, leave `Cache-Control: private, no-store`; the document cache neither stores nor serves them. On a `ppr` route, state set before its handler ran, by middleware or a redirect's cookie, rides the live payload: the request is still a `MISS` that captures or a `HIT`, and `prelude` holds none of it. State its handler sets keeps the route from being stored as a shell (`readEntry()` is null).
440
+ - For a `form` request, the re-rendered document carries what the action set; a redirecting action carries it in the cookie, as above.
441
+ - What a reader shows is client-side: seed it with `renderRoute({ hydrate: true, documentLocationState })` (see [`./client-components.md`](./client-components.md)).
442
+
418
443
  ## A server action called from the page
419
444
 
420
445
  `serveShellRequest(router, url, { action: { ref, id, args, segments } })` sends the request a JS server action call makes from the page at `url`, as `browser/server-action-bridge.ts` sends it: a POST of `args` with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments`. `ref` is registered under `id` (`<module id>#<export name>`, any unique string of that shape in a test) so the handler loads it by id. The response is the action's Flight payload: its return value and the segments it re-rendered, `flight` as for any other request. Pass the segment ids the page holds as `segments`, so the request has segments to revalidate; without them the server holds none. Read them from the `metadata.matched` of a `partial` request's payload (`flight`), as in "Testing `prefetch: false`" above.
@@ -313,9 +313,12 @@ The rule: **prefetch broadly, and flag what is expensive.**
313
313
  - On a server route, `loading(fallback, { prefetch: false })` keeps the whole
314
314
  entry behind that fallback out of the prefetch (`/route`, "`loading()`
315
315
  options"). A group's `loading()` takes no options.
316
- - When the target's `revalidate()` reads `currentUrl` or `currentParams`, give
317
- the Link `prefetchKey=":source"`: the default prefetch entry is shared by
318
- every source page (`src/browser/react/Link.tsx`, `prefetchKey`).
316
+ - The default prefetch entry is shared by every source page. When the
317
+ target's `revalidate()` reads `currentParams`, `currentUrl` or
318
+ `fromRouteName` and keeps the segment, the router scopes that prefetch to
319
+ the page that sent it (`x-rsc-prefetch-scope: source`): another page
320
+ prefetches its own. `prefetchKey=":source"` on the Link scopes every
321
+ prefetch of it (`src/browser/react/Link.tsx`, `prefetchKey`).
319
322
 
320
323
  ### 2.7 transition() and view transitions
321
324
 
@@ -158,8 +158,9 @@ export function stripShellMissMarker(): void {
158
158
  * the server rendered this document without them. One pass before hydration
159
159
  * (initBrowserApp; renderRoute's `hydrate` mode), so no reader needs to know
160
160
  * the page began as a document load, and a slot nobody reads is removed too.
161
- * No server-set state exists yet to lose: a document response carries none
162
- * (rsc-rendering.ts attaches it to partial payloads only).
161
+ * Server-set state is not lost to it: the state a document response carries
162
+ * is merged after this pass (applyDocumentLocationState), so a `~r` slot the
163
+ * server set for this load survives this load and no later one.
163
164
  *
164
165
  * A slot is recognised by its key alone (the `~r` suffix): at start-up the
165
166
  * module that defines it may not be loaded.
@@ -185,6 +186,27 @@ export function clearLocationStateOnDocumentLoad(): void {
185
186
  if (next) window.history.replaceState(next, "");
186
187
  }
187
188
 
189
+ /**
190
+ * Merge the location state a document response carried (its bootstrap
191
+ * payload's `metadata.locationState`, server/document-location-state.ts)
192
+ * into the entry the document loaded on. Start-up runs it after
193
+ * clearLocationStateOnDocumentLoad and the version is set, before the entry
194
+ * is committed for readers (initBrowserApp; renderRoute's `hydrate` mode),
195
+ * so readers get it once they have hydrated, as stored state. Only typed
196
+ * slots are taken.
197
+ */
198
+ export function applyDocumentLocationState(
199
+ locationState: Record<string, unknown> | undefined,
200
+ ): void {
201
+ if (!locationState) return;
202
+ let slots: Record<string, unknown> | undefined;
203
+ for (const key of Object.keys(locationState)) {
204
+ if (!isLocationStateKey(key)) continue;
205
+ (slots ??= {})[key] = locationState[key];
206
+ }
207
+ if (slots) mergeLocationState(slots);
208
+ }
209
+
188
210
  /**
189
211
  * Merge server-set location state into the current history entry and return
190
212
  * the entry state written. Readers see it when the caller commits it
@@ -110,23 +110,20 @@ export interface LinkProps extends Omit<
110
110
  * keyed on Rango state + target URL. This is correct for routes whose
111
111
  * response shape doesn't depend on where the user navigates from.
112
112
  *
113
- * Set `":source"` when this Link's response would legitimately differ
114
- * based on the source page — typically when the target route (or one
115
- * of its layouts) uses a custom `revalidate()` handler that reads
116
- * `currentUrl` / `currentParams`, and the wildcard entry would
117
- * therefore serve the wrong diff to a navigation from a different
118
- * source.
113
+ * Set `":source"` to scope every prefetch of this Link to the page that
114
+ * sent it, when its response would differ by source page in a way the
115
+ * server cannot see.
119
116
  *
120
- * A route an intercept targets is scoped to the source automatically
121
- * (server-side `x-rsc-prefetch-scope: source`), whether or not the
122
- * intercept applies from this page: the modal and the full page are
123
- * both source-specific. Every other route stays shared, so `":source"`
124
- * is only needed for custom revalidation logic.
117
+ * The server scopes a prefetch to its source automatically
118
+ * (`x-rsc-prefetch-scope: source`) when an intercept targets the route,
119
+ * whether or not it applies from this page (the modal and the full page
120
+ * are both source-specific), and when a `revalidate()` of the target
121
+ * read `currentParams`, `currentUrl` or `fromRouteName` and kept the
122
+ * segment: that diff is the source page's (#1331). Every other response
123
+ * stays shared.
125
124
  *
126
125
  * @example
127
126
  * ```tsx
128
- * // Route uses a `revalidate()` that branches on currentUrl — opt in
129
- * // so prefetches don't bleed across source pages.
130
127
  * <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
131
128
  * ```
132
129
  */
@@ -462,6 +462,12 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
462
462
  * The key is auto-injected by the Vite exposeInternalIds plugin as a property
463
463
  * based on file path and export name. No manual key required.
464
464
  *
465
+ * One definition is one slot in `history.state`. Entries of different
466
+ * definitions accumulate, but for the same definition the last write wins:
467
+ * two `ctx.setLocationState(Def(...))` calls in one response, or
468
+ * `redirect(url, { state: [Def(a), Def(b)] })`, deliver only the later
469
+ * value. To combine writes, make the state an array and write it once.
470
+ *
465
471
  * @param options Optional configuration
466
472
  * @returns A typed state definition for use with Link and useLocationState
467
473
  *
@@ -675,7 +681,8 @@ export function isLocationStateEntry(
675
681
  }
676
682
 
677
683
  /**
678
- * Resolve state entries into a flat object for history.state
684
+ * Resolve state entries into a flat object for history.state. One slot per
685
+ * definition: a later entry of the same definition replaces an earlier one.
679
686
  */
680
687
  export function resolveLocationStateEntries(
681
688
  entries: LocationStateEntry[],
@@ -47,6 +47,7 @@ import {
47
47
  import { createAppShellRef } from "./app-shell.js";
48
48
  import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
49
49
  import {
50
+ applyDocumentLocationState,
50
51
  clearLocationStateOnDocumentLoad,
51
52
  stripShellMissMarker,
52
53
  } from "./history-state.js";
@@ -274,8 +275,10 @@ export async function initBrowserApp(
274
275
  setSuspenseAuditStreamProbe(() => eventController.getState().isStreaming);
275
276
  }
276
277
 
277
- // The document's entry, after the clearOnReload pass and under the version
278
- // set above: what readers get once they have hydrated.
278
+ // The state the document response carried, after the clearOnReload pass
279
+ // and under the version set above.
280
+ applyDocumentLocationState(initialPayload.metadata?.locationState);
281
+ // The document's entry: what readers get once they have hydrated.
279
282
  eventController.commitLocationState(window.history.state);
280
283
 
281
284
  // Initialize event controller with segment order (even without handles)
@@ -37,10 +37,12 @@ export async function discoverAndWriteRouteTypes(
37
37
  outputFiles: string[];
38
38
  }> {
39
39
  let loadConfigFromFile: typeof import("vite").loadConfigFromFile;
40
+ let resolveConfig: typeof import("vite").resolveConfig;
40
41
 
41
42
  try {
42
43
  const vite = await import("vite");
43
44
  loadConfigFromFile = vite.loadConfigFromFile;
45
+ resolveConfig = vite.resolveConfig;
44
46
  } catch {
45
47
  throw new Error(
46
48
  "Runtime discovery requires 'vite'. Install it with: pnpm add -D vite",
@@ -59,14 +61,19 @@ export async function discoverAndWriteRouteTypes(
59
61
  const { createDiscoveryState } = await import("../vite/discovery/state.js");
60
62
  const { discoverClientUrlProjections } =
61
63
  await import("../vite/discovery/client-urls-projection.js");
62
- const { pickForwardedRunnerConfig, selectForwardableResolvePlugins } =
63
- await import("../vite/utils/forward-user-plugins.js");
64
+ const {
65
+ isFrameworkPlugin,
66
+ pickForwardedRunnerConfig,
67
+ selectForwardableResolvePlugins,
68
+ } = await import("../vite/utils/forward-user-plugins.js");
64
69
 
65
70
  // The discovery temp server of `vite dev/build` (expose-internal-ids, the
66
71
  // cloudflare:* stubs, the user's resolver plugins and resolve/define/oxc).
67
- // The config stays unresolved: resolving it would run rango()'s
68
- // configResolved, which writes the static gen files. Forwarded resolvers
69
- // therefore run without their own config/configResolved hooks.
72
+ // Resolver plugins are forwarded from a resolved config, as `vite dev` does:
73
+ // vite-tsconfig-paths and the like build their matchers in config /
74
+ // configResolved, and resolving drops `apply: "build"` plugins. The
75
+ // framework plugins are left out of that resolve: rango()'s configResolved
76
+ // writes the static gen files.
70
77
  const entryPath = resolve(opts.entry);
71
78
  const state = createDiscoveryState(entryPath, undefined);
72
79
  state.projectRoot = opts.root;
@@ -80,9 +87,40 @@ export async function discoverAndWriteRouteTypes(
80
87
  );
81
88
  if (loaded) {
82
89
  state.userRunnerConfig = pickForwardedRunnerConfig(loaded.config);
83
- state.userResolvePlugins = selectForwardableResolvePlugins(
84
- await flattenPluginOptions(loaded.config.plugins),
85
- );
90
+ const userPlugins = (
91
+ await flattenPluginOptions(loaded.config.plugins)
92
+ ).filter((p) => !isFrameworkPlugin(p.name ?? ""));
93
+ try {
94
+ // The temp server's root, so a resolver's root-relative state
95
+ // matches the server it runs in.
96
+ const resolved = await resolveConfig(
97
+ {
98
+ ...loaded.config,
99
+ root: opts.root,
100
+ configFile: false,
101
+ plugins: userPlugins,
102
+ },
103
+ "serve",
104
+ "development",
105
+ );
106
+ state.userResolvePlugins = selectForwardableResolvePlugins(
107
+ resolved.plugins,
108
+ );
109
+ } catch (err: any) {
110
+ console.warn(
111
+ `[rango] Could not resolve the Vite config (${err?.message ?? err}); forwarding its resolver plugins without their config hooks. rango() and rsc() are left out of that resolve, so a plugin that needs the rsc environment fails there`,
112
+ );
113
+ // The resolve would have applied `apply`; do the same for the serve
114
+ // pass, including the function form.
115
+ const serveEnv = { command: "serve", mode: "development" } as const;
116
+ state.userResolvePlugins = selectForwardableResolvePlugins(
117
+ userPlugins.filter((p) =>
118
+ typeof p.apply === "function"
119
+ ? p.apply(loaded.config, serveEnv)
120
+ : p.apply !== "build",
121
+ ),
122
+ );
123
+ }
86
124
  }
87
125
  } catch (err: any) {
88
126
  // Discovery without the app's resolvers fails later on an unrelated