@rangojs/router 0.9.0 → 0.10.1
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 +1 -1
- package/LICENSE +21 -0
- package/README.md +17 -15
- package/dist/types/cache/cf/cf-cache-constants.d.ts +1 -1
- package/dist/types/cache/cf/cf-cache-store.d.ts +1 -1
- package/dist/types/outlet-context.d.ts +9 -0
- package/dist/types/outlet-provider.d.ts +2 -1
- package/dist/types/route-content-wrapper.d.ts +4 -1
- package/dist/types/rsc/nonce.d.ts +10 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
- package/dist/types/rsc/types.d.ts +6 -2
- package/dist/types/ssr/index.d.ts +18 -0
- package/dist/types/ssr/ssr-root.d.ts +16 -0
- package/dist/types/ssr-suspension-warning.d.ts +20 -0
- package/dist/types/types/loader-types.d.ts +5 -1
- package/dist/types/types/segments.d.ts +17 -0
- package/dist/types/vite/discovery/state.d.ts +6 -0
- package/dist/types/vite/plugin-types.d.ts +27 -0
- package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
- package/dist/types/vite/utils/shared-utils.d.ts +1 -0
- package/dist/vite/index.js +56 -26
- package/package.json +26 -26
- package/skills/bundle-analysis/SKILL.md +1 -1
- package/skills/loader/SKILL.md +17 -2
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/bindings.md +1 -1
- package/skills/testing/cache-prerender.md +1 -1
- package/skills/testing/client-components.md +1 -1
- package/skills/testing/e2e-parity.md +1 -1
- package/skills/testing/flight.md +1 -1
- package/skills/testing/handles.md +1 -1
- package/skills/testing/loader.md +1 -1
- package/skills/testing/middleware.md +1 -1
- package/skills/testing/render-handler.md +1 -1
- package/skills/testing/response-routes.md +1 -1
- package/skills/testing/reverse-and-types.md +1 -1
- package/skills/testing/server-actions.md +1 -1
- package/skills/testing/server-tree.md +1 -1
- package/skills/testing/setup.md +1 -1
- package/src/browser/merge-segment-loaders.ts +4 -0
- package/src/cache/cache-runtime.ts +2 -2
- package/src/cache/cf/cf-cache-constants.ts +1 -1
- package/src/cache/cf/cf-cache-store.ts +1 -1
- package/src/client.tsx +2 -0
- package/src/outlet-context.ts +9 -0
- package/src/outlet-provider.tsx +4 -0
- package/src/route-content-wrapper.tsx +7 -0
- package/src/router/segment-resolution/fresh.ts +48 -17
- package/src/router.ts +7 -2
- package/src/rsc/handler.ts +4 -4
- package/src/rsc/nonce.ts +16 -0
- package/src/rsc/shell-capture-constants.ts +1 -1
- package/src/rsc/shell-capture.ts +41 -8
- package/src/rsc/types.ts +6 -2
- package/src/segment-system.tsx +57 -12
- package/src/ssr/index.tsx +53 -1
- package/src/ssr/ssr-root.tsx +50 -22
- package/src/ssr-suspension-warning.ts +68 -0
- package/src/types/loader-types.ts +5 -1
- package/src/types/segments.ts +17 -0
- package/src/use-loader.tsx +24 -1
- package/src/vite/discovery/shell-prerender-phase.ts +5 -0
- package/src/vite/discovery/state.ts +6 -0
- package/src/vite/plugin-types.ts +28 -0
- package/src/vite/plugins/virtual-entries.ts +26 -3
- package/src/vite/rango.ts +3 -0
- package/src/vite/router-discovery.ts +31 -19
- package/src/vite/utils/shared-utils.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -9,15 +9,15 @@
|
|
|
9
9
|
"rsc",
|
|
10
10
|
"vite"
|
|
11
11
|
],
|
|
12
|
-
"homepage": "https://github.com/
|
|
12
|
+
"homepage": "https://github.com/rangojs/rango#readme",
|
|
13
13
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/
|
|
14
|
+
"url": "https://github.com/rangojs/rango/issues"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"author": "Ivo Todorov",
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
20
|
-
"url": "git+https://github.com/
|
|
20
|
+
"url": "git+https://github.com/rangojs/rango.git",
|
|
21
21
|
"directory": "packages/rangojs-router"
|
|
22
22
|
},
|
|
23
23
|
"bin": {
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"dist",
|
|
33
33
|
"skills",
|
|
34
34
|
"AGENTS.md",
|
|
35
|
-
"README.md"
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
36
37
|
],
|
|
37
38
|
"type": "module",
|
|
38
39
|
"exports": {
|
|
@@ -176,20 +177,6 @@
|
|
|
176
177
|
"access": "public",
|
|
177
178
|
"tag": "latest"
|
|
178
179
|
},
|
|
179
|
-
"scripts": {
|
|
180
|
-
"build": "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
181
|
-
"build:types": "rm -rf dist/types && pnpm exec tsc -p tsconfig.types.json",
|
|
182
|
-
"prepublishOnly": "pnpm build",
|
|
183
|
-
"typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
|
|
184
|
-
"test": "playwright test",
|
|
185
|
-
"test:preload": "playwright test --config=playwright.preload.config.ts",
|
|
186
|
-
"test:ui": "playwright test --ui",
|
|
187
|
-
"test:hmr-local": "playwright test --project=dev-warmup --project=hmr-basename --project=hmr-prerender --no-deps --workers=1 && RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
|
|
188
|
-
"test:hmr-routes-local": "RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
|
|
189
|
-
"test:unit": "pnpm run build:types && vitest run",
|
|
190
|
-
"test:unit:watch": "vitest",
|
|
191
|
-
"test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
|
|
192
|
-
},
|
|
193
180
|
"dependencies": {
|
|
194
181
|
"@types/debug": "^4.1.12",
|
|
195
182
|
"@vitejs/plugin-rsc": "^0.5.31",
|
|
@@ -206,18 +193,18 @@
|
|
|
206
193
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
207
194
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
208
195
|
"@playwright/test": "^1.49.1",
|
|
209
|
-
"@shared/e2e": "workspace:*",
|
|
210
196
|
"@testing-library/dom": "^10.4.1",
|
|
211
197
|
"@testing-library/react": "^16.3.2",
|
|
212
198
|
"@types/node": "^24.10.1",
|
|
213
|
-
"@types/react": "
|
|
214
|
-
"@types/react-dom": "
|
|
199
|
+
"@types/react": "^19.2.7",
|
|
200
|
+
"@types/react-dom": "^19.2.3",
|
|
215
201
|
"esbuild": "^0.28.1",
|
|
216
202
|
"happy-dom": "^20.10.1",
|
|
217
203
|
"jiti": "^2.7.0",
|
|
218
|
-
"react": "
|
|
219
|
-
"react-dom": "
|
|
220
|
-
"vitest": "^4.1.9"
|
|
204
|
+
"react": "^19.2.8",
|
|
205
|
+
"react-dom": "^19.2.8",
|
|
206
|
+
"vitest": "^4.1.9",
|
|
207
|
+
"@shared/e2e": "0.0.1"
|
|
221
208
|
},
|
|
222
209
|
"peerDependencies": {
|
|
223
210
|
"@cloudflare/vite-plugin": "^1.42.1",
|
|
@@ -256,5 +243,18 @@
|
|
|
256
243
|
},
|
|
257
244
|
"engines": {
|
|
258
245
|
"node": ">=24.0.0"
|
|
246
|
+
},
|
|
247
|
+
"scripts": {
|
|
248
|
+
"build": "pnpm run build:types && pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
249
|
+
"build:types": "rm -rf dist/types && pnpm exec tsc -p tsconfig.types.json",
|
|
250
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
|
|
251
|
+
"test": "playwright test",
|
|
252
|
+
"test:preload": "playwright test --config=playwright.preload.config.ts",
|
|
253
|
+
"test:ui": "playwright test --ui",
|
|
254
|
+
"test:hmr-local": "playwright test --project=dev-warmup --project=hmr-basename --project=hmr-prerender --no-deps --workers=1 && RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
|
|
255
|
+
"test:hmr-routes-local": "RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
|
|
256
|
+
"test:unit": "pnpm run build:types && vitest run",
|
|
257
|
+
"test:unit:watch": "vitest",
|
|
258
|
+
"test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
|
|
259
259
|
}
|
|
260
|
-
}
|
|
260
|
+
}
|
|
@@ -150,7 +150,7 @@ The treemap shows nested boxes; box area = uncompressed size. Hover for gzip/bro
|
|
|
150
150
|
|
|
151
151
|
## Reporting Rango regressions
|
|
152
152
|
|
|
153
|
-
If a finding looks like a `@rangojs/router` regression (the framework is shipping more than it should, not your app), open an issue at the [@rangojs/router GitHub](https://github.com/
|
|
153
|
+
If a finding looks like a `@rangojs/router` regression (the framework is shipping more than it should, not your app), open an issue at the [@rangojs/router GitHub](https://github.com/rangojs/rango/issues) and include:
|
|
154
154
|
|
|
155
155
|
- The output of `client.html` / `rsc.html` (screenshots or the JSON `data = {...}` block from the HTML).
|
|
156
156
|
- The `@rangojs/router` version (`pnpm why @rangojs/router`).
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -757,6 +757,18 @@ Response construction (real 404, no warm-up race). Client navigations stream
|
|
|
757
757
|
exactly as before. Scoped per LOADER: the flagged loader awaits only itself;
|
|
758
758
|
siblings keep streaming.
|
|
759
759
|
|
|
760
|
+
Delivery is in-place, not merely in-document. React's Fizz outlines any
|
|
761
|
+
COMPLETED Suspense boundary over ~500 bytes to an end-of-stream
|
|
762
|
+
`<div hidden>` + `$RC()` reveal once the shell saturates its 12800-byte
|
|
763
|
+
`progressiveChunkSize` budget — the bytes are in the HTML, but not at their
|
|
764
|
+
document position, and a consumer that doesn't execute scripts never sees
|
|
765
|
+
them revealed. When the matched chain has a flagged loader, the document
|
|
766
|
+
render raises the budget to `MAX_SAFE_INTEGER` automatically, so the awaited
|
|
767
|
+
content renders where it belongs. Pin the budget yourself (in either
|
|
768
|
+
direction) with `rango({ progressiveChunkSize })` in the Vite config — an
|
|
769
|
+
explicit value disables the auto-raise. Boundaries hoisting stylesheets
|
|
770
|
+
still outline; their reveal must wait for the CSS.
|
|
771
|
+
|
|
760
772
|
The costs and constraints:
|
|
761
773
|
|
|
762
774
|
- Every document load pays the flagged loader's latency before first byte.
|
|
@@ -766,8 +778,11 @@ The costs and constraints:
|
|
|
766
778
|
document render awaits the loader before the render barrier resolves, so
|
|
767
779
|
that wait is a cycle by construction; it throws a deadlock error naming the
|
|
768
780
|
fix.
|
|
769
|
-
- PPR capture
|
|
770
|
-
|
|
781
|
+
- PPR capture is the BAKE lane for flagged loaders (`/ppr`): the capture
|
|
782
|
+
render awaits them too, and the settled result — handle pushes included —
|
|
783
|
+
freezes into the stored shell. Unflagged loaders stay masked as live
|
|
784
|
+
holes. The `progressiveChunkSize` auto-raise is live-document only;
|
|
785
|
+
captured shells outline per the explicit option or React's default.
|
|
771
786
|
|
|
772
787
|
Also available in `clientUrls()` route groups (`/client-urls`), where the
|
|
773
788
|
loader-heavy shape makes it most useful. `LoaderOptions` is exported from the
|
package/skills/testing/SKILL.md
CHANGED
|
@@ -32,7 +32,7 @@ index.rsc.ts` alias are all in [`./setup.md`](./setup.md). Read it before writin
|
|
|
32
32
|
> [`./bindings.md`](./bindings.md).
|
|
33
33
|
|
|
34
34
|
For the long-form prose guide (setup walkthrough + migration), see
|
|
35
|
-
[`docs/testing.md`](https://github.com/
|
|
35
|
+
[`docs/testing.md`](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md)
|
|
36
36
|
(the `docs/` directory is not shipped in the published package, so this is an
|
|
37
37
|
absolute link).
|
|
38
38
|
|
|
@@ -100,4 +100,4 @@ describe("bindings seam", () => {
|
|
|
100
100
|
|
|
101
101
|
- (cross-cutting)
|
|
102
102
|
- Siblings: `./loader.md`, `./middleware.md`, `./server-actions.md`
|
|
103
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
103
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "What these primitives deliberately don't cover (the platform-bindings paragraph)"
|
|
@@ -169,4 +169,4 @@ expect(await store.getShell(key)).not.toBeNull();
|
|
|
169
169
|
|
|
170
170
|
- `/caching`, `/prerender`, `/use-cache`, `/ppr` — the DSL this tests
|
|
171
171
|
- Siblings: [`./e2e-parity.md`](./e2e-parity.md), [`./response-routes.md`](./response-routes.md)
|
|
172
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
172
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "Cache, SWR, and prerender"
|
|
@@ -128,4 +128,4 @@ it("resolves params + reverse + Outlet through the layout chain", async () => {
|
|
|
128
128
|
|
|
129
129
|
- `/hooks` — the DSL this tests
|
|
130
130
|
- Siblings: `./handles.md`, `./reverse-and-types.md`, `./render-handler.md`, `./e2e-parity.md`
|
|
131
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
131
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "Reverse and components" (and the "Catch: streaming `use(promise)` Suspense content" subsection)
|
|
@@ -122,4 +122,4 @@ A `submit` intent does NOT replay against a snapshot of the server — it submit
|
|
|
122
122
|
|
|
123
123
|
- `/hooks`, `/view-transitions` — the DSL this tests
|
|
124
124
|
- Siblings: [`./cache-prerender.md`](./cache-prerender.md), [`./client-components.md`](./client-components.md)
|
|
125
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
125
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "E2E with dev/prod and PE parity" (and "Running a subset locally")
|
package/skills/testing/flight.md
CHANGED
|
@@ -88,4 +88,4 @@ it("snapshots the normalized payload", async () => {
|
|
|
88
88
|
|
|
89
89
|
- `/route` — the DSL this tests
|
|
90
90
|
- Siblings: `./setup.md`, `./server-tree.md`, `./render-handler.md`
|
|
91
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
91
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "renderToFlightString — real async Server Components"
|
|
@@ -150,4 +150,4 @@ it("renders the seeded trail (seed the INPUT pushes, the collect runs)", async (
|
|
|
150
150
|
|
|
151
151
|
- `/handler-use`, `/breadcrumbs` — the DSL this tests
|
|
152
152
|
- Siblings: `./loader.md`, `./client-components.md`, `./render-handler.md`
|
|
153
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
153
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "Testing a handle's collect/accumulator"
|
package/skills/testing/loader.md
CHANGED
|
@@ -127,4 +127,4 @@ it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
|
|
|
127
127
|
|
|
128
128
|
- `/loader` — the DSL this tests
|
|
129
129
|
- Siblings: `./handles.md`, `./reverse-and-types.md`, `./bindings.md`, `./server-actions.md`
|
|
130
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
130
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "Loaders — the raw body or a registered createLoader"
|
|
@@ -96,4 +96,4 @@ Pass an array to run several in order. Cookies set inside middleware via the sta
|
|
|
96
96
|
|
|
97
97
|
- `/middleware` — the DSL this tests
|
|
98
98
|
- Siblings: `./response-routes.md`, `./server-actions.md`, `./loader.md`, `./bindings.md`
|
|
99
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
99
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "Middleware"
|
|
@@ -119,4 +119,4 @@ it("asserts the client-cache directives", async () => {
|
|
|
119
119
|
|
|
120
120
|
- `/route` — the DSL this tests
|
|
121
121
|
- Siblings: [`./server-tree.md`](./server-tree.md), [`./server-actions.md`](./server-actions.md), [`./setup.md`](./setup.md), [`./loader.md`](./loader.md)
|
|
122
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
122
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "renderHandler — run a real route handler and assert its RSC"
|
|
@@ -92,4 +92,4 @@ describe("api routes via dispatch", () => {
|
|
|
92
92
|
|
|
93
93
|
- `/response-routes`, `/mime-routes` — the DSL this tests
|
|
94
94
|
- Siblings: `./middleware.md`, `./setup.md`, `./cache-prerender.md`
|
|
95
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
95
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "dispatch — request to Response" (the `rangoTestConfig` preset stubs `@vitejs/plugin-rsc/rsc`, so no per-file `vi.mock` is needed)
|
|
@@ -82,4 +82,4 @@ For a large type-only suite, collect recipe-1/2 assertions in `*.test-d.ts` file
|
|
|
82
82
|
|
|
83
83
|
- `/typesafety`, `/links` — the DSL this tests
|
|
84
84
|
- Siblings: `./client-components.md`, `./loader.md`
|
|
85
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
85
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "Type-level tests — make misuse fail to compile"
|
|
@@ -104,4 +104,4 @@ it("asserts the client-cache directives an action issued", async () => {
|
|
|
104
104
|
|
|
105
105
|
- `/server-actions` — the DSL this tests
|
|
106
106
|
- Siblings: `./render-handler.md`, `./middleware.md`, `./loader.md`, `./bindings.md`
|
|
107
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
107
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "runInRequestContext — the handler / server-action test primitive"
|
|
@@ -125,4 +125,4 @@ it("asserts the server-rendered host content", async () => {
|
|
|
125
125
|
|
|
126
126
|
- `/route` — the DSL this tests
|
|
127
127
|
- Siblings: `./flight.md`, `./render-handler.md`, `./setup.md`
|
|
128
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
128
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "renderServerTree — serialize then deserialize to an inspectable tree" (and the "findElements / textContent" subsection)
|
package/skills/testing/setup.md
CHANGED
|
@@ -120,4 +120,4 @@ Scripts:
|
|
|
120
120
|
|
|
121
121
|
- (cross-cutting)
|
|
122
122
|
- Siblings: `./flight.md`, `./server-tree.md`, `./render-handler.md`, `./response-routes.md`
|
|
123
|
-
- Long-form prose: [docs/testing.md](https://github.com/
|
|
123
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/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")
|
|
@@ -45,6 +45,10 @@ export function mergeSegmentLoaders(
|
|
|
45
45
|
}),
|
|
46
46
|
// Keep all loader IDs from cache
|
|
47
47
|
loaderIds: fromCache.loaderIds,
|
|
48
|
+
// The rebuilt aggregate is the source of truth. Cached per-loader
|
|
49
|
+
// streams would make LoaderResolver ignore loaderDataPromise.
|
|
50
|
+
loaderStreams: undefined,
|
|
51
|
+
awaitedLoaderIds: undefined,
|
|
48
52
|
};
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -207,8 +207,8 @@ interface CacheEnvelope {
|
|
|
207
207
|
* leader (function threw, or the result was not serializable) propagates to
|
|
208
208
|
* current waiters, which then retry fresh.
|
|
209
209
|
*
|
|
210
|
-
* A leader that NEVER settles must not hang followers (scar tissue,
|
|
211
|
-
* pilot
|
|
210
|
+
* A leader that NEVER settles must not hang followers (scar tissue, production
|
|
211
|
+
* pilot incident): a background shell capture's render became leader, awaited a
|
|
212
212
|
* tarpitting upstream fetch, and workerd killed the capture's waitUntil context
|
|
213
213
|
* — orphaning the leader promise as permanently pending, its map entry never
|
|
214
214
|
* cleared. Every later document render calling the same cached function (an
|
|
@@ -86,7 +86,7 @@ export const MAX_REVALIDATION_INTERVAL = 30;
|
|
|
86
86
|
* This is the default; override per store via
|
|
87
87
|
* `CFCacheStoreOptions.edgeLookupTimeoutMs` (<= 0 disables the budget).
|
|
88
88
|
*
|
|
89
|
-
* 25ms, raised from 10:
|
|
89
|
+
* 25ms, raised from 10: Workers logs (production pilot) showed the
|
|
90
90
|
* 10ms budget firing frequently on cold colos where the first Cache API touch
|
|
91
91
|
* is slow but healthy — each false positive downgrades a warm L1 HIT to an
|
|
92
92
|
* L2/render round trip that costs far more than the 15ms of extra patience.
|
|
@@ -2383,7 +2383,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2383
2383
|
* this, kv.put/get reject with `414 ... exceeds key length limit of 512`
|
|
2384
2384
|
* and the entry silently never reaches L2 (observed in production for
|
|
2385
2385
|
* "use cache" items whose serialized args — e.g. a CMS query object — blow
|
|
2386
|
-
* the cap;
|
|
2386
|
+
* the cap; production pilot). Keys are opaque storage identifiers, so
|
|
2387
2387
|
* normalization is semantics-preserving as long as distinct logical keys
|
|
2388
2388
|
* stay distinct: colliding requires an identical 400-byte prefix AND a
|
|
2389
2389
|
* 128-bit SHA-256 collision. Deterministic, so every family's read, write,
|
package/src/client.tsx
CHANGED
|
@@ -49,6 +49,8 @@ function renderSlotContent(segment: ResolvedSegment | null): ReactNode {
|
|
|
49
49
|
<LoaderBoundary
|
|
50
50
|
loaderDataPromise={segment.loaderDataPromise!}
|
|
51
51
|
loaderIds={segment.loaderIds!}
|
|
52
|
+
loaderStreams={segment.loaderStreams}
|
|
53
|
+
awaitedLoaderIds={segment.awaitedLoaderIds}
|
|
52
54
|
fallback={segment.loading}
|
|
53
55
|
outletKey={segment.id + "-loader"}
|
|
54
56
|
outletContent={null}
|
package/src/outlet-context.ts
CHANGED
|
@@ -18,6 +18,15 @@ export interface OutletContextValue {
|
|
|
18
18
|
* the resolved `loaderData` record.
|
|
19
19
|
*/
|
|
20
20
|
loaderStreams?: Record<string, unknown>;
|
|
21
|
+
/**
|
|
22
|
+
* Loader $$ids on this segment that the render awaited before first flush
|
|
23
|
+
* (loader(Def, { ssr: false })). Dev-diagnostic input only: useLoader uses
|
|
24
|
+
* it to warn when a read still suspends during SSR on a render that awaited
|
|
25
|
+
* flagged loaders (ssr-suspension-warning.ts). Present only on streaming
|
|
26
|
+
* lanes with flagged loaders (document and shell-capture renders); absent
|
|
27
|
+
* on forceAwait/action lanes.
|
|
28
|
+
*/
|
|
29
|
+
awaitedLoaderIds?: readonly string[];
|
|
21
30
|
parent?: OutletContextValue | null;
|
|
22
31
|
/** Loading component for Suspense fallback (from segment's loading() definition) */
|
|
23
32
|
loading?: ReactNode;
|
package/src/outlet-provider.tsx
CHANGED
|
@@ -13,6 +13,7 @@ export function OutletProvider({
|
|
|
13
13
|
segment,
|
|
14
14
|
loaderData,
|
|
15
15
|
loaderStreams,
|
|
16
|
+
awaitedLoaderIds,
|
|
16
17
|
pending = false,
|
|
17
18
|
children,
|
|
18
19
|
}: {
|
|
@@ -21,6 +22,7 @@ export function OutletProvider({
|
|
|
21
22
|
segment?: ResolvedSegment;
|
|
22
23
|
loaderData?: Record<string, any>;
|
|
23
24
|
loaderStreams?: Record<string, unknown>;
|
|
25
|
+
awaitedLoaderIds?: readonly string[];
|
|
24
26
|
pending?: boolean;
|
|
25
27
|
children: ReactNode;
|
|
26
28
|
}): ReactNode {
|
|
@@ -34,6 +36,7 @@ export function OutletProvider({
|
|
|
34
36
|
segment,
|
|
35
37
|
loaderData,
|
|
36
38
|
loaderStreams,
|
|
39
|
+
awaitedLoaderIds,
|
|
37
40
|
pending,
|
|
38
41
|
parent: parentContext,
|
|
39
42
|
loading: segment?.loading,
|
|
@@ -44,6 +47,7 @@ export function OutletProvider({
|
|
|
44
47
|
segment,
|
|
45
48
|
loaderData,
|
|
46
49
|
loaderStreams,
|
|
50
|
+
awaitedLoaderIds,
|
|
47
51
|
pending,
|
|
48
52
|
parentContext,
|
|
49
53
|
],
|
|
@@ -129,6 +129,9 @@ export interface LoaderBoundaryProps {
|
|
|
129
129
|
* the children; useLoader suspends per loader at the read site.
|
|
130
130
|
*/
|
|
131
131
|
loaderStreams?: Record<string, unknown>;
|
|
132
|
+
/** Dev-diagnostic input for the SSR suspension warning — see
|
|
133
|
+
* OutletContextValue.awaitedLoaderIds. Rides the streams lane only. */
|
|
134
|
+
awaitedLoaderIds?: readonly string[];
|
|
132
135
|
fallback?: ReactNode;
|
|
133
136
|
outletKey: string;
|
|
134
137
|
outletContent: ReactNode;
|
|
@@ -141,6 +144,7 @@ export function LoaderBoundary({
|
|
|
141
144
|
loaderDataPromise,
|
|
142
145
|
loaderIds,
|
|
143
146
|
loaderStreams,
|
|
147
|
+
awaitedLoaderIds,
|
|
144
148
|
fallback,
|
|
145
149
|
outletKey,
|
|
146
150
|
outletContent,
|
|
@@ -154,6 +158,7 @@ export function LoaderBoundary({
|
|
|
154
158
|
loaderDataPromise={loaderDataPromise}
|
|
155
159
|
loaderIds={loaderIds}
|
|
156
160
|
loaderStreams={loaderStreams}
|
|
161
|
+
awaitedLoaderIds={awaitedLoaderIds}
|
|
157
162
|
outletKey={outletKey}
|
|
158
163
|
outletContent={outletContent}
|
|
159
164
|
segment={segment}
|
|
@@ -187,6 +192,7 @@ function LoaderResolver({
|
|
|
187
192
|
loaderDataPromise,
|
|
188
193
|
loaderIds,
|
|
189
194
|
loaderStreams,
|
|
195
|
+
awaitedLoaderIds,
|
|
190
196
|
outletKey,
|
|
191
197
|
outletContent,
|
|
192
198
|
segment,
|
|
@@ -201,6 +207,7 @@ function LoaderResolver({
|
|
|
201
207
|
segment={segment}
|
|
202
208
|
parallel={parallel}
|
|
203
209
|
loaderStreams={loaderStreams}
|
|
210
|
+
awaitedLoaderIds={awaitedLoaderIds}
|
|
204
211
|
>
|
|
205
212
|
{children}
|
|
206
213
|
</OutletProvider>
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getParallelEntries,
|
|
12
12
|
getParallelSlotEntries,
|
|
13
13
|
type EntryData,
|
|
14
|
+
type LoaderEntry,
|
|
14
15
|
} from "../../server/context";
|
|
15
16
|
import type {
|
|
16
17
|
HandlerContext,
|
|
@@ -45,6 +46,37 @@ import {
|
|
|
45
46
|
latchPprHeaderScopeForEntries,
|
|
46
47
|
} from "../../server/context.js";
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Register flagged loader $$ids on the request context and return their
|
|
51
|
+
* indices. Must run BEFORE kickoff: rendered() checks the set to fail
|
|
52
|
+
* fast on the barrier cycle (resolution awaits the loader, the barrier
|
|
53
|
+
* awaits resolution).
|
|
54
|
+
*/
|
|
55
|
+
function registerAwaitBeforeFlushIds(loaderEntries: LoaderEntry[]): number[] {
|
|
56
|
+
const awaitedIndices: number[] = [];
|
|
57
|
+
for (let i = 0; i < loaderEntries.length; i++) {
|
|
58
|
+
if (loaderEntries[i]!.awaitBeforeFlush) awaitedIndices.push(i);
|
|
59
|
+
}
|
|
60
|
+
if (awaitedIndices.length > 0) {
|
|
61
|
+
const reqCtx = _getRequestContext();
|
|
62
|
+
if (reqCtx) {
|
|
63
|
+
reqCtx._awaitBeforeFlushLoaderIds ??= new Set();
|
|
64
|
+
for (const i of awaitedIndices) {
|
|
65
|
+
reqCtx._awaitBeforeFlushLoaderIds.add(loaderEntries[i]!.loader.$$id);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return awaitedIndices;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stampAwaitBeforeFlush(
|
|
73
|
+
loaderEntry: LoaderEntry,
|
|
74
|
+
): { awaitBeforeFlush: true } | Record<string, never> {
|
|
75
|
+
return loaderEntry.awaitBeforeFlush === true
|
|
76
|
+
? { awaitBeforeFlush: true as const }
|
|
77
|
+
: {};
|
|
78
|
+
}
|
|
79
|
+
|
|
48
80
|
// ---------------------------------------------------------------------------
|
|
49
81
|
// Fresh path (full match, no revalidation)
|
|
50
82
|
// ---------------------------------------------------------------------------
|
|
@@ -112,23 +144,12 @@ export async function resolveLoaders<TEnv>(
|
|
|
112
144
|
// the set to fail fast on the barrier cycle (segment resolution awaits the
|
|
113
145
|
// loader, the barrier awaits segment resolution, rendered() awaits the
|
|
114
146
|
// barrier), and the loader body can call rendered() before the await below
|
|
115
|
-
// is reached.
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
if (awaitedIndices.length > 0) {
|
|
124
|
-
const reqCtx = _getRequestContext();
|
|
125
|
-
if (reqCtx) {
|
|
126
|
-
reqCtx._awaitBeforeFlushLoaderIds ??= new Set();
|
|
127
|
-
for (const i of awaitedIndices) {
|
|
128
|
-
reqCtx._awaitBeforeFlushLoaderIds.add(loaderEntries[i]!.loader.$$id);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
147
|
+
// is reached.
|
|
148
|
+
//
|
|
149
|
+
// Shell-capture renders await too: flagged loaders BAKE at capture
|
|
150
|
+
// (loader-mask masks only LIVE-lane loaders). Capture must await them so
|
|
151
|
+
// bake-lane handle pushes land in the prelude.
|
|
152
|
+
const awaitedIndices = registerAwaitBeforeFlushIds(loaderEntries);
|
|
132
153
|
|
|
133
154
|
// Streaming loaders: promises kick off now, settle during RSC serialization.
|
|
134
155
|
const segments = loaderEntries.map((loaderEntry, i) => {
|
|
@@ -142,6 +163,10 @@ export async function resolveLoaders<TEnv>(
|
|
|
142
163
|
component: null,
|
|
143
164
|
params: ctx.params,
|
|
144
165
|
loaderId: loader.$$id,
|
|
166
|
+
// Stamped on document AND capture renders: segment-system's
|
|
167
|
+
// value-delivery await and the dev SSR-suspension diagnostic both
|
|
168
|
+
// key off it.
|
|
169
|
+
...stampAwaitBeforeFlush(loaderEntry),
|
|
145
170
|
loaderData: deps.wrapLoaderPromise(
|
|
146
171
|
runInsideLoaderScope(() =>
|
|
147
172
|
resolveLoaderData(
|
|
@@ -188,6 +213,11 @@ export async function resolveLoaders<TEnv>(
|
|
|
188
213
|
// collapsing the whole entry and discarding successful sibling data, and
|
|
189
214
|
// (2) leave the other in-flight raw promises without a .catch, producing
|
|
190
215
|
// unhandled rejections. Mirrors the loading path and intercept-resolution.
|
|
216
|
+
//
|
|
217
|
+
// Flagged loaders still get awaitBeforeFlush stamped: auto-raise and
|
|
218
|
+
// settled-value delivery key off the field, and this path already paid
|
|
219
|
+
// the pre-flush await.
|
|
220
|
+
registerAwaitBeforeFlushIds(loaderEntries);
|
|
191
221
|
const pendingLoaderData = loaderEntries.map((loaderEntry, i) => {
|
|
192
222
|
const { loader } = loaderEntry;
|
|
193
223
|
const segmentId = `${shortCode}D${i}.${loader.$$id}`;
|
|
@@ -224,6 +254,7 @@ export async function resolveLoaders<TEnv>(
|
|
|
224
254
|
component: null,
|
|
225
255
|
params: ctx.params,
|
|
226
256
|
loaderId: loader.$$id,
|
|
257
|
+
...stampAwaitBeforeFlush(loaderEntry),
|
|
227
258
|
loaderData: pending.wrapped,
|
|
228
259
|
belongsToRoute,
|
|
229
260
|
};
|
package/src/router.ts
CHANGED
|
@@ -1167,11 +1167,16 @@ export function createRouter<TEnv = any>(
|
|
|
1167
1167
|
| null = null;
|
|
1168
1168
|
|
|
1169
1169
|
return async (request: Request, input: RouterRequestInput<TEnv> = {}) => {
|
|
1170
|
+
const requestedDiscoveryEpoch = request.headers.get(
|
|
1171
|
+
DEV_DISCOVERY_PROBE_HEADER,
|
|
1172
|
+
);
|
|
1170
1173
|
if (
|
|
1171
1174
|
devDiscoveryEpoch !== undefined &&
|
|
1172
|
-
|
|
1173
|
-
String(devDiscoveryEpoch)
|
|
1175
|
+
requestedDiscoveryEpoch !== null
|
|
1174
1176
|
) {
|
|
1177
|
+
// A stale generation must still answer as a probe. Rendering the app
|
|
1178
|
+
// on an epoch mismatch makes the readiness loop overlap full renders
|
|
1179
|
+
// with workerd reloads and can exhaust the dev server heap.
|
|
1175
1180
|
return new Response(null, {
|
|
1176
1181
|
headers: {
|
|
1177
1182
|
[DEV_DISCOVERY_EPOCH_HEADER]: String(devDiscoveryEpoch),
|
package/src/rsc/handler.ts
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
handleResponseRoute,
|
|
46
46
|
type ResponseRouteMatch,
|
|
47
47
|
} from "./response-route-handler.js";
|
|
48
|
-
import {
|
|
48
|
+
import { nonce as nonceToken, resolveProviderNonce } from "./nonce.js";
|
|
49
49
|
import { VERSION } from "@rangojs/router:version";
|
|
50
50
|
import type { ErrorPhase } from "../types.js";
|
|
51
51
|
import type { RouterRequestInput } from "../router/router-interfaces.js";
|
|
@@ -456,12 +456,12 @@ export function createRSCHandler<
|
|
|
456
456
|
}
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
-
// Resolve nonce if provider is set
|
|
459
|
+
// Resolve nonce if provider is set. false/"" normalize to undefined —
|
|
460
|
+
// the per-request opt-out (resolveProviderNonce).
|
|
460
461
|
const nonceStart = performance.now();
|
|
461
462
|
let nonce: string | undefined;
|
|
462
463
|
if (nonceProvider) {
|
|
463
|
-
|
|
464
|
-
nonce = result === true ? generateNonce() : result;
|
|
464
|
+
nonce = resolveProviderNonce(await nonceProvider(request, env));
|
|
465
465
|
}
|
|
466
466
|
const nonceDur = performance.now() - nonceStart;
|
|
467
467
|
|
package/src/rsc/nonce.ts
CHANGED
|
@@ -25,6 +25,22 @@ import { createVar } from "../context-var.js";
|
|
|
25
25
|
*/
|
|
26
26
|
export const nonce: ContextVar<string> = createVar<string>();
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Normalize a NonceProvider return value to the nonce the request runs with.
|
|
30
|
+
* `true` auto-generates; `false` and the empty string are the per-request
|
|
31
|
+
* opt-out and normalize to undefined — without this, a falsy string would be
|
|
32
|
+
* threaded as-is into the render: never written to the token (`if (nonce)`)
|
|
33
|
+
* yet still `!== undefined` for the ppr shell gate (rsc-rendering.ts
|
|
34
|
+
* activeNonce), pinning a ppr route to axis 1 with no way to opt a request
|
|
35
|
+
* out while keeping the provider for the rest of the app.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveProviderNonce(
|
|
38
|
+
result: string | boolean,
|
|
39
|
+
): string | undefined {
|
|
40
|
+
if (result === true) return generateNonce();
|
|
41
|
+
return result === false || result === "" ? undefined : result;
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
/**
|
|
29
45
|
* Generate a cryptographic nonce for CSP.
|
|
30
46
|
* Returns a 16-byte random value encoded as base64.
|
|
@@ -30,7 +30,7 @@ export const SHELL_CAPTURE_MAX_WAIT_MS = 15_000;
|
|
|
30
30
|
* Hard cap on one capture TASK — runShellCapture end to end (both attempts +
|
|
31
31
|
* the in-place retry delay). SHELL_CAPTURE_MAX_WAIT_MS arms only inside
|
|
32
32
|
* captureShellHTML, AFTER the capture's router.match(); a handler wedged on a
|
|
33
|
-
* never-settling upstream await (
|
|
33
|
+
* never-settling upstream await (production pilot: a 30s+ tarpitting fetch)
|
|
34
34
|
* wedges the task with no deadline in force. The task's settle path releases
|
|
35
35
|
* the per-key stampede guard and the serialized capture-queue slot, so an
|
|
36
36
|
* unbounded task strands BOTH for the isolate's lifetime.
|