@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.
- package/AGENTS.md +8 -0
- package/README.md +43 -2
- package/dist/bin/rango.js +92 -16
- package/dist/vite/index.js +166 -70
- package/package.json +19 -18
- package/skills/breadcrumbs/SKILL.md +1 -1
- package/skills/bundle-analysis/SKILL.md +2 -2
- package/skills/cache-guide/SKILL.md +2 -2
- package/skills/caching/SKILL.md +16 -9
- package/skills/debug-manifest/SKILL.md +4 -2
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/handler-use/SKILL.md +1 -1
- package/skills/hooks/SKILL.md +2 -2
- package/skills/host-router/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +1 -1
- package/skills/loader/SKILL.md +2 -0
- package/skills/migrate-react-router/SKILL.md +4 -2
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/prerender/SKILL.md +2 -0
- package/skills/rango/SKILL.md +12 -11
- package/skills/response-routes/SKILL.md +2 -2
- package/skills/route/SKILL.md +4 -0
- package/skills/router-setup/SKILL.md +3 -0
- package/skills/scripts/SKILL.md +179 -0
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/bindings.md +20 -6
- package/skills/testing/cache-prerender.md +5 -2
- package/skills/testing/client-components.md +2 -0
- package/skills/testing/e2e-parity.md +1 -1
- package/skills/testing/flight.md +8 -9
- package/skills/testing/render-handler.md +1 -1
- package/skills/testing/response-routes.md +1 -1
- package/skills/testing/server-actions.md +11 -11
- package/skills/testing/setup.md +3 -0
- package/skills/typesafety/SKILL.md +3 -2
- package/skills/use-cache/SKILL.md +10 -9
- package/src/browser/event-controller.ts +109 -2
- package/src/browser/partial-update.ts +12 -0
- package/src/browser/prefetch/cache.ts +17 -0
- package/src/browser/prefetch/fetch.ts +69 -2
- package/src/browser/react/Link.tsx +30 -5
- package/src/browser/react/NavigationProvider.tsx +12 -2
- package/src/browser/react/location-state-shared.ts +14 -2
- package/src/browser/react/use-href.tsx +8 -1
- package/src/browser/react/use-link-status.ts +23 -2
- package/src/browser/response-adapter.ts +14 -3
- package/src/browser/rsc-router.tsx +3 -0
- package/src/browser/scroll-restoration.ts +8 -3
- package/src/browser/server-action-bridge.ts +46 -11
- package/src/browser/types.ts +6 -0
- package/src/build/generate-route-types.ts +0 -1
- package/src/build/route-trie.ts +33 -9
- package/src/build/route-types/include-resolution.ts +7 -1
- package/src/build/route-types/router-processing.ts +0 -6
- package/src/build/route-types/source-scan.ts +105 -7
- package/src/cache/cache-policy.ts +42 -8
- package/src/cache/cache-runtime.ts +65 -5
- package/src/cache/cache-scope.ts +71 -11
- package/src/cache/cache-tag.ts +7 -2
- package/src/cache/cf/cf-base64.ts +33 -0
- package/src/cache/cf/cf-cache-constants.ts +127 -0
- package/src/cache/cf/cf-cache-store.ts +85 -613
- package/src/cache/cf/cf-cache-types.ts +349 -0
- package/src/cache/cf/cf-kv-utils.ts +46 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
- package/src/cache/document-cache.ts +11 -0
- package/src/cache/handle-snapshot.ts +8 -1
- package/src/cache/profile-registry.ts +25 -1
- package/src/cache/segment-codec.ts +9 -1
- package/src/cache/types.ts +4 -0
- package/src/client.rsc.tsx +38 -0
- package/src/client.tsx +11 -0
- package/src/components/DefaultDocument.tsx +8 -2
- package/src/context-var.ts +1 -1
- package/src/decode-loader-results.ts +7 -1
- package/src/escape-script.ts +52 -0
- package/src/handles/MetaTags.tsx +56 -5
- package/src/handles/Scripts.tsx +183 -0
- package/src/handles/breadcrumbs.ts +29 -11
- package/src/handles/is-thenable.ts +19 -0
- package/src/handles/meta.ts +46 -0
- package/src/handles/script.ts +244 -0
- package/src/host/cookie-handler.ts +7 -3
- package/src/host/pattern-matcher.ts +16 -2
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +5 -0
- package/src/response-utils.ts +25 -0
- package/src/route-definition/dsl-helpers.ts +7 -0
- package/src/route-definition/redirect.ts +1 -2
- package/src/router/content-negotiation.ts +58 -10
- package/src/router/intercept-resolution.ts +9 -0
- package/src/router/match-middleware/cache-store.ts +10 -1
- package/src/router/middleware.ts +10 -3
- package/src/router/pattern-matching.ts +25 -23
- package/src/router/prefetch-cache-ttl.ts +51 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +23 -0
- package/src/router/segment-resolution/fresh.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +35 -1
- package/src/router/segment-resolution/loader-cache.ts +10 -6
- package/src/router/segment-resolution/revalidation.ts +6 -0
- package/src/router/segment-resolution.ts +1 -0
- package/src/router/trie-matching.ts +14 -9
- package/src/router.ts +18 -10
- package/src/rsc/handler.ts +52 -13
- package/src/rsc/helpers.ts +7 -1
- package/src/rsc/index.ts +1 -4
- package/src/rsc/loader-fetch.ts +107 -37
- package/src/rsc/progressive-enhancement.ts +18 -6
- package/src/rsc/response-cache-serve.ts +238 -0
- package/src/rsc/response-route-handler.ts +16 -133
- package/src/rsc/rsc-rendering.ts +13 -4
- package/src/rsc/server-action.ts +52 -6
- package/src/rsc/types.ts +7 -0
- package/src/search-params.ts +24 -5
- package/src/segment-loader-promise.ts +17 -2
- package/src/server/loader-registry.ts +16 -18
- package/src/server/request-context.ts +47 -20
- package/src/testing/dispatch.ts +108 -25
- package/src/testing/flight.ts +25 -0
- package/src/testing/internal/context.ts +25 -2
- package/src/testing/render-handler.ts +3 -1
- package/src/testing/render-route.tsx +15 -0
- package/src/testing/run-loader.ts +10 -3
- package/src/theme/ThemeProvider.tsx +20 -6
- package/src/theme/ThemeScript.tsx +7 -3
- package/src/theme/constants.ts +54 -3
- package/src/theme/theme-script.ts +22 -7
- package/src/types/request-scope.ts +8 -3
- package/src/vite/plugins/cjs-to-esm.ts +8 -1
- package/src/vite/plugins/expose-id-utils.ts +10 -1
- package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
- package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
- package/src/vite/plugins/expose-internal-ids.ts +0 -1
- package/src/vite/plugins/version-plugin.ts +5 -17
- package/src/vite/plugins/virtual-entries.ts +12 -2
- package/src/vite/rango.ts +15 -6
- package/src/vite/utils/ast-handler-extract.ts +11 -4
- package/src/vite/utils/directive-prologue.ts +40 -0
- 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
|
|
16
|
+
http://localhost:PORT/?__debug_manifest
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Returns formatted JSON
|
|
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(
|
package/skills/hooks/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
|
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(
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -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
|
|
603
|
+
errorBoundary(({ error }) => (
|
|
602
604
|
<div>
|
|
603
605
|
<h2>Something went wrong</h2>
|
|
604
|
-
<
|
|
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: */*` —
|
|
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)
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -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
|
-
| `/
|
|
229
|
-
| `/
|
|
230
|
-
| `/
|
|
231
|
-
| `/
|
|
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
|
|
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
|
|
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">;
|
package/skills/route/SKILL.md
CHANGED
|
@@ -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`.
|
package/skills/testing/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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 {
|
|
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(
|
|
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`).
|
package/skills/testing/flight.md
CHANGED
|
@@ -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
|
|
28
|
-
| ----------- |
|
|
29
|
-
| `request` | `Request`
|
|
30
|
-
| `url` | `URL`
|
|
31
|
-
| `env` | `unknown`
|
|
32
|
-
| `params` | `Record<string, string>`
|
|
33
|
-
| `routeName` | `string \| undefined`
|
|
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` | `(
|
|
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
|
|
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
|
|
32
|
-
| ------------------------------ |
|
|
33
|
-
| `env` | `TEnv`
|
|
34
|
-
| `request` | `Request`
|
|
35
|
-
| `cookies()` | `
|
|
36
|
-
| `get(token)` / `set(token, v)` | accessor
|
|
37
|
-
| `params` | `Record<string, string>`
|
|
38
|
-
| `reverse(name, params?)` | function
|
|
39
|
-
| `header(name, value)` | function
|
|
40
|
-
| `setLocationState(...)` | function
|
|
41
|
-
| `theme`/`setTheme` | —
|
|
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
|
|
package/skills/testing/setup.md
CHANGED
|
@@ -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"
|
|
36
|
-
|
|
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`
|