@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.124
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 +7 -2
- package/dist/vite/index.js +47 -6
- package/package.json +61 -21
- package/skills/cache-guide/SKILL.md +8 -6
- package/skills/caching/SKILL.md +148 -1
- package/skills/hooks/SKILL.md +38 -27
- package/skills/host-router/SKILL.md +16 -2
- package/skills/intercept/SKILL.md +4 -2
- package/skills/layout/SKILL.md +11 -6
- package/skills/loader/SKILL.md +6 -2
- package/skills/middleware/SKILL.md +4 -2
- package/skills/migrate-nextjs/SKILL.md +38 -16
- package/skills/parallel/SKILL.md +9 -4
- package/skills/rango/SKILL.md +27 -15
- package/skills/route/SKILL.md +4 -2
- package/skills/testing/SKILL.md +129 -0
- package/skills/testing/bindings.md +89 -0
- package/skills/testing/cache-prerender.md +98 -0
- package/skills/testing/client-components.md +122 -0
- package/skills/testing/e2e-parity.md +125 -0
- package/skills/testing/flight.md +89 -0
- package/skills/testing/handles.md +129 -0
- package/skills/testing/loader.md +128 -0
- package/skills/testing/middleware.md +99 -0
- package/skills/testing/render-handler.md +118 -0
- package/skills/testing/response-routes.md +95 -0
- package/skills/testing/reverse-and-types.md +84 -0
- package/skills/testing/server-actions.md +107 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +120 -0
- package/skills/use-cache/SKILL.md +9 -7
- package/src/browser/action-fence.ts +37 -0
- package/src/browser/cookie-name.ts +140 -0
- package/src/browser/invalidate-client-cache.ts +52 -0
- package/src/browser/navigation-bridge.ts +14 -1
- package/src/browser/navigation-client.ts +14 -1
- package/src/browser/navigation-store-handle.ts +39 -0
- package/src/browser/navigation-store.ts +26 -12
- package/src/browser/prefetch/fetch.ts +7 -0
- package/src/browser/rango-state.ts +176 -97
- package/src/browser/react/index.ts +0 -6
- package/src/browser/rsc-router.tsx +12 -4
- package/src/browser/server-action-bridge.ts +77 -15
- package/src/browser/types.ts +7 -1
- package/src/cache/cache-error.ts +104 -0
- package/src/cache/cache-policy.ts +95 -1
- package/src/cache/cache-runtime.ts +79 -13
- package/src/cache/cache-scope.ts +55 -4
- package/src/cache/cache-tag.ts +135 -0
- package/src/cache/cf/cf-cache-store.ts +2080 -224
- package/src/cache/cf/index.ts +15 -1
- package/src/cache/document-cache.ts +74 -7
- package/src/cache/index.ts +17 -0
- package/src/cache/memory-segment-store.ts +164 -14
- package/src/cache/tag-invalidation.ts +230 -0
- package/src/cache/types.ts +27 -0
- package/src/client.rsc.tsx +1 -1
- package/src/client.tsx +0 -6
- package/src/component-utils.ts +19 -0
- package/src/handle.ts +29 -9
- package/src/host/testing.ts +43 -14
- package/src/index.rsc.ts +29 -1
- package/src/index.ts +43 -1
- package/src/loader.rsc.ts +24 -3
- package/src/loader.ts +16 -2
- package/src/prerender.ts +24 -3
- package/src/router/basename.ts +14 -0
- package/src/router/match-handlers.ts +62 -20
- package/src/router/prerender-match.ts +6 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +30 -0
- package/src/router/segment-resolution/loader-cache.ts +8 -17
- package/src/router/state-cookie-name.ts +33 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router.ts +36 -7
- package/src/rsc/handler.ts +13 -1
- package/src/rsc/helpers.ts +19 -0
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +8 -1
- package/src/rsc/rsc-rendering.ts +2 -0
- package/src/rsc/types.ts +2 -0
- package/src/runtime-env.ts +18 -0
- package/src/server/cookie-store.ts +52 -1
- package/src/server/request-context.ts +105 -2
- package/src/static-handler.ts +25 -3
- package/src/testing/cache-status.ts +166 -0
- package/src/testing/collect-handle.ts +63 -0
- package/src/testing/dispatch.ts +581 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +188 -0
- package/src/testing/e2e/index.ts +149 -0
- package/src/testing/e2e/matchers.ts +51 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +387 -0
- package/src/testing/e2e/server.ts +195 -0
- package/src/testing/flight-matchers.ts +110 -0
- package/src/testing/flight-normalize.ts +38 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +682 -0
- package/src/testing/flight.entry.ts +52 -0
- package/src/testing/flight.ts +234 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +119 -0
- package/src/testing/internal/context.ts +390 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +80 -0
- package/src/testing/render-handler.ts +360 -0
- package/src/testing/render-route.tsx +594 -0
- package/src/testing/run-loader.ts +474 -0
- package/src/testing/run-middleware.ts +231 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +305 -0
- package/src/types/cache-types.ts +13 -4
- package/src/types/error-types.ts +5 -1
- package/src/types/global-namespace.ts +11 -1
- package/src/types/handler-context.ts +16 -5
- package/src/browser/react/use-client-cache.ts +0 -58
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Testing a server action — runInRequestContext
|
|
2
|
+
|
|
3
|
+
**Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `"use server"` action (see `/server-actions`)
|
|
4
|
+
|
|
5
|
+
`runInRequestContext(fn, opts)` builds a real `RequestContext` (the same `createRequestContext` the RSC handler uses) AND enters it around `fn`, so an action that calls `getRequestContext()` / `cookies()` / `ctx.get(var)` runs with production fidelity. You SEED the request, env, and vars; the REAL machinery is cookie/header accumulation, location-state, and redirect/notFound throwing.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `CreateTestContextOptions<TEnv>`
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| --------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `env` | `TEnv` | Platform bindings the action reads (`ctx.env`). Default `{}`. Double them yourself (see `./bindings.md`). |
|
|
14
|
+
| `request` | `Request \| string` | The request to run under. A `string` becomes `new Request(url)`; pass a full `Request` to seed a `Cookie` header. Default origin `http://localhost/`. |
|
|
15
|
+
| `requestInit` | `RequestInit` | Init merged when `request` is a string (e.g. `{ method, headers, body }`). |
|
|
16
|
+
| `variables` | `Record<string, unknown>` | Raw backing store for `ctx.get()` / `ctx.set()`, pre-seeded from `vars`. |
|
|
17
|
+
| `vars` | `VarsInit` | Vars a prior middleware would have set (object or `[token, value]` list). |
|
|
18
|
+
| `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()` without global state. |
|
|
19
|
+
| `routeName` | `string` | Current route name (drives `ctx.reverse()` self-references). |
|
|
20
|
+
| `params` | `Record<string, string>` | Route params on `ctx.params`. |
|
|
21
|
+
| `basename` | `string` | Router basename, normalized exactly like `createRouter({ basename })`; drives `redirect()` prefixing. Default `undefined`. |
|
|
22
|
+
| `cacheStore` | `SegmentCacheStore` | Backing store for `use cache` functions (same shape as `createRouter({ cache })`). Without it, cached functions run uncached and their guards never fire. |
|
|
23
|
+
| `cacheProfiles` | `Record<string, CacheProfile>` | Profiles for `use cache: "name"`, same shape as `createRouter({ cacheProfiles })`. An unknown profile throws. |
|
|
24
|
+
| `theme` | `ThemeConfig \| true` | Theme config (same shape as `createRouter({ theme })`). Without it `ctx.theme` / `ctx.setTheme` are inert. |
|
|
25
|
+
| `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"`). |
|
|
26
|
+
|
|
27
|
+
### Context — `RequestContext<TEnv>` (what your code receives)
|
|
28
|
+
|
|
29
|
+
`fn` receives `ctx`, the full entered `RequestContext`; the same object resolves via `getRequestContext()` inside `fn`. Notable fields:
|
|
30
|
+
|
|
31
|
+
| Field | Type | Meaning |
|
|
32
|
+
| ------------------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| `env` | `TEnv` | The seeded platform bindings. |
|
|
34
|
+
| `request` | `Request` | The concrete request the run is bound to. |
|
|
35
|
+
| `cookies()` | `() => Record<string, string>` | @internal effective cookie view. To read or queue cookies inside the action, use the standalone `cookies()` from `@rangojs/router` (`cookies().get(name)` / `cookies().set(...)`), which returns a `CookieStore`. |
|
|
36
|
+
| `get(token)` / `set(token, v)` | accessor | Read/write request-scoped vars (seeded from `vars` / `variables`). |
|
|
37
|
+
| `params` | `Record<string, string>` | Seeded route params. |
|
|
38
|
+
| `reverse(name, params?)` | function | Build a URL from `routeMap` (when seeded). |
|
|
39
|
+
| `header(name, value)` | function | Queue a response header. |
|
|
40
|
+
| `setLocationState(...)` | function | Set the flash / location state the client reads. |
|
|
41
|
+
| `theme`/`setTheme` | — | Theme accessors, inert unless `theme` is seeded. |
|
|
42
|
+
|
|
43
|
+
### Returns — `RunInRequestContextResult<T>`
|
|
44
|
+
|
|
45
|
+
| Field | Type | Meaning |
|
|
46
|
+
| ----------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
47
|
+
| `result` | `T \| undefined` | `fn`'s awaited return, or `undefined` if it threw. |
|
|
48
|
+
| `thrown` | `unknown` | What `fn` threw (a redirect / `notFound` `Response` on the success path), or `undefined`. Captured, NOT re-thrown — assert on it. |
|
|
49
|
+
| `response` | `Response` | The merged `Response` (status + headers + Set-Cookie). On a thrown redirect, that redirect's `Location` merged with the accumulated cookies/headers. |
|
|
50
|
+
| `cookies` | `Record<string, string>` | Effective cookie view: request cookies + run mutations, last-write-wins. |
|
|
51
|
+
| `headers` | `Record<string, string>` | Response headers the run set (plus a thrown redirect's `Location`), EXCLUDING `set-cookie` (use `cookies`). Names lowercased. A `keepClientCache()` call shows here as `x-rango-keep-cache: "1"`. |
|
|
52
|
+
| `stateCookieName` | `string` | The resolved rango state cookie name this run seeded (default `rango-state_router_0`). Assert an `invalidateClientCache()` rotation against it without recomputing. |
|
|
53
|
+
| `locationState` | `Record<string, unknown>` | The flash set via `ctx.setLocationState()` / `redirect({ state })`, as the flat `{ key: value }` the client reads. |
|
|
54
|
+
|
|
55
|
+
Low-level variant: when you already hold a context from `createTestRequestContext(opts)`, call `runWithRequestContext(ctx, fn)` (re-exported from `@rangojs/router/testing`) to enter it directly. `runInRequestContext` is the one-call convenience over `createTestRequestContext` + `runWithRequestContext`.
|
|
56
|
+
|
|
57
|
+
## Recipe
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { it, expect } from "vitest";
|
|
61
|
+
import { runInRequestContext } from "@rangojs/router/testing";
|
|
62
|
+
import { loginAction } from "../src/actions/login"; // sets a session cookie + flash, then throw redirect("/app")
|
|
63
|
+
|
|
64
|
+
it("sets the session cookie + flash and redirects", async () => {
|
|
65
|
+
const { thrown, cookies, locationState } = await runInRequestContext(
|
|
66
|
+
() => loginAction(input),
|
|
67
|
+
{
|
|
68
|
+
env,
|
|
69
|
+
request: new Request("https://app.test/admin", {
|
|
70
|
+
headers: { Cookie: "sid=abc" },
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
expect((thrown as Response).headers.get("Location")).toBe("/app"); // redirected
|
|
75
|
+
expect(cookies.session).toBeDefined(); // cookie set before the throw, no @internal cast
|
|
76
|
+
expect(locationState).toEqual({ flash: { text: "Welcome back" } });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("asserts the client-cache directives an action issued", async () => {
|
|
80
|
+
// invalidateClientCache() rotates the state cookie -> a Set-Cookie on response.
|
|
81
|
+
const { response, stateCookieName } = await runInRequestContext(() =>
|
|
82
|
+
logoutAction(),
|
|
83
|
+
);
|
|
84
|
+
expect(
|
|
85
|
+
response.headers
|
|
86
|
+
.getSetCookie()
|
|
87
|
+
.some((c) => c.startsWith(stateCookieName + "=")),
|
|
88
|
+
).toBe(true);
|
|
89
|
+
|
|
90
|
+
// keepClientCache() sets the suppression directive header (no cookie).
|
|
91
|
+
const { headers } = await runInRequestContext(() => dismissBannerAction());
|
|
92
|
+
expect(headers["x-rango-keep-cache"]).toBe("1");
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Caveats
|
|
97
|
+
|
|
98
|
+
- 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.
|
|
99
|
+
- There is no cookies / headers option. Seed a request cookie by passing a full `Request` with the `Cookie` header (as in the recipe).
|
|
100
|
+
- `runWithRequestContext(ctx, fn)` is the low-level entry when you already hold a context; `runInRequestContext` is the one-call convenience over `createTestRequestContext` + `runWithRequestContext`.
|
|
101
|
+
- Platform bindings are yours to double via `env` (see `./bindings.md`).
|
|
102
|
+
|
|
103
|
+
## See also
|
|
104
|
+
|
|
105
|
+
- `/server-actions` — the DSL this tests
|
|
106
|
+
- Siblings: `./render-handler.md`, `./middleware.md`, `./loader.md`, `./bindings.md`
|
|
107
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "runInRequestContext — the handler / server-action test primitive"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Inspecting the rendered tree — renderServerTree, findClientBoundaries, findElements
|
|
2
|
+
|
|
3
|
+
**Layer:** RSC unit (react-server project) · **Import:** `@rangojs/router/testing/flight` · **DSL it tests:** client islands across the boundary + server-rendered host content (see `/route`)
|
|
4
|
+
|
|
5
|
+
`renderServerTree` serializes the real Flight (identical bytes to `renderToFlightString`) and then deserializes it back to an inspectable React element tree you traverse — that serialize/deserialize round-trip is REAL; what you SEED is the element you render plus the request context (`request`/`headers`/`params`/`vars`/`env`). The win over the wire string: a client boundary's props come back as real JS values (a `Date` is a `Date`, not the opaque `$D...` encoding) and you can confirm a `"use client"` component actually crossed the boundary (an `I` row) instead of being inlined. There is NO hydration and NO interaction — boundaries are inert placeholders carrying props.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `RenderServerTreeOptions` (extends `RenderToFlightStringOptions`)
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| ------------------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `request` | `Request \| string` | The request the render runs under (absolute URL, path, or a `Request`). Defaults to `http://localhost/`. A server component reading `getRequestContext()` sees this url/cookies. A passed `Request`'s headers win; `headers` is then ignored. |
|
|
14
|
+
| `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) visible to the server tree, when `request` is a string. |
|
|
15
|
+
| `env` | `unknown` | Env / bindings exposed as `ctx.env`. Defaults to `{}`. |
|
|
16
|
+
| `params` | `Record<string, string>` | Route params exposed via `ctx.params` and loader contexts. |
|
|
17
|
+
| `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
|
|
18
|
+
| `vars` | `VarsInit` | Context variables visible via `ctx.get(...)`, as a prior middleware would have set them. Object form (`{ user }`) or `[key, value]` tuples. |
|
|
19
|
+
| `clientComponents` | `Record<string, unknown>` | The `"use client"` components reachable from the tree, keyed by the boundary name to register each as a client reference (in place) so it serializes as an `I` row. Omit when `rangoUseClientTransform()` auto-discovers them, or for pure server-only trees. First-wins per worker; already-registered references are left untouched. |
|
|
20
|
+
|
|
21
|
+
### Context — what your code receives
|
|
22
|
+
|
|
23
|
+
A server component rendered here runs under a real request context: `getRequestContext()` resolves, `ctx.params`/`ctx.routeName`/`ctx.env` reflect the options, `ctx.get(MyVar)` reads a seeded `var`, and cookies come off the request. Same seeding as the handler-test primitives — you render an **element** you build (`<Page />`); to run a route **handler** `(ctx) => rsc` use `renderHandler` (see `./render-handler.md`).
|
|
24
|
+
|
|
25
|
+
### Returns — `RenderServerTreeResult`
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
renderServerTree(element, opts?): Promise<{ flight: string; tree: unknown }>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Field | Type | Meaning |
|
|
32
|
+
| -------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| `flight` | `string` | The raw Flight wire string (so `toMatchFlight` assertions still apply). |
|
|
34
|
+
| `tree` | `unknown` | The deserialized React element tree. Server elements are plain React elements; each client boundary is an inert placeholder whose `props` are the real deserialized JS values that crossed the boundary. |
|
|
35
|
+
|
|
36
|
+
#### `findClientBoundaries(tree, selector?) -> ClientBoundary[]`
|
|
37
|
+
|
|
38
|
+
Every client boundary in document order; always an array (no throw on zero/many — destructure `const [tag] = ...` and assert `.length` when the count matters; no match yields `[]`). `selector` is a STRING (match by export name) or a `BoundarySelector` object, criteria AND-ed.
|
|
39
|
+
|
|
40
|
+
| `BoundarySelector` | Type | Meaning |
|
|
41
|
+
| ------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
42
|
+
| `name` | `string` | Match the boundary's export name (same as a bare string). |
|
|
43
|
+
| `testId` | `string` | Match `props["data-testid"]` exactly (a `data-testid` you passed AS A PROP to the island). |
|
|
44
|
+
| `props` | `Record<string, unknown>` | Subset deep-equal match (Date/Map/Set/array/nested-object aware); unlisted props ignored. |
|
|
45
|
+
| `where` | `(boundary: ClientBoundary) => boolean` | Arbitrary predicate. |
|
|
46
|
+
|
|
47
|
+
`ClientBoundary` = `{ id, name, props (excludes children), children, element }`.
|
|
48
|
+
|
|
49
|
+
#### `findElements(tree, selector?) -> FoundElement[]`
|
|
50
|
+
|
|
51
|
+
Every SERVER/HOST element a server component produced (`<article>`, `<h2>`), in document order; always an array. `selector` is a host TAG string (`"h2"`) or an `ElementSelector` object, criteria AND-ed.
|
|
52
|
+
|
|
53
|
+
| `ElementSelector` | Type | Meaning |
|
|
54
|
+
| ----------------- | ------------------------------------ | ---------------------------------------------------------------------------------- |
|
|
55
|
+
| `tag` | `string` | Match the host tag name (`"article"`, `"h2"`). |
|
|
56
|
+
| `testId` | `string` | Match `props["data-testid"]` exactly (on a host element). |
|
|
57
|
+
| `props` | `Record<string, unknown>` | Subset deep-equal match (Date/Map/Set/array/nested aware). |
|
|
58
|
+
| `text` | `string \| RegExp` | Match the element's text content (substring for a string, `.test()` for a RegExp). |
|
|
59
|
+
| `where` | `(element: FoundElement) => boolean` | Arbitrary predicate. |
|
|
60
|
+
|
|
61
|
+
`FoundElement` = `{ tag, props (excludes children), children, text, element }`.
|
|
62
|
+
|
|
63
|
+
#### `textContent(node) -> string`
|
|
64
|
+
|
|
65
|
+
Concatenates every string/number leaf of a node's subtree in document order — the clean way to assert rendered text, instead of `JSON.stringify(tree).toContain(...)`.
|
|
66
|
+
|
|
67
|
+
## Recipe
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { it, expect } from "vitest";
|
|
71
|
+
import {
|
|
72
|
+
renderServerTree,
|
|
73
|
+
findClientBoundaries,
|
|
74
|
+
findElements,
|
|
75
|
+
textContent,
|
|
76
|
+
} from "@rangojs/router/testing/flight";
|
|
77
|
+
import { PriceTag } from "./PriceTag.js"; // a "use client" component (any filename)
|
|
78
|
+
|
|
79
|
+
async function ProductPanel({ amount, asOf }: { amount: number; asOf: Date }) {
|
|
80
|
+
await Promise.resolve();
|
|
81
|
+
return (
|
|
82
|
+
<article>
|
|
83
|
+
<h2>Wine</h2>
|
|
84
|
+
<PriceTag amount={amount} currency="USD" asOf={asOf} />
|
|
85
|
+
</article>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
it("client props survive the serialize -> deserialize round trip", async () => {
|
|
90
|
+
const { flight, tree } = await renderServerTree(
|
|
91
|
+
<ProductPanel amount={19.5} asOf={new Date("2026-01-02T00:00:00Z")} />,
|
|
92
|
+
// Omit clientComponents when rangoUseClientTransform() is wired (see ./setup.md);
|
|
93
|
+
// otherwise register islands explicitly:
|
|
94
|
+
{ clientComponents: { PriceTag } },
|
|
95
|
+
);
|
|
96
|
+
expect(flight).toMatchFlight("PriceTag"); // wire assertions still work
|
|
97
|
+
|
|
98
|
+
const [tag] = findClientBoundaries(tree, "PriceTag");
|
|
99
|
+
expect(tag.props.amount).toBe(19.5); // a real number
|
|
100
|
+
expect(tag.props.asOf).toBeInstanceOf(Date); // a real Date, not "$D..."
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("asserts the server-rendered host content", async () => {
|
|
104
|
+
const { tree } = await renderServerTree(
|
|
105
|
+
<ProductPanel amount={19.5} asOf={new Date("2026-01-02T00:00:00Z")} />,
|
|
106
|
+
{ clientComponents: { PriceTag } },
|
|
107
|
+
);
|
|
108
|
+
const [h2] = findElements(tree, "h2");
|
|
109
|
+
expect(h2.text).toBe("Wine");
|
|
110
|
+
expect(textContent(tree)).toContain("Wine"); // instead of JSON.stringify(tree)
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Caveats
|
|
115
|
+
|
|
116
|
+
- This renders an ELEMENT you build (`<Page />`). To test a route HANDLER (a `(ctx) => rsc` function registered via `path(...)`), use `renderHandler` (see [`./render-handler.md`](./render-handler.md)) — handlers have their own util. Do NOT wrap a handler in `createElement` and render it here: a handler is not a component, so React would invoke it with `props` as its argument instead of the real `HandlerContext`, and the seeded `params`/`vars` plus `ctx.use`/`ctx.reverse`/`ctx.get`/`cookies()` would all be absent.
|
|
117
|
+
- Island auto-discovery from the server tree's imports needs `rangoUseClientTransform()` in the rsc project (see `./setup.md`). Without it a plainly-imported island is just a function the serializer renders server-side — register islands explicitly via `{ clientComponents: { PriceTag } }`.
|
|
118
|
+
- Same alias requirement as `./flight.md`: a rendered component (or handler) that reads `getRequestContext()`/`cookies()` from the `@rangojs/router` barrel needs the `index.rsc.ts` alias (see `./setup.md`), or it hits the throwing out-of-react-server stub.
|
|
119
|
+
- A client boundary's props come back as REAL JS values after deserialization (a `Date` is a `Date`, not a `$D...` encoding) — but there is NO hydration and NO interaction; boundaries are inert placeholders carrying props.
|
|
120
|
+
- Server COMPONENTS do not survive Flight as identities (they are executed during serialization), so `findElements` matches the host elements they PRODUCED, not the component function. Client islands keep identity — use `findClientBoundaries` for those.
|
|
121
|
+
- `findClientBoundaries` finds islands (`I` rows); `findElements` finds host elements. A `testId` on an island matches with `findClientBoundaries`; a `testId` on a host element matches with `findElements`. Use `textContent(node)` in place of `JSON.stringify(tree).toContain`.
|
|
122
|
+
- A true interactive, clickable DOM `renderServer` is intentionally NOT shipped: in-process happy-dom hydration re-tests React more than your app and misses server/client divergence (the only hydration bug worth a dedicated test, which needs a real browser). Test interaction at e2e.
|
|
123
|
+
|
|
124
|
+
## See also
|
|
125
|
+
|
|
126
|
+
- `/route` — the DSL this tests
|
|
127
|
+
- Siblings: `./flight.md`, `./render-handler.md`, `./setup.md`
|
|
128
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "renderServerTree — serialize then deserialize to an inspectable tree" (and the "findElements / textContent" subsection)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Testing setup — the two vitest projects
|
|
2
|
+
|
|
3
|
+
**Layer:** cross-cutting (vitest config) · **Import:** `@rangojs/router/testing/vitest`
|
|
4
|
+
|
|
5
|
+
Real machinery: Vite transpiles `@rangojs/router`'s shipped TS source and resolves the bare specifier to its react-server impls so your app's router / loaders / middleware import in a bare Vitest process. You SEED nothing here — this file only wires the two projects every other recipe builds on. The node/DOM project keeps React on its CLIENT build; the Flight project flips to the `react-server` condition.
|
|
6
|
+
|
|
7
|
+
## API
|
|
8
|
+
|
|
9
|
+
### Options — `RangoTestAliasOptions`
|
|
10
|
+
|
|
11
|
+
| Field | Type | Meaning |
|
|
12
|
+
| -------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `preset` | `"node" \| "cloudflare"` | Deployment preset, matching `rango({ preset })` in the Vite plugin. `"cloudflare"` additionally stubs the `cloudflare:workers` / `cloudflare:email` runtime virtuals a CF route tree imports. A string (not a boolean) so more presets can be added without an API change. Default `"node"`. |
|
|
14
|
+
|
|
15
|
+
### Functions
|
|
16
|
+
|
|
17
|
+
| Function | Returns | Use |
|
|
18
|
+
| --------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
19
|
+
| `rangoTestConfig(opts?)` | `{ alias, server: { deps: { inline } } }` | Recommended. Spread into the node/DOM project's `test` block. Bundles the resolve aliases AND `server.deps.inline`. |
|
|
20
|
+
| `rangoTestAliases(opts?)` | `TestAlias[]` (`{ find, replacement }[]`) | Lower-level. The bare `@rangojs/router` -> `index.rsc.ts` alias plus the `:version` / `@vitejs/plugin-rsc/rsc` stubs (and CF stubs under `preset:"cloudflare"`). Used in the rsc project's `resolve.alias`. |
|
|
21
|
+
| `rangoUseClientTransform()` | a Vite plugin (`{ name, transform }`) | Add to the rsc project `plugins`. Applies the `"use client"` transform so `renderServerTree` auto-discovers client islands from the server tree's imports. |
|
|
22
|
+
|
|
23
|
+
### Returns — `RangoTestConfig` (from `rangoTestConfig`)
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
interface RangoTestConfig {
|
|
27
|
+
alias: TestAlias[]; // -> test.alias
|
|
28
|
+
server: { deps: { inline: RegExp[] } }; // [/@rangojs[/\\]router/] -> test.server.deps.inline
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Recipe
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// vitest.config.ts — the node + DOM project (keeps React on its CLIENT build)
|
|
36
|
+
import { defineConfig } from "vitest/config";
|
|
37
|
+
import { rangoTestConfig } from "@rangojs/router/testing/vitest";
|
|
38
|
+
|
|
39
|
+
export default defineConfig({
|
|
40
|
+
test: {
|
|
41
|
+
globals: true,
|
|
42
|
+
include: ["test/**/*.test.{ts,tsx}"],
|
|
43
|
+
environment: "node", // renderRoute tests add a `// @vitest-environment happy-dom` pragma
|
|
44
|
+
// `preset: "cloudflare"` also stubs cloudflare:workers / cloudflare:email (default "node").
|
|
45
|
+
...rangoTestConfig({ preset: "cloudflare" }),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// vitest.rsc.config.ts — the Flight project (react-server condition)
|
|
52
|
+
import { defineConfig } from "vitest/config";
|
|
53
|
+
import {
|
|
54
|
+
rangoTestAliases,
|
|
55
|
+
rangoUseClientTransform,
|
|
56
|
+
} from "@rangojs/router/testing/vitest";
|
|
57
|
+
|
|
58
|
+
// Production React in this process AND any forked worker (forks inherit env).
|
|
59
|
+
process.env.NODE_ENV = "production";
|
|
60
|
+
|
|
61
|
+
export default defineConfig({
|
|
62
|
+
plugins: [rangoUseClientTransform()],
|
|
63
|
+
resolve: {
|
|
64
|
+
conditions: ["react-server"],
|
|
65
|
+
alias: rangoTestAliases({ preset: "cloudflare" }), // or { preset: "node" }
|
|
66
|
+
},
|
|
67
|
+
test: {
|
|
68
|
+
globals: true,
|
|
69
|
+
include: ["**/*.rsc-test.{ts,tsx}"],
|
|
70
|
+
pool: "forks",
|
|
71
|
+
execArgv: ["--conditions=react-server"], // or React throws "react-server condition must be enabled"
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// example.test.ts — one test in the node/DOM project, importing real app code
|
|
78
|
+
import { describe, it, expect } from "vitest";
|
|
79
|
+
import { dispatch } from "@rangojs/router/testing";
|
|
80
|
+
import { createRouter } from "@rangojs/router";
|
|
81
|
+
import { apiPatterns } from "../src/api/urls"; // path.json(...) routes only, no Prerender()
|
|
82
|
+
|
|
83
|
+
const router = createRouter().routes(apiPatterns);
|
|
84
|
+
|
|
85
|
+
describe("api", () => {
|
|
86
|
+
it("serializes a JSON response route", async () => {
|
|
87
|
+
const res = await dispatch(router, { request: "/health" });
|
|
88
|
+
expect(res.status).toBe(200);
|
|
89
|
+
expect(await res.json()).toEqual({ status: "ok" });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Scripts:
|
|
95
|
+
|
|
96
|
+
```jsonc
|
|
97
|
+
{
|
|
98
|
+
"scripts": {
|
|
99
|
+
"test:unit": "vitest run",
|
|
100
|
+
"test:unit:rsc": "vitest run --config vitest.rsc.config.ts",
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Caveats
|
|
106
|
+
|
|
107
|
+
- Node >= 23 requires `rangoTestConfig()`, not bare `rangoTestAliases()`. `@rangojs/router` is consumed as SOURCE (exports -> `./src/*.ts`), and Node >= 23 refuses to type-strip `.ts` under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). `rangoTestConfig` ships as compiled JS AND adds `server.deps.inline: [/@rangojs[/\\]router/]` so Vite (not Node) transpiles rango source. With bare `rangoTestAliases` you must wire `deps.inline` yourself.
|
|
108
|
+
- Two separate projects. The node/DOM project keeps React on its CLIENT build; the Flight project uses the `react-server` condition in a separate `vitest.rsc.config.ts`. The main project must NOT set `react-server` — it flips React to the no-hooks server build and breaks every `renderRoute` / client test.
|
|
109
|
+
- The rsc project needs BOTH `resolve.conditions: ["react-server"]` AND the bare `@rangojs/router` -> `index.rsc.ts` alias from `rangoTestAliases({ preset })`. `resolve.conditions` alone is not reliably applied to bare-package export resolution; without the alias a handler/component reading `getRequestContext()` / `cookies()` resolves the throwing out-of-react-server stub (symptom: `renderHandler` returns `tree: undefined`).
|
|
110
|
+
- `NODE_ENV` must be `"production"` in the rsc project. Dev `NODE_ENV` crashes the bare worker (jsxDEV owner-stack machinery uninitialized) and emits volatile debug rows that defeat stable Flight snapshots.
|
|
111
|
+
- The forked rsc worker (`pool: "forks"`) must force the condition via `execArgv: ["--conditions=react-server"]`, or React throws "the react-server condition must be enabled".
|
|
112
|
+
- The `@rangojs/router:version` and `@vitejs/plugin-rsc/rsc` virtuals must be stubbed; the preset does it. A bare router import without stubbing throws.
|
|
113
|
+
- The rango fragment goes under `test` (`test.alias` + `test.server.deps.inline`, both returned by `rangoTestConfig`), NOT under top-level `resolve`.
|
|
114
|
+
- Wire `rangoUseClientTransform()` into the rsc project `plugins` so islands auto-discover from the server tree imports (see `./server-tree.md`); without it, register islands explicitly with `clientComponents`.
|
|
115
|
+
|
|
116
|
+
## See also
|
|
117
|
+
|
|
118
|
+
- (cross-cutting)
|
|
119
|
+
- Siblings: `./flight.md`, `./server-tree.md`, `./render-handler.md`, `./response-routes.md`
|
|
120
|
+
- Long-form prose: [docs/testing.md](https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/testing.md) — section "Setup" (and the subsections "Resolving @rangojs/router in a unit test — use the preset" and "Two vitest projects")
|
|
@@ -328,13 +328,15 @@ export async function getProducts() {
|
|
|
328
328
|
Writes to the same `SegmentCacheStore` as `cache()` DSL, `Static()`, and `Prerender()`.
|
|
329
329
|
One store, one configuration.
|
|
330
330
|
|
|
331
|
-
Cache entries (and `cacheProfiles`)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
331
|
+
Cache entries (and `cacheProfiles`) can be tagged via `cache({ tags })` or, inside
|
|
332
|
+
a `"use cache"` function, runtime `cacheTag(...tags)`. The built-in
|
|
333
|
+
`MemorySegmentCacheStore` and `CFCacheStore` index by tag. Invalidate on demand
|
|
334
|
+
with `updateTag(...tags)` (awaitable, read-your-own-writes; for server actions) or
|
|
335
|
+
`revalidateTag(...tags)` (background, non-blocking; for route handlers/webhooks).
|
|
336
|
+
Both hard-purge; the difference is awaitability, not stale-serving. For
|
|
337
|
+
`CFCacheStore`, distributed invalidation needs a `kv` namespace (markers live in
|
|
338
|
+
that same namespace). The separate `revalidate()` export is the client-update axis
|
|
339
|
+
(which segments re-render on a navigation or action), not a cache bust.
|
|
338
340
|
|
|
339
341
|
## Interaction with Other Caching
|
|
340
342
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action fence: a refcounted flag that is raised while a server action is in
|
|
3
|
+
* flight and lowered when it resolves.
|
|
4
|
+
*
|
|
5
|
+
* It replaces the eager cache clear the action bridge used to do at action
|
|
6
|
+
* start. While the fence is up, the decision of whether the action invalidated
|
|
7
|
+
* anything is deferred to the response:
|
|
8
|
+
*
|
|
9
|
+
* - prefetch consumption is suspended (a queued prefetch result is not served),
|
|
10
|
+
* - a genuine navigation during the flight fetches with `cache: "no-store"` so
|
|
11
|
+
* it cannot be served stale bytes from the browser's Vary-keyed HTTP cache,
|
|
12
|
+
* - popstate reads are treated as stale-while-revalidate.
|
|
13
|
+
*
|
|
14
|
+
* Nothing is wiped, rotated, or broadcast while the fence is up — that is what
|
|
15
|
+
* keeps a sibling tab from seeing a pre-commit signal. Refcounted so concurrent
|
|
16
|
+
* actions compose: each action raises and lowers its own reference, and the
|
|
17
|
+
* fence is down only when the count reaches zero.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
let fenceCount = 0;
|
|
21
|
+
|
|
22
|
+
export function enterActionFence(): void {
|
|
23
|
+
fenceCount++;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function exitActionFence(): void {
|
|
27
|
+
if (fenceCount > 0) fenceCount--;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isActionFenceActive(): boolean {
|
|
31
|
+
return fenceCount > 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Test-only: reset the refcount between cases. */
|
|
35
|
+
export function __resetActionFence(): void {
|
|
36
|
+
fenceCount = 0;
|
|
37
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rango state cookie: value codec and attribute serialization.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the client (the document.cookie writer in rango-state.ts) and the
|
|
5
|
+
* server (the Set-Cookie writer in invalidateClientCache). Environment-agnostic:
|
|
6
|
+
* no window/document access and no name composition.
|
|
7
|
+
*
|
|
8
|
+
* Name RESOLUTION deliberately lives elsewhere (router init, see
|
|
9
|
+
* router/state-cookie-name.ts). Keeping composition out of this shared module
|
|
10
|
+
* is what lets the client read the server-resolved name verbatim and compose
|
|
11
|
+
* nothing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Default prefix when `stateCookiePrefix` is unset or empty after sanitization. */
|
|
15
|
+
export const DEFAULT_STATE_COOKIE_PREFIX = "rango-state";
|
|
16
|
+
|
|
17
|
+
/** Internal response header carrying the keepClientCache() directive. */
|
|
18
|
+
export const KEEP_CACHE_HEADER = "x-rango-keep-cache";
|
|
19
|
+
|
|
20
|
+
// Per-client signal headers (lower-case) that a SHARED response cache must never
|
|
21
|
+
// store or replay (Finding #3): a `Set-Cookie` (rango state rotation, or any
|
|
22
|
+
// cookie a loader set) and the keepClientCache() directive. Strip-all-Set-Cookie
|
|
23
|
+
// is deliberate — a shared store can't know the resolved cookie name, and any
|
|
24
|
+
// per-client cookie in a cacheable document is the hazard. Single source for the
|
|
25
|
+
// predicate, presence check, and strip below.
|
|
26
|
+
const PER_CLIENT_SIGNAL_HEADERS: readonly string[] = [
|
|
27
|
+
"set-cookie",
|
|
28
|
+
KEEP_CACHE_HEADER,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** True for a per-client signal header. */
|
|
32
|
+
export function isPerClientSignalHeader(name: string): boolean {
|
|
33
|
+
return PER_CLIENT_SIGNAL_HEADERS.includes(name.toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True if `headers` carries any per-client signal. */
|
|
37
|
+
export function hasPerClientSignal(headers: Headers): boolean {
|
|
38
|
+
return PER_CLIENT_SIGNAL_HEADERS.some((name) => headers.has(name));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Remove every per-client signal header from `headers` in place. */
|
|
42
|
+
export function stripPerClientSignals(headers: Headers): void {
|
|
43
|
+
for (const name of PER_CLIENT_SIGNAL_HEADERS) headers.delete(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the raw, UNDECODED value of a single cookie from a cookie string (a
|
|
48
|
+
* `document.cookie` jar or a request `Cookie` header). Shared by both seats so
|
|
49
|
+
* the client mirror and the server monotonic-guard read the SAME jar entry —
|
|
50
|
+
* their agreement is the guard's contract. First match wins (the entry the
|
|
51
|
+
* client's mirror holds); exact name boundary via the `=`; an empty value
|
|
52
|
+
* (`name=`) returns null (treated as absent, not a usable prior value).
|
|
53
|
+
*/
|
|
54
|
+
export function getRawCookieValue(
|
|
55
|
+
cookieString: string | null,
|
|
56
|
+
name: string,
|
|
57
|
+
): string | null {
|
|
58
|
+
if (!cookieString) return null;
|
|
59
|
+
const target = name + "=";
|
|
60
|
+
for (const part of cookieString.split(";")) {
|
|
61
|
+
const trimmed = part.trim();
|
|
62
|
+
if (trimmed.startsWith(target)) {
|
|
63
|
+
const value = trimmed.slice(target.length);
|
|
64
|
+
return value === "" ? null : value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Encode a state value for the wire: `encodeURIComponent(version):timestamp`.
|
|
72
|
+
* Only the build-derived version is encoded (it is arbitrary); the `:`
|
|
73
|
+
* separator and numeric timestamp stay raw, so the `{version}:{timestamp}`
|
|
74
|
+
* shape survives and `:` is a legal cookie-value octet.
|
|
75
|
+
*/
|
|
76
|
+
export function encodeStateValue(version: string, timestamp: number): string {
|
|
77
|
+
return `${encodeURIComponent(version)}:${timestamp}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parsed state value. `version` is decoded; `timestamp` is the raw integer. */
|
|
81
|
+
export interface StateValue {
|
|
82
|
+
version: string;
|
|
83
|
+
timestamp: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decode a wire value back into `{version, timestamp}`. Returns null for a
|
|
88
|
+
* malformed value (no `:`, empty version, or non-numeric timestamp) so callers
|
|
89
|
+
* mint fresh instead of trusting garbage.
|
|
90
|
+
*/
|
|
91
|
+
export function decodeStateValue(raw: string): StateValue | null {
|
|
92
|
+
const colon = raw.indexOf(":");
|
|
93
|
+
if (colon <= 0) return null;
|
|
94
|
+
const timestamp = Number(raw.slice(colon + 1));
|
|
95
|
+
if (!Number.isFinite(timestamp)) return null;
|
|
96
|
+
let version: string;
|
|
97
|
+
try {
|
|
98
|
+
version = decodeURIComponent(raw.slice(0, colon));
|
|
99
|
+
} catch {
|
|
100
|
+
// A malformed percent-escape (e.g. "%:1") must mint fresh, not throw —
|
|
101
|
+
// a thrown URIError here would 500 the server seat or fail client boot.
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return { version, timestamp };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Mint a fresh state value whose timestamp is strictly greater than the previous
|
|
109
|
+
* one, so a re-mint inside the same millisecond (or a backward clock step) still
|
|
110
|
+
* differs from the current value. `prevRaw` is the current wire value (the
|
|
111
|
+
* client's in-memory mirror, or the server's inbound header/cookie) or null; its
|
|
112
|
+
* timestamp is the floor. Shared by both seats so the monotonic guard lives once.
|
|
113
|
+
*/
|
|
114
|
+
export function mintStateValue(
|
|
115
|
+
version: string,
|
|
116
|
+
prevRaw: string | null,
|
|
117
|
+
): string {
|
|
118
|
+
const prevTs = prevRaw ? (decodeStateValue(prevRaw)?.timestamp ?? 0) : 0;
|
|
119
|
+
const ts = Math.max(Date.now(), prevTs + 1);
|
|
120
|
+
return encodeStateValue(version, ts);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Attribute string for the state cookie. Session cookie (no Max-Age/Expires),
|
|
125
|
+
* Path=/ (whole app), SameSite=Lax (sent on top-level navigations), and Secure
|
|
126
|
+
* only on https so the document.cookie write does not silently fail on plain
|
|
127
|
+
* http dev. Never HttpOnly (the client reads and writes it).
|
|
128
|
+
*/
|
|
129
|
+
export function stateCookieAttributes(secure: boolean): string {
|
|
130
|
+
return `; Path=/; SameSite=Lax${secure ? "; Secure" : ""}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Serialize a full `name=value` cookie string with the state attributes. */
|
|
134
|
+
export function serializeStateCookie(
|
|
135
|
+
name: string,
|
|
136
|
+
value: string,
|
|
137
|
+
secure: boolean,
|
|
138
|
+
): string {
|
|
139
|
+
return `${name}=${value}${stateCookieAttributes(secure)}`;
|
|
140
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client seat of `invalidateClientCache()` (the `default` export condition).
|
|
3
|
+
*
|
|
4
|
+
* Makes the current client behave as if a server action had just completed:
|
|
5
|
+
* the history cache is marked stale (SWR), the prefetch map is flushed, the
|
|
6
|
+
* state rotates, and sibling tabs are broadcast to — the same
|
|
7
|
+
* `markCacheAsStaleAndBroadcast()` path the server-action bridge uses. This is
|
|
8
|
+
* the gentler mark-stale (not hard-clear) behavior, so Back renders the cached
|
|
9
|
+
* entry instantly and revalidates.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getRegisteredStore } from "./navigation-store-handle.js";
|
|
13
|
+
import { clearPrefetchCache } from "./prefetch/cache.js";
|
|
14
|
+
|
|
15
|
+
export function invalidateClientCache(): void {
|
|
16
|
+
if (typeof document === "undefined") {
|
|
17
|
+
// SSR pass of a client component also resolves the default condition. A
|
|
18
|
+
// render-time call must not take down the page; no-op with a dev warning.
|
|
19
|
+
if (process.env.NODE_ENV !== "production") {
|
|
20
|
+
console.warn(
|
|
21
|
+
"[rango] invalidateClientCache() was called during a server render; " +
|
|
22
|
+
"it is a no-op outside the browser.",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const store = getRegisteredStore();
|
|
29
|
+
if (store) {
|
|
30
|
+
store.markCacheAsStaleAndBroadcast();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Pre-boot: no store registered yet. clearPrefetchCache() (which rotates the
|
|
35
|
+
// state) is complete at this point — there is no history cache to mark and no
|
|
36
|
+
// sibling state worth broadcasting.
|
|
37
|
+
clearPrefetchCache();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Client no-op for `keepClientCache()`. It is a server action directive (the
|
|
42
|
+
* `react-server` condition sets a response header the action bridge reads);
|
|
43
|
+
* there is nothing to suppress from the client side.
|
|
44
|
+
*/
|
|
45
|
+
export function keepClientCache(): void {
|
|
46
|
+
if (process.env.NODE_ENV !== "production") {
|
|
47
|
+
console.warn(
|
|
48
|
+
"[rango] keepClientCache() has no effect on the client; it is a server " +
|
|
49
|
+
"action directive. Call it from inside a server action.",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|