@rangojs/router 0.0.0-experimental.132 → 0.0.0-experimental.133

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 (141) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +43 -2
  3. package/dist/bin/rango.js +92 -16
  4. package/dist/vite/index.js +166 -70
  5. package/package.json +19 -18
  6. package/skills/breadcrumbs/SKILL.md +1 -1
  7. package/skills/bundle-analysis/SKILL.md +2 -2
  8. package/skills/cache-guide/SKILL.md +2 -2
  9. package/skills/caching/SKILL.md +16 -9
  10. package/skills/debug-manifest/SKILL.md +4 -2
  11. package/skills/document-cache/SKILL.md +2 -2
  12. package/skills/handler-use/SKILL.md +1 -1
  13. package/skills/hooks/SKILL.md +2 -2
  14. package/skills/host-router/SKILL.md +1 -1
  15. package/skills/intercept/SKILL.md +1 -1
  16. package/skills/loader/SKILL.md +2 -0
  17. package/skills/migrate-react-router/SKILL.md +4 -2
  18. package/skills/mime-routes/SKILL.md +1 -1
  19. package/skills/prerender/SKILL.md +2 -0
  20. package/skills/rango/SKILL.md +12 -11
  21. package/skills/response-routes/SKILL.md +2 -2
  22. package/skills/route/SKILL.md +4 -0
  23. package/skills/router-setup/SKILL.md +3 -0
  24. package/skills/scripts/SKILL.md +179 -0
  25. package/skills/testing/SKILL.md +1 -1
  26. package/skills/testing/bindings.md +20 -6
  27. package/skills/testing/cache-prerender.md +5 -2
  28. package/skills/testing/client-components.md +2 -0
  29. package/skills/testing/e2e-parity.md +1 -1
  30. package/skills/testing/flight.md +8 -9
  31. package/skills/testing/render-handler.md +1 -1
  32. package/skills/testing/response-routes.md +1 -1
  33. package/skills/testing/server-actions.md +11 -11
  34. package/skills/testing/setup.md +3 -0
  35. package/skills/typesafety/SKILL.md +3 -2
  36. package/skills/use-cache/SKILL.md +10 -9
  37. package/src/browser/event-controller.ts +109 -2
  38. package/src/browser/partial-update.ts +12 -0
  39. package/src/browser/prefetch/cache.ts +17 -0
  40. package/src/browser/prefetch/fetch.ts +69 -2
  41. package/src/browser/react/Link.tsx +30 -5
  42. package/src/browser/react/NavigationProvider.tsx +12 -2
  43. package/src/browser/react/location-state-shared.ts +14 -2
  44. package/src/browser/react/use-href.tsx +8 -1
  45. package/src/browser/react/use-link-status.ts +23 -2
  46. package/src/browser/response-adapter.ts +14 -3
  47. package/src/browser/rsc-router.tsx +3 -0
  48. package/src/browser/scroll-restoration.ts +8 -3
  49. package/src/browser/server-action-bridge.ts +46 -11
  50. package/src/browser/types.ts +6 -0
  51. package/src/build/generate-route-types.ts +0 -1
  52. package/src/build/route-trie.ts +33 -9
  53. package/src/build/route-types/include-resolution.ts +7 -1
  54. package/src/build/route-types/router-processing.ts +0 -6
  55. package/src/build/route-types/source-scan.ts +105 -7
  56. package/src/cache/cache-policy.ts +42 -8
  57. package/src/cache/cache-runtime.ts +65 -5
  58. package/src/cache/cache-scope.ts +71 -11
  59. package/src/cache/cache-tag.ts +7 -2
  60. package/src/cache/cf/cf-base64.ts +33 -0
  61. package/src/cache/cf/cf-cache-constants.ts +127 -0
  62. package/src/cache/cf/cf-cache-store.ts +85 -613
  63. package/src/cache/cf/cf-cache-types.ts +349 -0
  64. package/src/cache/cf/cf-kv-utils.ts +46 -0
  65. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  66. package/src/cache/document-cache.ts +11 -0
  67. package/src/cache/handle-snapshot.ts +8 -1
  68. package/src/cache/profile-registry.ts +25 -1
  69. package/src/cache/segment-codec.ts +9 -1
  70. package/src/cache/types.ts +4 -0
  71. package/src/client.rsc.tsx +38 -0
  72. package/src/client.tsx +11 -0
  73. package/src/components/DefaultDocument.tsx +8 -2
  74. package/src/context-var.ts +1 -1
  75. package/src/decode-loader-results.ts +7 -1
  76. package/src/escape-script.ts +52 -0
  77. package/src/handles/MetaTags.tsx +56 -5
  78. package/src/handles/Scripts.tsx +183 -0
  79. package/src/handles/breadcrumbs.ts +29 -11
  80. package/src/handles/is-thenable.ts +19 -0
  81. package/src/handles/meta.ts +46 -0
  82. package/src/handles/script.ts +244 -0
  83. package/src/host/cookie-handler.ts +7 -3
  84. package/src/host/pattern-matcher.ts +16 -2
  85. package/src/index.rsc.ts +5 -0
  86. package/src/index.ts +5 -0
  87. package/src/response-utils.ts +25 -0
  88. package/src/route-definition/dsl-helpers.ts +7 -0
  89. package/src/route-definition/redirect.ts +1 -2
  90. package/src/router/content-negotiation.ts +58 -10
  91. package/src/router/intercept-resolution.ts +9 -0
  92. package/src/router/match-middleware/cache-store.ts +10 -1
  93. package/src/router/middleware.ts +10 -3
  94. package/src/router/pattern-matching.ts +25 -23
  95. package/src/router/prefetch-cache-ttl.ts +51 -0
  96. package/src/router/router-interfaces.ts +7 -0
  97. package/src/router/router-options.ts +23 -0
  98. package/src/router/segment-resolution/fresh.ts +10 -0
  99. package/src/router/segment-resolution/helpers.ts +35 -1
  100. package/src/router/segment-resolution/loader-cache.ts +10 -6
  101. package/src/router/segment-resolution/revalidation.ts +6 -0
  102. package/src/router/segment-resolution.ts +1 -0
  103. package/src/router/trie-matching.ts +14 -9
  104. package/src/router.ts +18 -10
  105. package/src/rsc/handler.ts +52 -13
  106. package/src/rsc/helpers.ts +7 -1
  107. package/src/rsc/index.ts +1 -4
  108. package/src/rsc/loader-fetch.ts +107 -37
  109. package/src/rsc/progressive-enhancement.ts +18 -6
  110. package/src/rsc/response-cache-serve.ts +238 -0
  111. package/src/rsc/response-route-handler.ts +16 -133
  112. package/src/rsc/rsc-rendering.ts +13 -4
  113. package/src/rsc/server-action.ts +52 -6
  114. package/src/rsc/types.ts +7 -0
  115. package/src/search-params.ts +24 -5
  116. package/src/segment-loader-promise.ts +17 -2
  117. package/src/server/loader-registry.ts +16 -18
  118. package/src/server/request-context.ts +47 -20
  119. package/src/testing/dispatch.ts +108 -25
  120. package/src/testing/flight.ts +25 -0
  121. package/src/testing/internal/context.ts +25 -2
  122. package/src/testing/render-handler.ts +3 -1
  123. package/src/testing/render-route.tsx +15 -0
  124. package/src/testing/run-loader.ts +10 -3
  125. package/src/theme/ThemeProvider.tsx +20 -6
  126. package/src/theme/ThemeScript.tsx +7 -3
  127. package/src/theme/constants.ts +54 -3
  128. package/src/theme/theme-script.ts +22 -7
  129. package/src/types/request-scope.ts +8 -3
  130. package/src/vite/plugins/cjs-to-esm.ts +8 -1
  131. package/src/vite/plugins/expose-id-utils.ts +10 -1
  132. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
  133. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
  134. package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
  135. package/src/vite/plugins/expose-internal-ids.ts +0 -1
  136. package/src/vite/plugins/version-plugin.ts +5 -17
  137. package/src/vite/plugins/virtual-entries.ts +12 -2
  138. package/src/vite/rango.ts +15 -6
  139. package/src/vite/utils/ast-handler-extract.ts +11 -4
  140. package/src/vite/utils/directive-prologue.ts +40 -0
  141. package/src/vite/utils/prerender-utils.ts +17 -2
@@ -13,10 +13,10 @@ Inspect the route manifest to verify parent relationships, shortCodes, and route
13
13
  In development, visit:
14
14
 
15
15
  ```
16
- http://localhost:PORT/__debug_manifest
16
+ http://localhost:PORT/?__debug_manifest
17
17
  ```
18
18
 
19
- Returns formatted JSON with all routes and layouts.
19
+ Returns formatted JSON. The HTTP endpoint shape is `{ routerId, routeManifest, routeAncestry, routeTrie, precomputedEntries }` (see below for the programmatic API shape).
20
20
 
21
21
  ## Programmatic Access
22
22
 
@@ -32,6 +32,8 @@ if (process.env.NODE_ENV !== "production") {
32
32
 
33
33
  ## Manifest Structure
34
34
 
35
+ The programmatic `router.debugManifest()` call returns `{ routes, layouts, totalRoutes, totalLayouts }`:
36
+
35
37
  ```json
36
38
  {
37
39
  "routes": {
@@ -28,7 +28,7 @@ const router = createRouter<AppBindings>({
28
28
  urls: urlpatterns,
29
29
  // App-level cache store. The document cache middleware uses this store's
30
30
  // getResponse/putResponse methods.
31
- cache: (_env, ctx) => new CFCacheStore({ ctx: ctx! }),
31
+ cache: (_env, ctx) => ({ store: new CFCacheStore({ ctx: ctx! }) }),
32
32
  });
33
33
 
34
34
  router.use(
@@ -150,7 +150,7 @@ import { urlpatterns } from "./urls";
150
150
  const router = createRouter<AppBindings>({
151
151
  document: Document,
152
152
  urls: urlpatterns,
153
- cache: (_env, ctx) => new CFCacheStore({ ctx: ctx! }),
153
+ cache: (_env, ctx) => ({ store: new CFCacheStore({ ctx: ctx! }) }),
154
154
  });
155
155
 
156
156
  router.use(
@@ -40,7 +40,7 @@ ProductPage.use = () => [
40
40
  loading(<ProductSkeleton />),
41
41
  middleware(async (ctx, next) => {
42
42
  await next();
43
- ctx.header("Cache-Control", "private, max-age=60");
43
+ ctx.headers.set("Cache-Control", "private, max-age=60");
44
44
  }),
45
45
  ];
46
46
  ```
@@ -915,8 +915,8 @@ See `/links` for the full URL generation guide. `ctx.reverse()` is server-only;
915
915
  | `useRouter()` | Stable router actions | push, replace, refresh, prefetch, back, forward |
916
916
  | `useSegments()` | URL path & segment IDs | path, segmentIds, location |
917
917
  | `useLinkStatus()` | Link pending state | { pending } |
918
- | `useLoader()` | Loader data (strict) | data, isLoading, error |
919
- | `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading |
918
+ | `useLoader()` | Loader data (strict) | data, isLoading, error, load, refetch |
919
+ | `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading, error, refetch |
920
920
  | `useRefreshLoaders()` | Refresh cross-loader group(s) | `() => (groups: string \| string[]) => Promise<void>` |
921
921
  | `useHandle()` | Accumulated handle data | T (handle type) |
922
922
  | `useAction()` | Server action state | state, error, result |
@@ -66,7 +66,7 @@ Why two methods instead of one overloaded `.map()`:
66
66
  | `.` or `*` | Any apex domain (`example.com`) |
67
67
  | `**` | Any domain (apex + all subdomains) |
68
68
  | `*.` | Any single-level subdomain (`www.example.com`) |
69
- | `**. ` | Any multi-level subdomain (`a.b.example.com`) |
69
+ | `**.` | Any multi-level subdomain (`a.b.example.com`) |
70
70
  | `example.com` | Exact domain |
71
71
  | `*.com` | Any apex `.com` domain |
72
72
  | `*.example.com` | Single subdomain of `example.com` |
@@ -23,7 +23,7 @@ function ShopLayout() {
23
23
  );
24
24
  }
25
25
 
26
- export const urlpatterns = urls(({ path, layout, intercept, loader }) => [
26
+ export const urlpatterns = urls(({ path, layout, intercept, loader, loading }) => [
27
27
  layout(<ShopLayout />, () => [
28
28
  // Intercept product detail - shows modal during soft navigation
29
29
  intercept(
@@ -561,6 +561,8 @@ entirely (no read, no write).
561
561
  ### Per-Loader Store Override
562
562
 
563
563
  ```typescript
564
+ import { MemorySegmentCacheStore } from "@rangojs/router/cache";
565
+
564
566
  const hotStore = new MemorySegmentCacheStore({ defaults: { ttl: 10 } });
565
567
 
566
568
  loader(PricingLoader, () => [
@@ -597,11 +597,13 @@ function ErrorBoundary() {
597
597
  }
598
598
 
599
599
  // Rango: errorBoundary() wrapping a group of routes
600
+ // Server-side error boundaries only receive `error` (no `reset` — server render
601
+ // cannot be retried; users can navigate away or refresh).
600
602
  layout(<DashboardLayout />, () => [
601
- errorBoundary(({ error, reset }) => (
603
+ errorBoundary(({ error }) => (
602
604
  <div>
603
605
  <h2>Something went wrong</h2>
604
- <button onClick={reset}>Try again</button>
606
+ <p>{error.message}</p>
605
607
  </div>
606
608
  )),
607
609
  path("/dashboard", DashboardIndex, { name: "dashboard" }),
@@ -81,7 +81,7 @@ export const urlpatterns = urls(({ path }) => [
81
81
  - `Accept: application/json` — JSON handler
82
82
  - `Accept: text/plain` — text handler
83
83
  - `Accept: application/xml` — XML handler
84
- - `Accept: */*` — first variant (JSON, since it was registered first)
84
+ - `Accept: */*` — RSC page (the primary, since it was registered first)
85
85
 
86
86
  ## Wildcard Routes
87
87
 
@@ -119,6 +119,8 @@ interface BuildContext<TParams> {
119
119
  use: <T>(handle: Handle<T>) => (data: T) => void; // Push handle data
120
120
  url: URL; // Synthetic URL from pattern + params
121
121
  pathname: string; // Pathname from synthetic URL
122
+ searchParams: URLSearchParams; // URLSearchParams from the synthetic URL (always empty for prerender)
123
+ search: {}; // Typed search params -- always {} for prerender (no real query string)
122
124
  set(key: string, value: any): void; // Set context variable (string key)
123
125
  set<T>(contextVar: ContextVar<T>, value: T): void; // Set typed context variable
124
126
  get(key: string): any; // Read context variable (string key)
@@ -218,17 +218,18 @@ Grouped by concern — read when you need to…
218
218
 
219
219
  **Client & presentation** — build the client-side UX:
220
220
 
221
- | Skill | Description |
222
- | ------------------- | ------------------------------------------------------------------------- |
223
- | `/hooks` | Client-side React hooks |
224
- | `/theme` | Light/dark mode with FOUC prevention |
225
- | `/i18n` | Locale routing with `:locale?`, resolution chains, react-intl integration |
226
- | `/fonts` | Load web fonts with preload hints |
227
- | `/css` | Import CSS in the Document `<head>` (`?url` + managed `precedence` links) |
228
- | `/tailwind` | Set up Tailwind CSS v4 with `?url` imports |
229
- | `/view-transitions` | React View Transitions on layouts, routes, and parallel slots |
230
- | `/breadcrumbs` | Built-in Breadcrumbs handle for breadcrumb navigation |
231
- | `/react-compiler` | Enable React Compiler (opt-in) the vite-rsc way; client-only scope |
221
+ | Skill | Description |
222
+ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
223
+ | `/hooks` | Client-side React hooks |
224
+ | `/theme` | Light/dark mode with FOUC prevention |
225
+ | `/i18n` | Locale routing with `:locale?`, resolution chains, react-intl integration |
226
+ | `/fonts` | Load web fonts with preload hints |
227
+ | `/css` | Import CSS in the Document `<head>` (`?url` + managed `precedence` links) |
228
+ | `/scripts` | Inject third-party scripts (GTM/analytics) into head/body via the `Script` handle; nonce auto-applied to document-rendered scripts |
229
+ | `/tailwind` | Set up Tailwind CSS v4 with `?url` imports |
230
+ | `/view-transitions` | React View Transitions on layouts, routes, and parallel slots |
231
+ | `/breadcrumbs` | Built-in Breadcrumbs handle for breadcrumb navigation |
232
+ | `/react-compiler` | Enable React Compiler (opt-in) the vite-rsc way; client-only scope |
232
233
 
233
234
  **Observability & production health**:
234
235
 
@@ -316,7 +316,7 @@ the response payload straight from the `urls()` patterns and needs no
316
316
  ### ParamsFor with Response Routes
317
317
 
318
318
  ```typescript
319
- import type { ParamsFor } from "@rangojs/router/client";
319
+ import type { ParamsFor } from "@rangojs/router";
320
320
 
321
321
  // Works for both RSC and response routes
322
322
  type ProductParams = ParamsFor<"api.productDetail">;
@@ -422,7 +422,7 @@ export const urlpatterns = urls(({ path, include }) => [
422
422
 
423
423
  ```typescript
424
424
  import type { RouteResponse } from "@rangojs/router";
425
- import type { ParamsFor } from "@rangojs/router/client";
425
+ import type { ParamsFor } from "@rangojs/router";
426
426
 
427
427
  // Scoped (before mount) -- use the module directly, no global wiring needed
428
428
  type Stats = RouteResponse<typeof blogApiPatterns, "stats">;
@@ -346,6 +346,10 @@ state persists on back/forward. See `/hooks` for details.
346
346
  Attach location state to any server response (not just redirects):
347
347
 
348
348
  ```typescript
349
+ import { createLocationState } from "@rangojs/router";
350
+
351
+ const ServerInfo = createLocationState<{ data: string }>();
352
+
349
353
  path("/dashboard", (ctx) => {
350
354
  ctx.setLocationState(ServerInfo({ data: "welcome" }));
351
355
  return <Dashboard />;
@@ -62,6 +62,9 @@ urls(
62
62
  revalidate, // Control revalidation
63
63
  intercept, // Intercept routes for modals
64
64
  when, // Conditional rendering
65
+ errorBoundary, // Add an error boundary
66
+ notFoundBoundary, // Add a not-found boundary
67
+ transition, // Configure view transitions
65
68
  }) => [
66
69
  // Route definitions here
67
70
  ],
@@ -0,0 +1,179 @@
1
+ ---
2
+ name: scripts
3
+ description: Inject third-party scripts (GTM, analytics, widgets) into the document head/body via the Script handle
4
+ argument-hint: "[vendor]"
5
+ ---
6
+
7
+ # Scripts
8
+
9
+ Inject `<script>` tags into the document the idiomatic Rango way: push a config
10
+ from a **server** route/layout handler with `ctx.use(Script)(config)`, and render
11
+ them with the built-in **`<Scripts />`** component (the `Meta` / `<MetaTags>`
12
+ pair, but for scripts). The request CSP **nonce is applied automatically to
13
+ document-rendered scripts** — you never read or pass it. (The one exception is an
14
+ async script first encountered on a soft navigation; see the nonce caveat under
15
+ "Execution contract".)
16
+
17
+ ## Setup
18
+
19
+ `<Scripts />` is a client component; place it in your Document (which is
20
+ `"use client"`). The default Document already includes both sites; a custom one
21
+ adds them next to `<MetaTags />`:
22
+
23
+ ```tsx
24
+ // document.tsx ("use client")
25
+ import { MetaTags, Scripts } from "@rangojs/router/client";
26
+
27
+ export function Document({ children }) {
28
+ return (
29
+ <html lang="en" suppressHydrationWarning>
30
+ <head>
31
+ <MetaTags />
32
+ <Scripts /> {/* renders position: "head" scripts (the default) */}
33
+ </head>
34
+ <body>
35
+ <Scripts position="body" /> {/* renders position: "body" scripts */}
36
+ {children}
37
+ </body>
38
+ </html>
39
+ );
40
+ }
41
+ ```
42
+
43
+ ## Push from a handler
44
+
45
+ `ScriptConfig` is a discriminated union — exactly one of three shapes, so invalid
46
+ combinations are compile errors:
47
+
48
+ ```ts
49
+ import { Script } from "@rangojs/router";
50
+
51
+ // 1. External ASYNC — a React resource. Loads once when first encountered,
52
+ // including after a soft navigation, deduped by src. The fire-and-forget case.
53
+ ctx.use(Script)({ id: "stripe", src: "https://js.stripe.com/v3", async: true });
54
+
55
+ // 2. External ORDERED — in-place, optional `defer`. Document-load (see below).
56
+ ctx.use(Script)({
57
+ id: "plausible",
58
+ src: "https://plausible.io/js/script.js",
59
+ defer: true,
60
+ attributes: { "data-domain": "example.com" },
61
+ });
62
+
63
+ // 3. INLINE — `id` REQUIRED, raw JS body (escaped against </script> by <Scripts>).
64
+ // For GTM/GA4/Segment let the body self-inject its loader (see below).
65
+ ctx.use(Script)({ id: "gtm", children: gtmBootstrap("GTM-XXXX") });
66
+ ```
67
+
68
+ | Shape | Required | Optional | Forbidden |
69
+ | ---------------- | -------------------- | ----------------------------------------------- | ----------------------- |
70
+ | Inline | `id`, `children` | `position`, `type`, `attributes` | `src`, `async`, `defer` |
71
+ | External async | `src`, `async: true` | `id`, `position`, `type`, `attributes` | `children`, `defer` |
72
+ | External ordered | `src` | `defer`, `id`, `position`, `type`, `attributes` | `children`, `async` |
73
+
74
+ - `id` — dedup key (last-push-wins), and rendered as the script's DOM `id` (for
75
+ vendors that target `<script id="…">`). Required for inline (React never dedups
76
+ inline scripts); for ordered external it falls back to `src`. Async externals
77
+ dedup by `src` (matching React), so there `id` is the DOM id only.
78
+ - `position` — `"head"` (default) or `"body"`. An async script is hoisted to
79
+ `<head>` by React regardless.
80
+ - `type` — free string: `"module"`, `"application/ld+json"`, `"text/partytown"`, …
81
+ - `attributes` — React-cased (`crossOrigin`, not `crossorigin`) and React-typed
82
+ (`data-*`, `integrity`, `referrerPolicy`, …). Excluded: the fields the handle
83
+ manages (`id`/`src`/`async`/`defer`/`type`/`children`/`nonce`) and all `on*`
84
+ handlers (`onLoad`/`onError`/… — a config is serialized to the client, so a
85
+ function can't survive; use a `"use client"` component for callbacks).
86
+
87
+ ## Execution contract (read this)
88
+
89
+ React makes a `<script>` it mounts on the client INERT (it creates the element via
90
+ innerHTML, which the HTML spec never executes). So:
91
+
92
+ | Script | Runs on hard load | Runs on soft (`<Link>`) navigation |
93
+ | -------------------------------- | ------------------------------ | ----------------------------------------------------- |
94
+ | Inline (`children`) | Yes (it's in the initial HTML) | **No** — it is document-load only |
95
+ | External ordered (`defer`/plain) | Yes | **No** — document-load only |
96
+ | External `async` | Yes | **Yes** — React loads the resource on first encounter |
97
+
98
+ `<Scripts>` enforces this honestly: after hydration it **freezes** the inline +
99
+ ordered set to what was in the initial HTML, so a navigation never inserts an
100
+ inert (silently dead) `<script>`. Async configs stay reactive. Reusing an `id`
101
+ shapes the INITIAL document output (last-push-wins) — it does not re-run a script
102
+ during navigation.
103
+
104
+ **Nonce caveat for soft-nav async.** The "nonce is applied automatically" claim
105
+ holds for DOCUMENT-RENDERED scripts (they carry the nonce in the SSR HTML). An
106
+ async script first encountered on a soft navigation is injected by React on the
107
+ client, where `useNonce()` is `undefined` by design (the router does not serialize
108
+ the nonce to the client — that would weaken CSP), so it has no nonce attribute. It
109
+ still loads under `'strict-dynamic'` (React's nonced runtime injects it, so the
110
+ trust propagates) — which is the recommended policy — or if your `script-src`
111
+ allows the host. A nonce-only policy without `'strict-dynamic'` would block it.
112
+
113
+ **Per-navigation behavior belongs in a client component or hook**, not in a
114
+ re-pushed inline script. The GTM demo does exactly this: a root-layout `Script`
115
+ bootstrap fires the first page_view on document load, and a `"use client"`
116
+ `<GtmPageViews>` component fires a page_view on every subsequent soft navigation.
117
+
118
+ ## The inline-self-inject rule (GTM/GA4/Segment)
119
+
120
+ If an inline bootstrap must run **before** an external loader, do NOT push the
121
+ loader as a separate `{ src, async }` config: React 19 hoists a declarative
122
+ `<script async src>` to the **top** of `<head>`, above your inline bootstrap, so
123
+ the loader could run before the bootstrap. Instead let the bootstrap inject its
124
+ own loader (Google's snippet does exactly this):
125
+
126
+ ```ts
127
+ function gtmBootstrap(id: string): string {
128
+ return [
129
+ "window.dataLayer=window.dataLayer||[];",
130
+ 'window.dataLayer.push({"gtm.start":new Date().getTime(),event:"gtm.js"});',
131
+ `(function(d,s,i){var j=d.createElement(s);j.async=true;j.src="https://www.googletagmanager.com/gtm.js?id="+encodeURIComponent(i);var f=d.getElementsByTagName(s)[0];f.parentNode.insertBefore(j,f);})(document,"script",${JSON.stringify(id)});`,
132
+ ].join("");
133
+ }
134
+ ```
135
+
136
+ Under a `'strict-dynamic'` CSP the nonced inline script vouches for the loader it
137
+ creates, so the injected loader needs no nonce of its own.
138
+
139
+ ### Per-route tagging on the first render
140
+
141
+ A route can **override** a layout's bootstrap by reusing the `id`, baking
142
+ per-route data into the FIRST (hard-load) page_view server-side — the Script
143
+ handle is collected after handlers run (parent → child, last-wins):
144
+
145
+ ```ts
146
+ // root layout: generic bootstrap
147
+ ctx.use(Script)({ id: "gtm", children: gtmBootstrap("GTM-XXXX") });
148
+ // a route: same id, with content_group baked in
149
+ ctx.use(Script)({
150
+ id: "gtm",
151
+ children: gtmBootstrapWith({ content_group: "blog" }),
152
+ });
153
+ ```
154
+
155
+ ## CSP
156
+
157
+ The nonce is automatic for document-rendered scripts. Include `'strict-dynamic'`
158
+ in `script-src` (recommended): besides letting a nonced loader vouch for the
159
+ scripts it injects, it also covers the one nonce-less case — an async script first
160
+ loaded on a soft navigation is injected client-side without a nonce (see the
161
+ caveat above), and `'strict-dynamic'` trusts it via React's nonced runtime.
162
+ Otherwise allow the vendor hosts. For GTM/GA4 (Google's wildcards): `script-src
163
+ 'self' 'nonce-…' 'strict-dynamic' https://*.googletagmanager.com`, plus `img-src`
164
+ / `connect-src` for `*.google-analytics.com` / `*.analytics.google.com`, and
165
+ `frame-src https://*.googletagmanager.com` for the GTM `<noscript>` iframe. See
166
+ [Google's CSP guide](https://developers.google.com/tag-platform/security/guides/csp).
167
+
168
+ ## Not covered (do it yourself)
169
+
170
+ - **`onLoad` / `onReady` / `onError`** — callbacks can't cross the server handle
171
+ boundary. Render your own `"use client"` component with a load listener keyed
172
+ off the script id.
173
+ - **`<noscript>` fallbacks** (e.g. the GTM body iframe) — not a `<script>`;
174
+ render it directly in your Document `<body>`.
175
+ - **Partytown / web-worker offloading** — push the worker config with
176
+ `type: "text/partytown"` and wire Partytown's own nonce config manually.
177
+
178
+ A full GTM + GA4-style integration (page_view on first render + soft nav, nonce,
179
+ ecommerce events) lives in `tests/vite-rsc-demo`.
@@ -88,7 +88,7 @@ Each primitive links to its sub-file (API + recipe + caveats).
88
88
  | a loader's cookie / header / redirect output (auth-loader pattern) | unit (node) | [`runLoaderResult`](./loader.md) | `@rangojs/router/testing` |
89
89
  | one middleware's ordering / short-circuit / cookie+header merge | unit (node) | [`runMiddleware`](./middleware.md) | `@rangojs/router/testing` |
90
90
  | a `"use server"` action's cookie / header / flash output (even on `throw redirect()`) | unit (node) | [`runInRequestContext`](./server-actions.md) | `@rangojs/router/testing` |
91
- | a handle's `collect`/accumulator, or a seeded handle read | unit | [`collectHandle` / seeded `handles`](./handles.md) | `@rangojs/router/testing[/dom]` |
91
+ | a handle's `collect`/accumulator, or a seeded handle read | unit | [`collectHandle` / seeded `handles`](./handles.md) | `@rangojs/router/testing` |
92
92
  | a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | [`renderRoute`](./client-components.md) | `@rangojs/router/testing/dom` |
93
93
  | a redirect / status / headers / cookies / **response route** (json/text/html/xml/md), no Flight | integration | [`dispatch`](./response-routes.md) | `@rangojs/router/testing` |
94
94
  | a real async **Server Component** (assert what it rendered: typed boundary props, server-rendered host content, inlined-vs-island) | RSC unit | [`renderServerTree` + `findClientBoundaries`/`findElements`](./server-tree.md) | `@rangojs/router/testing/flight` |
@@ -39,12 +39,26 @@ import { requireMembership } from "../app/middleware";
39
39
  import { authorizeAction } from "../app/actions";
40
40
 
41
41
  // A D1Database double satisfying drizzle-orm/d1's driver contract.
42
- const fakeD1 = makeFakeD1({
43
- // .raw() serves positional rows in schema-column order, driver-encoded.
44
- raw: () => [[1, "acme", "2026-01-01T00:00:00.000Z"]],
45
- // .run() returns { success, meta }, no rows.
46
- run: () => ({ success: true, meta: { changes: 1 } }),
47
- });
42
+ // rango ships no double for D1 — build your own to match the driver.
43
+ const fakeD1 = {
44
+ prepare: () => ({
45
+ // .raw() serves positional rows in schema-column order, driver-encoded.
46
+ raw: async () => [[1, "acme", "2026-01-01T00:00:00.000Z"]],
47
+ // .run() returns { success, meta }, no rows.
48
+ run: async () => ({ success: true, meta: { changes: 1 } }),
49
+ all: async () => ({ results: [], success: true, meta: {} }),
50
+ first: async () => null,
51
+ bind: (..._args: unknown[]) => ({
52
+ raw: async () => [[1, "acme", "2026-01-01T00:00:00.000Z"]],
53
+ run: async () => ({ success: true, meta: { changes: 1 } }),
54
+ all: async () => ({ results: [], success: true, meta: {} }),
55
+ first: async () => null,
56
+ }),
57
+ }),
58
+ batch: async (stmts: unknown[]) =>
59
+ stmts.map(() => ({ results: [], success: true, meta: {} })),
60
+ exec: async (_sql: string) => ({ count: 0, duration: 0 }),
61
+ };
48
62
 
49
63
  describe("bindings seam", () => {
50
64
  it("loader reads through env.DB", async () => {
@@ -60,10 +60,13 @@ filterCacheDecisions(events: readonly TelemetryEvent[]): CacheDecisionEvent[]
60
60
  ## Recipe
61
61
 
62
62
  ```ts
63
- // In a Playwright e2e, import the cache-status helpers from the e2e entry —
63
+ // In a Playwright e2e, import from the e2e entry —
64
64
  // the @rangojs/router/testing barrel pulls a build-only virtual that does not
65
65
  // resolve in a plain Playwright runner.
66
- import { assertCacheStatus } from "@rangojs/router/testing/e2e";
66
+ import { expect, test } from "@playwright/test";
67
+ import { assertCacheStatus, createRangoE2E } from "@rangojs/router/testing/e2e";
68
+
69
+ const { parityDescribe } = createRangoE2E({ test, expect });
67
70
 
68
71
  parityDescribe("product page caches", (f) => {
69
72
  test("second request is a hit", async ({ page }) => {
@@ -21,6 +21,7 @@ RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter
21
21
  | `basename` | `string` | `createRouter({ basename })` value. Wired into `NavigationProvider` so `useRouter().basename`, `<Link>` prefixing, `useMount`/`useHref` resolve against the mount. Normalized like `createRouter`. Defaults to root. |
22
22
  | `mount` | `string` | `include()` mount prefix. Wraps the segment chain in a `MountContext` so `useMount()` returns the prefix. Normalized like a path prefix. Defaults to `"/"`. |
23
23
  | `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape) to wrap the tree in a `ThemeProvider`. Defaults to no provider. A component calling `useTheme()` REQUIRES one. |
24
+ | `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). |
24
25
 
25
26
  `RenderRouteSpec = { path, Component, layout?, loaderIds?, name? }` — one node of the route definition. The array is the layout chain root-to-leaf; the LAST entry is the leaf route (its pattern is matched against `request` to extract params; layout patterns are informational). `loaderIds` attaches seeded loaders to THIS node's segment; `layout` on the leaf wraps it; `name` is informational.
26
27
 
@@ -36,6 +37,7 @@ RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter
36
37
  | `useRouter` | The router handle, including `.basename`. |
37
38
  | `usePathname` | Current committed pathname. |
38
39
  | `useSearchParams` | Search params from the `request` URL. |
40
+ | `useNonce` | SEEDED CSP nonce (`options.nonce`), else `undefined` (the browser default). |
39
41
  | `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). |
40
42
  | `useLocationState` | SEEDED `history.state` value. |
41
43
  | `useHandle` | SEEDED handle output (globally accumulated). |
@@ -49,7 +49,7 @@ This is full-stack: the harness builds and serves your real app (`pnpm dev` or `
49
49
 
50
50
  `createRangoE2E(...)` -> `RangoE2E`:
51
51
 
52
- - `useFixture(options)` -> `Fixture` (`{ mode, root, url(path?), proc() }`). `url(path)` resolves against the running server.
52
+ - `useFixture(options)` -> `Fixture` (`{ mode, root, url(url?), proc() }`). `url(path)` resolves against the running server.
53
53
  - `parityDescribe(name, (f) => { ... }, options?)` -> registers a dev describe `name` AND a production describe `` `${name} (production)` ``. Body runs once per describe with that describe's `Fixture`.
54
54
  - `expectParity(page, intent, opts) => Promise<void>` — runs `intent` over the JS page and a fresh no-JS context, asserts observed testids' text + pathname/search/hash + `document.cookie` are equal. `opts` is the required `observe` plus optional `baseURL`, `waitFor`, and `ignoreCookies` (the rango state cookie is excluded automatically).
55
55
  - `rangoMatchers` — `{ toHaveRangoPathname }` only (pass to `expect.extend`).
@@ -24,15 +24,14 @@
24
24
 
25
25
  A request context is active for the whole render, so an async Server Component can read it via `getRequestContext()` / the router's server APIs. The notable surfaces seeded from the options above:
26
26
 
27
- | Field | Type | Meaning |
28
- | ----------- | ----------------------------------------- | --------------------------------------------------------------------- |
29
- | `request` | `Request` | The backing request (from `request`/`headers`). |
30
- | `url` | `URL` | The request URL. |
31
- | `env` | `unknown` | Env / bindings (from `env`). |
32
- | `params` | `Record<string, string>` | Route params (from `params`). |
33
- | `routeName` | `string \| undefined` | Matched route name (from `routeName`). |
34
- | `get` | `<T>(v: ContextVar<T>) => T \| undefined` | Read a var seeded via `vars` (by `createVar()` handle or string key). |
35
- | `cookies` | reader | Cookies parsed from the request's Cookie header. |
27
+ | Field | Type | Meaning |
28
+ | ----------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------- |
29
+ | `request` | `Request` | The backing request (from `request`/`headers`). |
30
+ | `url` | `URL` | The request URL. |
31
+ | `env` | `unknown` | Env / bindings (from `env`). |
32
+ | `params` | `Record<string, string>` | Route params (from `params`). |
33
+ | `routeName` | `string \| undefined` | Matched route name (from `routeName`). |
34
+ | `get` | `<T>(v: ContextVar<T>) => T \| undefined; <K extends string>(key: K): any` | Read a var seeded via `vars` (by `createVar()` handle or string key). |
36
35
 
37
36
  ### Returns — `Promise<string>`
38
37
 
@@ -39,7 +39,7 @@ A Rango route handler is a pure function `(ctx) => rsc` — the function you pas
39
39
  | `get` | `(Var) => value` | Read a seeded `vars` variable. |
40
40
  | `headers` | `Headers` | Response headers; set via `ctx.headers.set(...)` (merged into `result.response`). |
41
41
  | `setLocationState` | `(entries) => void` | Set location state (surfaced on `result.locationState`). |
42
- | `waitUntil` | `(promise) => void` | Register background work. |
42
+ | `waitUntil` | `(fn: () => Promise<void>) => void` | Register background work. |
43
43
 
44
44
  ### Returns — `RenderHandlerResult`
45
45
 
@@ -82,7 +82,7 @@ describe("api routes via dispatch", () => {
82
82
 
83
83
  - Hitting a COMPONENT (RSC) route throws a clear directive error: `dispatch` is for response routes + redirects + 404 + content negotiation, plus the global + route-level middleware guard stack on RESPONSE routes — it never renders React. Use Flight primitives or e2e to exercise component rendering.
84
84
  - A COMPONENT route's guard stack cannot run here. Assert it at e2e, or extract the middleware fn and unit-test it with `runMiddleware` (see `./middleware.md`).
85
- - JSON serialization is bare, applied in `response-route-handler.ts`: a `path.json` handler that returns a value is serialized verbatim (`JSON.stringify(value)`, status 200, `application/json`) — no envelope. Returning a `Response` (e.g. `Response.json(x)`) passes through unchanged. A thrown error yields an RFC 9457 problem+json body `{ title, status, detail, code }` (`application/problem+json`) with the error's status (`RouterError.status`, else 500, or the effective `ctx.setStatus()` override); `code` is the `RouterError.code`, else `"INTERNAL"`. The `type` member is omitted this phase. Assert the shape matching what your handler returns.
85
+ - JSON serialization is bare, applied in `response-route-handler.ts`: a `path.json` handler that returns a value is serialized verbatim (`JSON.stringify(value)`, status 200, `application/json`) — no envelope. Returning a `Response` (e.g. `Response.json(x)`) passes through unchanged. A thrown error yields an RFC 9457 problem+json body `{ title, status, detail, code }` (`application/problem+json`) with the error's status (`RouterError.status`, else 500, or a non-200 `ctx.res.status` already set upstream in the request pipeline); `code` is the `RouterError.code`, else `"INTERNAL"`. The `type` member is omitted this phase. Assert the shape matching what your handler returns.
86
86
  - Setup: needs the preset (alias + virtual stubs) or a Vite-RSC env (see `./setup.md`); a bare router import throws on Vite virtuals.
87
87
  - A router using `Prerender()`/`createLoader()`/`Static()` now constructs in a bare test (each assigns a runtime fallback `$$id`). Importing the whole router _file_ may still need the plugin (its page modules pull app deps / `virtual:` modules) — build from a focused include (your API routes) for whole-router dispatch.
88
88
  - A `_rsc_partial` request to a response route runs global middleware first (an auth gate can still 401/redirect), then returns `X-RSC-Reload` — route-level middleware is skipped, exactly like production.
@@ -28,17 +28,17 @@
28
28
 
29
29
  `fn` receives `ctx`, the full entered `RequestContext`; the same object resolves via `getRequestContext()` inside `fn`. Notable fields:
30
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. |
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
42
 
43
43
  ### Returns — `RunInRequestContextResult<T>`
44
44
 
@@ -53,6 +53,7 @@ import { defineConfig } from "vitest/config";
53
53
  import {
54
54
  rangoTestAliases,
55
55
  rangoUseClientTransform,
56
+ rangoInlineDeps,
56
57
  } from "@rangojs/router/testing/vitest";
57
58
 
58
59
  // Production React in this process AND any forked worker (forks inherit env).
@@ -69,6 +70,8 @@ export default defineConfig({
69
70
  include: ["**/*.rsc-test.{ts,tsx}"],
70
71
  pool: "forks",
71
72
  execArgv: ["--conditions=react-server"], // or React throws "react-server condition must be enabled"
73
+ // Required for an installed consumer on Node >= 23 (rango ships TS source).
74
+ server: { deps: { inline: rangoInlineDeps } },
72
75
  },
73
76
  });
74
77
  ```
@@ -32,8 +32,9 @@ is only needed when you want the richer `typeof router.routeMap` shape
32
32
  available globally.
33
33
 
34
34
  - `GeneratedRouteMap` — auto-registered by `router.named-routes.gen.ts`
35
- Use for `Handler<"name">`, `Prerender<"name">`, server `ctx.reverse()`,
36
- and named-route param/search inference.
35
+ Use for `Handler<"name">` (type annotation), `Prerender<"name">(...)` (function
36
+ call with type arg for param inference), server `ctx.reverse()`, and
37
+ named-route param/search inference.
37
38
  - `typeof router.routeMap` — the real merged route map from your router
38
39
  instance, including response-route metadata such as `{ path, response }`.
39
40
  - `RegisteredRoutes` — manual global hook for exposing `typeof router.routeMap`