@rangojs/router 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +254 -89
- package/dist/testing/vitest.js +252 -0
- package/dist/types/browser/navigation-transaction.d.ts +6 -0
- package/dist/types/browser/partial-update.d.ts +11 -0
- package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
- package/dist/types/browser/rsc-router.d.ts +1 -1
- package/dist/types/browser/scroll-restoration.d.ts +7 -0
- package/dist/types/cache/cache-exec-scope.d.ts +18 -1
- package/dist/types/cache/cache-scope.d.ts +34 -5
- package/dist/types/cache/handle-capture.d.ts +41 -3
- package/dist/types/cache/handle-snapshot.d.ts +53 -13
- package/dist/types/cache/segment-codec.d.ts +10 -3
- package/dist/types/cache/taint.d.ts +1 -1
- package/dist/types/cache/types.d.ts +15 -3
- package/dist/types/client.d.ts +4 -4
- package/dist/types/client.rsc.d.ts +4 -5
- package/dist/types/components/DefaultDocument.d.ts +5 -2
- package/dist/types/context-var.d.ts +6 -5
- package/dist/types/escape-script.d.ts +1 -1
- package/dist/types/handles/MetaTags.d.ts +5 -4
- package/dist/types/handles/Scripts.d.ts +8 -7
- package/dist/types/handles/meta.d.ts +2 -2
- package/dist/types/handles/script.d.ts +17 -16
- package/dist/types/href-client.d.ts +1 -1
- package/dist/types/html.d.ts +12 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.rsc.d.ts +3 -2
- package/dist/types/loader-store.d.ts +7 -4
- package/dist/types/reverse.d.ts +36 -4
- package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
- package/dist/types/route-definition/helpers-types.d.ts +33 -9
- package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
- package/dist/types/route-types.d.ts +1 -1
- package/dist/types/router/debug-manifest.d.ts +3 -2
- package/dist/types/router/error-handling.d.ts +20 -0
- package/dist/types/router/handler-context.d.ts +6 -0
- package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
- package/dist/types/router/middleware-types.d.ts +3 -2
- package/dist/types/router/router-context.d.ts +3 -1
- package/dist/types/router/router-interfaces.d.ts +24 -2
- package/dist/types/router/router-options.d.ts +8 -5
- package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
- package/dist/types/router/segment-resolution/loader-cache.d.ts +37 -1
- package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +3 -3
- package/dist/types/router/segment-resolution/mask-nested.d.ts +7 -7
- package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
- package/dist/types/router/segment-wrappers.d.ts +1 -1
- package/dist/types/rsc/response-route-handler.d.ts +1 -1
- package/dist/types/rsc/rsc-rendering.d.ts +1 -2
- package/dist/types/rsc/shell-capture-constants.d.ts +8 -0
- package/dist/types/rsc/shell-capture.d.ts +5 -4
- package/dist/types/rsc/ssr-setup.d.ts +6 -6
- package/dist/types/rsc/types.d.ts +10 -0
- package/dist/types/server/context.d.ts +33 -3
- package/dist/types/server/handle-store.d.ts +26 -2
- package/dist/types/server/request-context.d.ts +24 -21
- package/dist/types/ssr/index.d.ts +15 -0
- package/dist/types/static-handler.d.ts +4 -1
- package/dist/types/testing/generated-routes.d.ts +2 -2
- package/dist/types/testing/internal/context.d.ts +1 -1
- package/dist/types/testing/render-route.d.ts +32 -6
- package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
- package/dist/types/testing/vitest.d.ts +35 -1
- package/dist/types/theme/ThemeScript.d.ts +3 -3
- package/dist/types/types/cache-types.d.ts +14 -10
- package/dist/types/types/handler-context.d.ts +14 -0
- package/dist/types/types/loader-types.d.ts +8 -6
- package/dist/types/urls/response-types.d.ts +3 -12
- package/dist/types/use-loader.d.ts +12 -3
- package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
- package/dist/types/vite/rango.d.ts +1 -1
- package/dist/vite/index.js +1381 -1346
- package/package.json +2 -2
- package/skills/api-client/SKILL.md +22 -10
- package/skills/breadcrumbs/SKILL.md +65 -22
- package/skills/bundle-analysis/SKILL.md +22 -22
- package/skills/cache-guide/SKILL.md +207 -97
- package/skills/caching/SKILL.md +295 -93
- package/skills/catalog.json +16 -16
- package/skills/client-urls/SKILL.md +53 -31
- package/skills/cloudflare/SKILL.md +72 -20
- package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
- package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
- package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
- package/skills/comparison/SKILL.md +5 -0
- package/skills/comparison/references/framework-comparison.md +39 -33
- package/skills/composability/SKILL.md +38 -29
- package/skills/css/SKILL.md +10 -13
- package/skills/debug-manifest/SKILL.md +74 -31
- package/skills/defer-hydration/SKILL.md +2 -1
- package/skills/deployment-caching/SKILL.md +6 -1
- package/skills/document-cache/SKILL.md +92 -28
- package/skills/fonts/SKILL.md +16 -8
- package/skills/handler-use/SKILL.md +53 -17
- package/skills/hooks/SKILL.md +28 -20
- package/skills/hooks/data.md +23 -3
- package/skills/hooks/handle-and-actions.md +31 -16
- package/skills/hooks/navigation.md +39 -10
- package/skills/hooks/outlets.md +13 -6
- package/skills/hooks/state.md +37 -15
- package/skills/hooks/urls.md +7 -5
- package/skills/host-router/SKILL.md +64 -12
- package/skills/i18n/SKILL.md +60 -34
- package/skills/intercept/SKILL.md +161 -26
- package/skills/layout/SKILL.md +106 -50
- package/skills/links/SKILL.md +51 -19
- package/skills/loader/SKILL.md +242 -106
- package/skills/middleware/SKILL.md +119 -23
- package/skills/migrate-nextjs/SKILL.md +100 -45
- package/skills/migrate-nextjs/backend-host-swap.md +5 -2
- package/skills/migrate-react-router/SKILL.md +49 -38
- package/skills/migrate-react-router/component-migration.md +25 -6
- package/skills/migrate-react-router/data-and-actions.md +18 -5
- package/skills/migrate-react-router/route-mapping.md +12 -6
- package/skills/mime-routes/SKILL.md +12 -7
- package/skills/observability/SKILL.md +28 -14
- package/skills/parallel/SKILL.md +75 -39
- package/skills/ppr/SKILL.md +345 -150
- package/skills/prerender/SKILL.md +189 -100
- package/skills/rango/SKILL.md +65 -34
- package/skills/react-compiler/SKILL.md +6 -5
- package/skills/response-routes/SKILL.md +133 -25
- package/skills/route/SKILL.md +165 -60
- package/skills/router-setup/SKILL.md +418 -240
- package/skills/scripts/SKILL.md +31 -20
- package/skills/server-actions/SKILL.md +220 -59
- package/skills/shell-manifest/SKILL.md +12 -7
- package/skills/streams-and-websockets/SKILL.md +32 -14
- package/skills/tailwind/SKILL.md +37 -33
- package/skills/testing/SKILL.md +68 -32
- package/skills/testing/cache-prerender.md +55 -29
- package/skills/testing/client-components.md +59 -6
- package/skills/testing/e2e-parity.md +12 -2
- package/skills/testing/flight.md +19 -11
- package/skills/testing/handles.md +8 -5
- package/skills/testing/loader.md +8 -5
- package/skills/testing/middleware.md +8 -3
- package/skills/testing/predicates.md +120 -0
- package/skills/testing/render-handler.md +70 -22
- package/skills/testing/response-routes.md +11 -4
- package/skills/testing/reverse-and-types.md +20 -14
- package/skills/testing/server-actions.md +12 -3
- package/skills/testing/server-tree.md +9 -0
- package/skills/testing/setup.md +9 -5
- package/skills/theme/SKILL.md +106 -29
- package/skills/typesafety/SKILL.md +20 -9
- package/skills/typesafety/env-and-bindings.md +126 -47
- package/skills/typesafety/generated-files-and-cli.md +26 -14
- package/skills/typesafety/params-and-search.md +44 -19
- package/skills/typesafety/route-types.md +50 -14
- package/skills/use-cache/SKILL.md +200 -82
- package/skills/vercel/SKILL.md +21 -11
- package/skills/view-transitions/SKILL.md +41 -20
- package/src/__augment-tests__/augmented.check.ts +26 -1
- package/src/browser/index.ts +2 -2
- package/src/browser/navigation-bridge.ts +3 -4
- package/src/browser/navigation-transaction.ts +22 -2
- package/src/browser/partial-update.ts +35 -25
- package/src/browser/react/ScrollRestoration.tsx +6 -6
- package/src/browser/rsc-router.tsx +1 -1
- package/src/browser/scroll-restoration.ts +53 -6
- package/src/cache/cache-exec-scope.ts +37 -4
- package/src/cache/cache-key-utils.ts +1 -6
- package/src/cache/cache-runtime.ts +230 -61
- package/src/cache/cache-scope.ts +76 -12
- package/src/cache/document-cache.ts +15 -0
- package/src/cache/handle-capture.ts +90 -10
- package/src/cache/handle-snapshot.ts +132 -28
- package/src/cache/segment-codec.ts +26 -7
- package/src/cache/tag-invalidation.ts +4 -3
- package/src/cache/taint.ts +1 -1
- package/src/cache/types.ts +16 -3
- package/src/client.rsc.tsx +10 -9
- package/src/client.tsx +5 -4
- package/src/components/DefaultDocument.tsx +7 -2
- package/src/context-var.ts +6 -5
- package/src/escape-script.ts +1 -1
- package/src/handles/MetaTags.tsx +13 -11
- package/src/handles/Scripts.tsx +11 -10
- package/src/handles/meta.ts +2 -2
- package/src/handles/script.ts +17 -16
- package/src/href-client.ts +1 -1
- package/src/html.ts +12 -0
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +1 -0
- package/src/loader-store.ts +7 -4
- package/src/prerender/build-shell-capture.ts +8 -11
- package/src/reverse.ts +44 -4
- package/src/route-definition/dsl-helpers.ts +117 -30
- package/src/route-definition/helpers-types.ts +33 -9
- package/src/route-definition/resolve-handler-use.ts +121 -6
- package/src/route-types.ts +0 -3
- package/src/router/debug-manifest.ts +10 -2
- package/src/router/error-handling.ts +51 -15
- package/src/router/handler-context.ts +34 -25
- package/src/router/intercept-resolution.ts +125 -39
- package/src/router/loader-resolution.ts +56 -11
- package/src/router/match-api.ts +12 -17
- package/src/router/match-middleware/background-revalidation.ts +161 -96
- package/src/router/match-middleware/cache-lookup.ts +93 -10
- package/src/router/match-middleware/cache-store.ts +17 -90
- package/src/router/middleware-types.ts +3 -5
- package/src/router/middleware.ts +52 -9
- package/src/router/prerender-match.ts +53 -19
- package/src/router/route-snapshot.ts +11 -1
- package/src/router/router-context.ts +2 -0
- package/src/router/router-interfaces.ts +25 -2
- package/src/router/router-options.ts +8 -5
- package/src/router/segment-resolution/fresh.ts +44 -12
- package/src/router/segment-resolution/loader-cache.ts +205 -45
- package/src/router/segment-resolution/loader-mask.ts +12 -22
- package/src/router/segment-resolution/loader-snapshot.ts +6 -6
- package/src/router/segment-resolution/mask-nested.ts +7 -7
- package/src/router/segment-resolution/revalidation.ts +34 -6
- package/src/router/segment-wrappers.ts +4 -0
- package/src/rsc/render-pipeline.ts +3 -0
- package/src/rsc/response-route-handler.ts +11 -3
- package/src/rsc/rsc-rendering.ts +8 -45
- package/src/rsc/shell-capture-constants.ts +13 -0
- package/src/rsc/shell-capture.ts +111 -90
- package/src/rsc/ssr-setup.ts +14 -8
- package/src/rsc/types.ts +11 -0
- package/src/server/context.ts +88 -11
- package/src/server/cookie-store.ts +5 -4
- package/src/server/handle-store.ts +232 -7
- package/src/server/request-context.ts +35 -30
- package/src/ssr/index.tsx +40 -7
- package/src/static-handler.ts +4 -1
- package/src/testing/flight-runtime.d.ts +21 -0
- package/src/testing/generated-routes.ts +2 -2
- package/src/testing/internal/context.ts +1 -1
- package/src/testing/internal/flight-client-globals.ts +7 -5
- package/src/testing/render-route.tsx +85 -24
- package/src/testing/run-loader.ts +5 -1
- package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
- package/src/testing/vitest.ts +62 -1
- package/src/theme/ThemeScript.tsx +3 -3
- package/src/types/cache-types.ts +14 -10
- package/src/types/handler-context.ts +14 -0
- package/src/types/loader-types.ts +8 -6
- package/src/urls/response-types.ts +3 -23
- package/src/use-loader.tsx +12 -3
- package/src/vite/plugins/use-cache-transform.ts +118 -74
- package/src/vite/rango.ts +7 -3
package/skills/ppr/SKILL.md
CHANGED
|
@@ -9,14 +9,23 @@ argument-hint: "[setup]"
|
|
|
9
9
|
Caches the rendered HTML **shell** of a page route (React `prerender` prelude
|
|
10
10
|
bytes plus `postponed` state) and, on a later request, flushes those bytes after
|
|
11
11
|
route classification and the complete middleware chain, but before downstream
|
|
12
|
-
tail rendering. It then resumes
|
|
13
|
-
one ordinary streamed document; loaders stay fresh on every
|
|
14
|
-
the second render axis
|
|
15
|
-
|
|
12
|
+
tail rendering. It then resumes React's HTML renderer for just the live holes.
|
|
13
|
+
The browser sees one ordinary streamed document; loaders stay fresh on every
|
|
14
|
+
request. Rango calls this the second render axis ("axis 2"): the default path
|
|
15
|
+
("axis 1", a normal full render) is untouched, and every ineligible request
|
|
16
|
+
falls open to it.
|
|
17
|
+
|
|
18
|
+
Use it when a page has a stable layout that should reach the browser instantly
|
|
19
|
+
(the first byte comes from cache) while specific regions stay per-request:
|
|
20
|
+
prices, a basket, recommendations. Opt in per page route with the `ppr` path
|
|
21
|
+
option; the holes are decided by the shape of your tree (see "The hole
|
|
22
|
+
doctrine").
|
|
16
23
|
|
|
17
24
|
Compare `/document-cache`, which freezes the WHOLE response including loader
|
|
18
|
-
output
|
|
19
|
-
|
|
25
|
+
output, and the `cache()` DSL (`/caching`), which caches Flight segments but
|
|
26
|
+
still renders HTML on every request. Shell caching is for pages that mix a
|
|
27
|
+
stable shell with live data: the shell is shared per host+URL, the holes are
|
|
28
|
+
per request.
|
|
20
29
|
|
|
21
30
|
This is in-function PPR on every deployment. The worker/function serves the
|
|
22
31
|
prelude; it is not a CDN static file. See `/deployment-caching` before combining
|
|
@@ -27,8 +36,11 @@ PPR with HTTP shared-cache headers.
|
|
|
27
36
|
- You want the WHOLE response frozen, loader output included — see
|
|
28
37
|
`/document-cache`.
|
|
29
38
|
- You want build-time Flight segment payloads from `Static()`/`Prerender()` —
|
|
30
|
-
see `/prerender`. A `Prerender` page may also declare `ppr`; then
|
|
31
|
-
|
|
39
|
+
see `/prerender`. A `Prerender` page may also declare `ppr`; then the build
|
|
40
|
+
step called "producer B" bakes the HTML shell at build time while loaders stay
|
|
41
|
+
live.
|
|
42
|
+
- You want cached segments with live loaders but no cached HTML — that is the
|
|
43
|
+
`cache()` DSL: see `/caching`.
|
|
32
44
|
- You are unsure which cache layer you need — start at `/cache-guide`.
|
|
33
45
|
|
|
34
46
|
## Setup: one path option, no PPR middleware to mount
|
|
@@ -37,9 +49,9 @@ PPR is a DOCUMENT-level property declared on the page route via the `ppr` path
|
|
|
37
49
|
option. Serving is **integral to the router** — there is nothing to mount. The
|
|
38
50
|
only prerequisite is an app-level `createRouter({ cache })` store that
|
|
39
51
|
implements the shell family (`getShell`/`putShell`): `MemorySegmentCacheStore`
|
|
40
|
-
(dev/tests), `CFCacheStore` (Cache API L1 + KV L2), or
|
|
41
|
-
(
|
|
42
|
-
a once-per-key warning.
|
|
52
|
+
(dev/tests), `CFCacheStore` (Cache API L1 + optional KV L2), or
|
|
53
|
+
`VercelCacheStore` (Vercel Runtime Cache). A ppr route on a store without the
|
|
54
|
+
family stays on axis 1 with a once-per-key warning.
|
|
43
55
|
|
|
44
56
|
```typescript
|
|
45
57
|
import { createRouter, urls } from "@rangojs/router";
|
|
@@ -61,14 +73,13 @@ export const urlpatterns = urls(({ path, layout, loader, loading }) => [
|
|
|
61
73
|
]),
|
|
62
74
|
]);
|
|
63
75
|
|
|
64
|
-
const router = createRouter<AppBindings>({
|
|
76
|
+
export const router = createRouter<AppBindings>({
|
|
65
77
|
document: Document,
|
|
66
78
|
urls: urlpatterns,
|
|
67
79
|
cache: (env, ctx) => ({
|
|
68
80
|
store: new CFCacheStore({ kv: env.CACHE_KV, ctx: ctx! }),
|
|
69
81
|
}),
|
|
70
82
|
});
|
|
71
|
-
export default router;
|
|
72
83
|
```
|
|
73
84
|
|
|
74
85
|
That is ONE of two hole mechanisms — the loader one. Do not conclude PPR
|
|
@@ -121,9 +132,8 @@ At capture the pending fetch cannot win the task-quantized quiet window, so
|
|
|
121
132
|
the boundary postpones — fallback in the frozen prelude, value resumed fresh
|
|
122
133
|
on every HIT. This is the PHYSICS class from the hole doctrine below, and it
|
|
123
134
|
is exactly how an existing Suspense-shaped tree (e.g. migrated from Next.js
|
|
124
|
-
PPR) works with zero restructuring. The
|
|
125
|
-
|
|
126
|
-
with no loader registration at all.
|
|
135
|
+
PPR) works with zero restructuring. The same works for a promise hole in a
|
|
136
|
+
LAYOUT with no loader registration at all.
|
|
127
137
|
|
|
128
138
|
A route WITHOUT the `ppr` option is pure axis 1: no store read, no capture, no
|
|
129
139
|
logs, zero cost. `ppr` is per page route — declaring it on a layout is not
|
|
@@ -139,7 +149,7 @@ outermost (final bytes):
|
|
|
139
149
|
| ----------------------- | ---------------------------------------- | -------------------------------------------------- | -------------------------------------- |
|
|
140
150
|
| 1. Function values | `"use cache"` | a function's return value | everything around the call |
|
|
141
151
|
| 2. Loader values | `loader(Fn, () => [cache({...})])` | one loader's result (opt-in; loaders default live) | all other loaders, handlers, rendering |
|
|
142
|
-
| 3. Segments (Flight) | `cache()` route / build-time `
|
|
152
|
+
| 3. Segments (Flight) | `cache()` route / build-time `Prerender` | serialized rendered segments + replayed handles | loaders, HTML render |
|
|
143
153
|
| 4. **HTML shell (PPR)** | `ppr` path option | rendered prelude bytes + React postponed state | the holes, hydration payload |
|
|
144
154
|
| 5. Whole response | `/document-cache` | final response bytes, headers included | nothing — all-or-nothing |
|
|
145
155
|
|
|
@@ -177,6 +187,11 @@ auth middleware anywhere (global or route DSL) and it guards PPR for free.
|
|
|
177
187
|
|
|
178
188
|
### Soft navigation caches and reuses the handler layer
|
|
179
189
|
|
|
190
|
+
In short: client-side (soft) navigations to a `ppr` route reuse the handler
|
|
191
|
+
segments captured with the shell, while loaders stay live. Nothing needs
|
|
192
|
+
configuring; the rest of this section explains the `x-rango-ppr-replay` header
|
|
193
|
+
for when you are debugging replay behavior.
|
|
194
|
+
|
|
180
195
|
Ordinary partial RSC navigations to a `ppr` URL use the same handler-layer cache
|
|
181
196
|
contract even when no document request has captured an HTML shell yet. When a
|
|
182
197
|
shell snapshot exists, the server replays its canonical document segment record.
|
|
@@ -321,7 +336,7 @@ the write — a write before it still throws.
|
|
|
321
336
|
```ts
|
|
322
337
|
function catalogPage(ctx) {
|
|
323
338
|
ctx.dynamic(); // declare live -> refuses capture AND clears the header latch
|
|
324
|
-
ctx.headers.set("x-
|
|
339
|
+
ctx.headers.set("x-catalog-mode", "live");
|
|
325
340
|
return <Catalog />;
|
|
326
341
|
}
|
|
327
342
|
```
|
|
@@ -381,9 +396,9 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
|
|
|
381
396
|
slowest bake source (a top-level pushed handle promise or a bake-lane loader
|
|
382
397
|
container) held the capture gate — in dev, past 2s, the source is also named
|
|
383
398
|
once per key in a console warning with the remedies (nest the promise /
|
|
384
|
-
`cache()` the work /
|
|
385
|
-
the last capture outcome for a key also rides the
|
|
386
|
-
`Server-Timing` as `ppr-capture;desc="…"`.
|
|
399
|
+
`cache()` the work / drop the loader's `ssr: false`). In dev, with
|
|
400
|
+
`debugPerformance` on, the last capture outcome for a key also rides the
|
|
401
|
+
next document GET's `Server-Timing` as `ppr-capture;desc="…"`.
|
|
387
402
|
- For deployed Cloudflare tier diagnostics, build with
|
|
388
403
|
`INTERNAL_RANGO_DEBUG=1` and run `wrangler tail`. `[CFCacheStore][shell]`
|
|
389
404
|
JSON events distinguish L1 hit/miss, KV fallback/promotion, tier writes, and
|
|
@@ -427,25 +442,105 @@ Do not invent a HIT Response in unit tests. Full recipe: `/testing` skill →
|
|
|
427
442
|
|
|
428
443
|
Holes are **render-defined**, decided by the shape of the tree, on three rules:
|
|
429
444
|
|
|
430
|
-
| Class | What makes the hole
|
|
431
|
-
| -------------- |
|
|
432
|
-
| **STRUCTURAL** | the ENTIRE segment subtree under a `loading()` registration
|
|
433
|
-
| **PHYSICS** | any promise NESTED in handed-over data still pending at capture, under the consumer's own `<Suspense>` — handler props, handle containers (`push({ x: promise })`), loader-carried
|
|
434
|
-
| **SHELL** | awaited handler data, TOP-LEVEL `push(promise)` (awaited before SSR), resolved promises, replayed `cache()` segments, BAKE-lane
|
|
445
|
+
| Class | What makes the hole | At capture | At serve |
|
|
446
|
+
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------- |
|
|
447
|
+
| **STRUCTURAL** | the ENTIRE segment subtree under a `loading()` registration, when a LIVE-lane loader read suspends to it | live loaders masked; the LoaderBoundary postpones; the fallback bakes in as route structure | loaders run fresh; resume fills it |
|
|
448
|
+
| **PHYSICS** | any promise NESTED in handed-over data still pending at capture, under the consumer's own `<Suspense>` — handler props, handle containers (`push({ x: promise })`), loader-carried | real I/O cannot win the task-quantized quiet window; the boundary postpones | the promise settles and streams in |
|
|
449
|
+
| **SHELL** | awaited handler data, TOP-LEVEL `push(promise)` (awaited before SSR), resolved promises, replayed `cache()` segments, the settled non-promise data of BAKE-lane (`ssr: false`) loaders | baked into the prelude | served from the frozen prelude |
|
|
435
450
|
|
|
436
451
|
ONE rule for promises, every lane — handlers, handles, AND loaders: **a promise
|
|
437
|
-
nested inside your data is never baked; the container settles.** A
|
|
438
|
-
|
|
439
|
-
shell material, exactly like awaited handler data
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
452
|
+
nested inside your data is never baked; the container settles.** A
|
|
453
|
+
`loader(Def, { ssr: false })` is the BAKE lane (see the lane rule below): its
|
|
454
|
+
settled non-promise data is shell material, exactly like awaited handler data
|
|
455
|
+
and top-level handle pushes.
|
|
456
|
+
|
|
457
|
+
### The loader lane rule
|
|
458
|
+
|
|
459
|
+
**Only `loader(Def, { ssr: false })` bakes into the shell, and only the parts
|
|
460
|
+
of its returned data that are NOT promises.** The loader runs at capture; its
|
|
461
|
+
settled non-promise values are frozen into the shell (snapshot-pinned per
|
|
462
|
+
shell). Any promise inside the returned data is masked at capture and stays a
|
|
463
|
+
live hole, streamed fresh on every request at the consumer's own `<Suspense>`,
|
|
464
|
+
however fast it settles. The shape of the return value declares what is live:
|
|
465
|
+
a plain value bakes, a promise stays live.
|
|
466
|
+
|
|
467
|
+
- **Any depth, plain containers only.** The mask walks plain objects
|
|
468
|
+
(prototype `Object.prototype` or `null`) and arrays at any depth —
|
|
469
|
+
`{ items: [{ stock: fetchStock(sku) }] }` keeps every `stock` live. A `Map`,
|
|
470
|
+
`Set`, class instance, or any other non-plain object is a LEAF: a promise
|
|
471
|
+
inside it is NOT masked and does NOT become a live hole. Keep live promises
|
|
472
|
+
in plain objects and arrays.
|
|
473
|
+
- **The consumer's `<Suspense>` is the hole.** A client component reads the
|
|
474
|
+
live property with `use(data.price)` inside its own `<Suspense>`; that
|
|
475
|
+
boundary postpones at capture and resumes with the fresh value.
|
|
476
|
+
- **Handle containers follow the same shape rule.** The capture applies the
|
|
477
|
+
same mask to pushed handle containers, so `ctx.use(Handle)({ ..., x: promise })`
|
|
478
|
+
keeps `x` live (see "Handles: nesting = liveness").
|
|
479
|
+
|
|
480
|
+
**Every loader without `ssr: false` is entirely live**, whether or not it has
|
|
481
|
+
`loading()`: masked at capture with a never-resolving promise, postponed at its
|
|
482
|
+
boundary, and streamed fresh on every request. It needs a boundary —
|
|
483
|
+
`loading()` or an inline `<Suspense>` above the reader — or the shell is
|
|
484
|
+
refused: a masked read with no boundary above it root-postpones, the `<body>`
|
|
485
|
+
sanity gate refuses the capture, and the route stays an eternal MISS (warned
|
|
486
|
+
once per key). A route whose loaders are ALL `ssr: false` needs no
|
|
487
|
+
`loading()`: nothing masks, and the shell captures complete.
|
|
488
|
+
|
|
489
|
+
```typescript
|
|
490
|
+
export const ProductLoader = createLoader(async (ctx) => {
|
|
491
|
+
const product = await getProduct(ctx.params.id);
|
|
492
|
+
return {
|
|
493
|
+
name: product.name, // plain value: bakes
|
|
494
|
+
description: product.description, // plain value: bakes
|
|
495
|
+
price: fetchPrice(ctx.params.id), // promise: masked at capture, live hole
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
path("/products/:id", ProductPage, { name: "product", ppr: true }, () => [
|
|
500
|
+
loader(ProductLoader, { ssr: false }), // BAKE lane
|
|
501
|
+
loader(StockLoader), // LIVE lane: ProductPage reads it under an inline <Suspense>
|
|
502
|
+
]);
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
```tsx
|
|
506
|
+
// ProductDetails.tsx — the consumer of the live nested property.
|
|
507
|
+
"use client";
|
|
508
|
+
|
|
509
|
+
import { Suspense, use } from "react";
|
|
510
|
+
import { useLoader } from "@rangojs/router/client";
|
|
511
|
+
|
|
512
|
+
function Price({ price }: { price: Promise<number> }) {
|
|
513
|
+
return <PriceTag amount={use(price)} />;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function ProductDetails() {
|
|
517
|
+
const { data } = useLoader(ProductLoader);
|
|
518
|
+
return (
|
|
519
|
+
<article>
|
|
520
|
+
<h1>{data.name}</h1> {/* baked */}
|
|
521
|
+
<p>{data.description}</p> {/* baked */}
|
|
522
|
+
<Suspense fallback={<PriceSkeleton />}>
|
|
523
|
+
<Price price={data.price} /> {/* hole — fresh on every request */}
|
|
524
|
+
</Suspense>
|
|
525
|
+
</article>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
`name` and `description` are frozen into the shell; `price` is a hole under
|
|
531
|
+
the consumer's `<Suspense>`, fresh on every request. `StockLoader` is masked at
|
|
532
|
+
capture and postpones at the inline `<Suspense>` around its reader. A
|
|
533
|
+
`loading()` on this entry would also be a valid boundary, but its fallback
|
|
534
|
+
wraps the entry's whole subtree: a live read with no inline `<Suspense>` of its
|
|
535
|
+
own takes the entire entry — baked `name`/`description` included — into that
|
|
536
|
+
hole.
|
|
537
|
+
|
|
538
|
+
**`loading()` is NOT the lane switch and NOT the gate for holes** — it is one
|
|
539
|
+
of the two boundary kinds a live loader postpones at. A pending promise under
|
|
540
|
+
any plain `<Suspense>` postpones and is a hole at whatever level it suspends —
|
|
541
|
+
the PHYSICS row needs no `loading()` anywhere (the e2e fixture's physics and
|
|
542
|
+
nested-handle holes sit in a layout with none). A route without a loader PPRs
|
|
543
|
+
on pure promise/Suspense holes.
|
|
449
544
|
|
|
450
545
|
The three promise positions, side by side:
|
|
451
546
|
|
|
@@ -471,19 +566,20 @@ async function Handler(ctx: HandlerContext) {
|
|
|
471
566
|
|
|
472
567
|
### Choosing the hole mechanism
|
|
473
568
|
|
|
474
|
-
| Your live region is… | Use
|
|
475
|
-
| ----------------------------------------------- |
|
|
476
|
-
| loader data, must be fresh EVERY serve | `loader()` + `loading()`
|
|
477
|
-
| loader data, shell container + live parts |
|
|
478
|
-
| handler-fetched real I/O (db, fetch) | un-awaited promise prop + consumer `<Suspense>` + `use()`
|
|
479
|
-
| per-segment metadata consumed elsewhere | handle container with a NESTED promise + consumer `<Suspense>`
|
|
480
|
-
| already-resolved / instant / synchronous values | `loader(() => Promise.resolve(x))`
|
|
481
|
-
| none of the above | nothing
|
|
569
|
+
| Your live region is… | Use | Why |
|
|
570
|
+
| ----------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
|
571
|
+
| loader data, must be fresh EVERY serve | `loader()` (the live lane) + a boundary: `loading()` or an inline `<Suspense>` | the guaranteed hole — masked at capture, fresh every serve, immune to fast resolution |
|
|
572
|
+
| loader data, shell container + live parts | `loader(Def, { ssr: false })` (the bake lane): `{ static, dynamic: promise }` | plain values bake (snapshot-pinned per shell); nested promises hole at the consumer's `<Suspense>` |
|
|
573
|
+
| handler-fetched real I/O (db, fetch) | un-awaited promise prop + consumer `<Suspense>` + `use()` | no loader needed; real latency postpones by physics |
|
|
574
|
+
| per-segment metadata consumed elsewhere | handle container with a NESTED promise + consumer `<Suspense>` | container is shell, nested value streams — "nesting = liveness" |
|
|
575
|
+
| already-resolved / instant / synchronous values | `loader(() => Promise.resolve(x))` (no `ssr: false`) + a boundary | a raw promise that settles inside the quiet window BAKES; only the live lane guarantees live |
|
|
576
|
+
| none of the above | nothing | it bakes — that is what the shell is for |
|
|
482
577
|
|
|
483
578
|
The physics caveat in one line: HANDLER-created promise props are holes only
|
|
484
579
|
because the I/O is genuinely pending at capture — if the value can resolve
|
|
485
580
|
near-instantly (memory read, warmed cache), it may bake into the shell; when
|
|
486
|
-
liveness must be guaranteed rather than probable, use a loader
|
|
581
|
+
liveness must be guaranteed rather than probable, use a live-lane loader (no
|
|
582
|
+
`ssr: false`). BAKE-LANE
|
|
487
583
|
NESTED promises are exempt from that race: the capture MASKS every thenable
|
|
488
584
|
nested in a bake-lane container regardless of settle timing
|
|
489
585
|
(`maskNestedContainerThenables`, loader-cache.ts), so the consuming boundary
|
|
@@ -498,31 +594,35 @@ capturing session's identifiers — served to anonymous visitors.)
|
|
|
498
594
|
- `ctx.use(H)(promise)` — a TOP-LEVEL pushed promise is awaited server-side
|
|
499
595
|
before SSR (`resolvedHandleStream`) and BAKED into the shell. The capture
|
|
500
596
|
gate is held open for the same await, so real latency here is safe (bounded
|
|
501
|
-
by the capture
|
|
597
|
+
by the capture budget, `ppr.captureTimeout`, 15s by default).
|
|
502
598
|
- `ctx.use(H)({ x: promise })` — the container passes through verbatim
|
|
503
599
|
(resolution is shallow); the nested promise streams to the consumer, who must
|
|
504
600
|
`<Suspense>` it. Under capture that boundary postpones — a hole — REGARDLESS
|
|
505
601
|
of settle timing: the capture masks nested thenables in pushed handle
|
|
506
602
|
containers (the capture store's push wrap, shell-capture.ts), so even an
|
|
507
603
|
already-resolved nested promise holes instead of baking its value into the
|
|
508
|
-
shared shell. Same shape-is-the-declaration rule
|
|
604
|
+
shared shell. Same shape-is-the-declaration rule, and the same mask, as
|
|
605
|
+
bake-lane loaders: promises are found at any depth of plain objects and
|
|
606
|
+
arrays; one inside a `Map`, `Set`, or class instance is not masked.
|
|
509
607
|
|
|
510
608
|
### Want a hole for already-resolved data?
|
|
511
609
|
|
|
512
|
-
Put it in a loader: `loader(() => Promise.resolve(x))`
|
|
513
|
-
|
|
514
|
-
how fast the value settles.
|
|
610
|
+
Put it in a loader without `ssr: false`: `loader(() => Promise.resolve(x))`
|
|
611
|
+
under `loading()` or an inline `<Suspense>`. Live-lane loaders are masked at
|
|
612
|
+
capture and fresh on every serve, no matter how fast the value settles.
|
|
515
613
|
|
|
516
|
-
### The bake lane:
|
|
614
|
+
### The bake lane: `loader(Def, { ssr: false })`
|
|
517
615
|
|
|
518
|
-
A loader
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
capture
|
|
524
|
-
|
|
525
|
-
|
|
616
|
+
A loader registered with `{ ssr: false }` EXECUTES during capture (the capture
|
|
617
|
+
gate holds open for its real latency, bounded by `ppr.captureTimeout`, 15s by
|
|
618
|
+
default). The flag's document promise — "this loader's data is in the HTML
|
|
619
|
+
before first flush" — maps to the frozen prelude under ppr. Its settled
|
|
620
|
+
non-promise data bakes into the prelude; every promise nested in it is masked
|
|
621
|
+
at capture (regardless of how fast it settles) and postpones at the consumer's
|
|
622
|
+
own `<Suspense>` — a hole. The capture records the container into the shell
|
|
623
|
+
snapshot's loader family so every HIT payload matches the frozen prelude
|
|
624
|
+
byte-for-byte (see "On a shell HIT" below). The return shape is the
|
|
625
|
+
declaration:
|
|
526
626
|
|
|
527
627
|
```typescript
|
|
528
628
|
export const StorefrontContextLoader = createLoader(async (ctx) => {
|
|
@@ -532,20 +632,66 @@ export const StorefrontContextLoader = createLoader(async (ctx) => {
|
|
|
532
632
|
basket: fetchBasket(ctx), // hole — consumer <Suspense>s it, fresh per request
|
|
533
633
|
};
|
|
534
634
|
});
|
|
635
|
+
|
|
636
|
+
layout(StoreLayout, () => [
|
|
637
|
+
loader(StorefrontContextLoader, { ssr: false }), // the flag selects the bake lane
|
|
638
|
+
path("/", HomePage, { name: "home", ppr: true }),
|
|
639
|
+
]);
|
|
535
640
|
```
|
|
536
641
|
|
|
537
|
-
The lane is decided PER
|
|
538
|
-
|
|
539
|
-
|
|
642
|
+
The lane is decided PER LOADER, by its own `ssr: false` flag — `loading()` on
|
|
643
|
+
the same entry, a parent, or a child never changes it. `loading()` only decides
|
|
644
|
+
where a live loader's hole sits, and it IS valid on layout and parallel
|
|
645
|
+
entries, not just routes.
|
|
646
|
+
|
|
647
|
+
#### On a shell HIT
|
|
648
|
+
|
|
649
|
+
What a bake-lane loader does on a document HIT depends on whether its recorded
|
|
650
|
+
container carried holes:
|
|
651
|
+
|
|
652
|
+
- **Hole-free record (the return had no promises): pin-first.** The response
|
|
653
|
+
uses the pinned container from the shell snapshot immediately and does not
|
|
654
|
+
wait for the loader. The loader body STILL runs fresh on every HIT, in the
|
|
655
|
+
background via `executionContext.waitUntil`, only for its side effects and
|
|
656
|
+
cache read-through writes; its result is discarded. The pinned SHAPE wins
|
|
657
|
+
wholesale: a field the fresh run adds mid-TTL is dropped, because the frozen
|
|
658
|
+
prelude has no hole for it. The output changes only when the shell is
|
|
659
|
+
recaptured (TTL/SWR expiry or tag invalidation).
|
|
660
|
+
- **Hole-carrying record (the return had promises).** The loader runs fresh ON
|
|
661
|
+
the critical path — only the body can create the live promises — and the
|
|
662
|
+
recorded baked paths are overlaid onto the fresh result, so the payload
|
|
663
|
+
matches the prelude while the nested promises stream fresh. A fresh
|
|
664
|
+
rejection skips the overlay and goes to the loader's error boundary.
|
|
665
|
+
|
|
666
|
+
A fully baked loader therefore still costs one backend call per HIT, off the
|
|
667
|
+
response's critical path. To avoid it, give the loader its own cache —
|
|
668
|
+
`loader(Def, { ssr: false }, () => [cache({ ttl })])` — so the background run
|
|
669
|
+
reads through the loader cache.
|
|
670
|
+
|
|
671
|
+
**Handle pushes from a bake-lane loader appear once, with the re-run's value.**
|
|
672
|
+
The capture records the loader's settled, thenable-free handle pushes (the
|
|
673
|
+
prelude rendered them), and a HIT that replays the handler layer from the
|
|
674
|
+
record restores them. The loader still re-runs, and its pushes replace the
|
|
675
|
+
restored ones in place, so any handle, deduping or not, shows each value
|
|
676
|
+
once and the live value wins. A re-run push that lands after the document's
|
|
677
|
+
handle snapshot reaches the client after hydration; until then the client
|
|
678
|
+
shows the recorded value, which matches the prelude. Deferred and
|
|
679
|
+
promise-carrying pushes are never recorded; the re-run is their only
|
|
680
|
+
producer. The record keeps only the pushes
|
|
681
|
+
made by the `ssr: false` loader's own body. A loader it awaits with
|
|
682
|
+
`ctx.use()` re-runs with it on a HIT, so that loader's pushes are not recorded
|
|
683
|
+
and appear once, whatever the value type (a string is left out the same way as
|
|
684
|
+
an object). The same holds for pushes the capture replays from the loader's
|
|
685
|
+
own `cache()` entry.
|
|
540
686
|
|
|
541
687
|
Four hard edges (each e2e/unit-pinned):
|
|
542
688
|
|
|
543
689
|
- **Header writes throw (issue #713).** ppr is a document-scoped `cache()`:
|
|
544
690
|
in any cached scenario ONLY MIDDLEWARE writes response headers. A handler
|
|
545
691
|
or loader on a ppr route calling `ctx.headers.set()`, `cookies().set()`,
|
|
546
|
-
`ctx.
|
|
547
|
-
dev and prod,
|
|
548
|
-
|
|
692
|
+
`ctx.setTheme()`, or the request-context `header()`/`setStatus()` throws on
|
|
693
|
+
EVERY render — dev and prod, first render, same guard family as the
|
|
694
|
+
`cache()` boundary guard. Handlers
|
|
549
695
|
are replayed on HITs (the write would silently differ between MISS and
|
|
550
696
|
HIT); loaders are live but settle AFTER the response headers flushed with
|
|
551
697
|
the shell (dead letters). Move the write into route middleware — it runs
|
|
@@ -555,39 +701,55 @@ Four hard edges (each e2e/unit-pinned):
|
|
|
555
701
|
HITs, so the write is deterministic (see "Opting out per request").
|
|
556
702
|
- **Identity refuses.** `cookies()`/`headers()` inside a bake-lane loader
|
|
557
703
|
throws during capture and the capture REFUSES (deterministic, once-per-key
|
|
558
|
-
warned) — identity can never bake into the shared shell.
|
|
559
|
-
|
|
560
|
-
part into a
|
|
704
|
+
warned) — identity can never bake into the shared shell. Drop the loader's
|
|
705
|
+
`ssr: false` flag (the live lane is exempt; give it a boundary) or move the
|
|
706
|
+
identity-dependent part into a separate loader without `ssr: false`. A
|
|
707
|
+
nested promise does not help here: its body still runs during capture and
|
|
708
|
+
trips the guard. The guard's scope is EXACTLY those two calls:
|
|
561
709
|
per-user state read from a middleware-provided object (`ctx.get("session")`)
|
|
562
710
|
does NOT refuse — it bakes silently as the capturing user's data (see
|
|
563
711
|
Pitfalls: the session-object bake trap).
|
|
564
|
-
- **A rejecting bake-lane loader refuses.** Error UI never bakes.
|
|
712
|
+
- **A rejecting bake-lane loader refuses.** Error UI never bakes. So does a
|
|
713
|
+
value that fails to encode: a function, a class instance, a promise inside a
|
|
714
|
+
`Map` that rejects, or a server component in the value that throws. The
|
|
715
|
+
capture stores nothing, logs the error, reports `cache-write` and backs the
|
|
716
|
+
key off, the same as a shell component that throws (below). The capture
|
|
717
|
+
encodes the value a second time to pin it for HITs, so a server component
|
|
718
|
+
that throws only on that run refuses it too. A build-time capture skips the
|
|
719
|
+
shell (`SHELL SKIP`) and the route keeps runtime capture.
|
|
565
720
|
- **Baked containers show CAPTURE-time data** for the shell's lifetime on
|
|
566
721
|
document GETs. Soft navigations may replay the captured handler segments, but
|
|
567
722
|
DSL loaders and their item/response reads remain fresh. That IS the bake
|
|
568
723
|
lane's meaning; if a value must be fresh on every serve, it belongs on the
|
|
569
|
-
live lane (`
|
|
724
|
+
live lane (no `ssr: false`) or in a nested promise.
|
|
570
725
|
|
|
571
726
|
### The layout-with-loaders playbook (the storefront case)
|
|
572
727
|
|
|
573
728
|
The most common real-app shape: an app-wide layout registers per-user loaders
|
|
574
729
|
(session context, basket, wishlist) and the page under it declares `ppr`.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
730
|
+
Unflagged, those loaders are on the LIVE lane: masked at capture, so every
|
|
731
|
+
read needs a boundary (`loading()` or an inline `<Suspense>`) or the capture
|
|
732
|
+
refuses and the page stays an eternal MISS. The question is which parts of
|
|
733
|
+
their data should bake vs stay live. Your levers, in order of preference:
|
|
734
|
+
|
|
735
|
+
1. **Flag the loader and shape the return value.**
|
|
736
|
+
`loader(SessionContextLoader, { ssr: false })` puts it on the bake lane:
|
|
737
|
+
shared/config data returns as plain values (bakes, pinned per shell);
|
|
738
|
+
per-request data returns as NESTED promises consumed under the widget's own
|
|
739
|
+
`<Suspense>` (live holes). No `loading()`, no restructuring. One wall: a
|
|
740
|
+
bake-lane loader that reads `cookies()`/`headers()` refuses the capture —
|
|
741
|
+
identity belongs on the live lane (a separate unflagged loader); a nested
|
|
742
|
+
promise inside the flagged loader still runs during capture and trips the
|
|
743
|
+
guard.
|
|
744
|
+
2. **Do NOT use `loading()` on the layout itself as the live loaders'
|
|
745
|
+
boundary** — any live read without an inline `<Suspense>` of its own
|
|
746
|
+
suspends to it, and the LoaderBoundary fallback wraps the layout's ENTIRE
|
|
747
|
+
subtree: chrome (header, nav, footer) falls out of the shell into the
|
|
748
|
+
skeleton. Technically PPR, practically pointless.
|
|
749
|
+
3. **Guaranteed-fresh widgets: live loaders with a widget-sized boundary.**
|
|
750
|
+
An unflagged loader read under an inline `<Suspense>` in the widget is
|
|
751
|
+
enough. Alternatively, a parallel slot with its OWN `loading()`:
|
|
752
|
+
parallel-owned loaders get their own per-slot boundary (`fresh.ts` tags
|
|
591
753
|
them with the slot's loading; `segment-system.tsx` builds a per-slot
|
|
592
754
|
LoaderBoundary), so the chrome bakes into the shell and each widget is an
|
|
593
755
|
independent, widget-sized hole:
|
|
@@ -613,8 +775,8 @@ stay live. Your levers, in order of preference:
|
|
|
613
775
|
]),
|
|
614
776
|
```
|
|
615
777
|
|
|
616
|
-
Slot-owned loaders are masked at capture and
|
|
617
|
-
use this where the bake lane's
|
|
778
|
+
Slot-owned loaders without `ssr: false` are masked at capture and
|
|
779
|
+
GUARANTEED fresh per serve — use this where the bake lane's pinning
|
|
618
780
|
(capture-time data for the shell's lifetime) is not acceptable, at the cost
|
|
619
781
|
of a widget-sized fallback in the shell. The slot handler must hand the
|
|
620
782
|
loader to a CLIENT component (`useLoader` in a `"use client"` component)
|
|
@@ -629,14 +791,17 @@ stay live. Your levers, in order of preference:
|
|
|
629
791
|
hole) or `cache()`/`"use cache"` to bake it with tag-invalidation.
|
|
630
792
|
|
|
631
793
|
The identity rule, stated once: per-user data on a PPR page lives in a NESTED
|
|
632
|
-
promise (a hole, fresh per request) or
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
794
|
+
promise (a hole, fresh per request) or in a live-lane loader (no `ssr: false`)
|
|
795
|
+
consumed CLIENT-side under `loading()` or an inline `<Suspense>`. A nested
|
|
796
|
+
promise's body still runs at capture, so it must not call `cookies()`/`headers()`
|
|
797
|
+
itself — identity reads belong in the live-lane loader. Reading
|
|
798
|
+
`cookies()`/`headers()` where the value would bake as SEGMENT material —
|
|
799
|
+
handler/render code or a bake-lane loader container — refuses the capture by
|
|
800
|
+
construction. The one exemption is
|
|
636
801
|
handler-INVOKED loader bodies (`await ctx.use(loader)`): they execute at
|
|
637
802
|
capture with identity reads permitted, and the value bakes as a shared
|
|
638
803
|
capture-time copy — mirroring `cache()` semantics (the consumption-lane
|
|
639
|
-
rule
|
|
804
|
+
rule, `/rango` → Invariants).
|
|
640
805
|
|
|
641
806
|
### Designing routes for cheap captures (the cost model)
|
|
642
807
|
|
|
@@ -658,36 +823,40 @@ Three levers, in preference order:
|
|
|
658
823
|
tag-invalidatable), but the capture replays the cached value instead of
|
|
659
824
|
re-executing the expensive body (mixed-chain). Choose this for shared,
|
|
660
825
|
expensive-to-compute shell content.
|
|
661
|
-
3.
|
|
662
|
-
|
|
663
|
-
|
|
826
|
+
3. **Drop `ssr: false`** (loaders only): moves the loader to the live lane —
|
|
827
|
+
masked at capture, fresh per serve — at the cost of a fallback in the shell
|
|
828
|
+
(a live loader needs `loading()` or an inline `<Suspense>` above its
|
|
829
|
+
reader).
|
|
664
830
|
|
|
665
831
|
Head material (Meta) pushed by HANDLERS cannot be a hole — the head is shell —
|
|
666
832
|
so those promises bake by design; make them cheap with lever 2. `bakeWaitMs`
|
|
667
833
|
on the capture debug event tells you what each capture actually paid. A
|
|
668
|
-
|
|
669
|
-
happens at request time and applies client-side
|
|
670
|
-
is never in the cached shell's head, by
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
834
|
+
Meta pushed by a LIVE-lane loader is different: those loaders are masked at
|
|
835
|
+
capture, so the push happens at request time and applies client-side
|
|
836
|
+
(`metadata.handlesLate`) — it is never in the cached shell's head, by
|
|
837
|
+
construction. A bake-lane (`ssr: false`) loader executes at capture, so its
|
|
838
|
+
settled pushes are shell material like a handler's.
|
|
839
|
+
|
|
840
|
+
`loader(Def, { ssr: false })` (the document-render await, `/loader`) is the
|
|
841
|
+
bake-lane switch under PPR, not an axis-1-only knob: the capture render awaits
|
|
842
|
+
the flagged loader too, so its settled non-promise data — handle pushes
|
|
843
|
+
included — freezes into the stored shell and recurs as capture cost on every
|
|
844
|
+
capture (levers 1 and 2 apply to it). Outside capture the flag keeps its
|
|
845
|
+
axis-1 meaning: document renders await it before first flush, client
|
|
846
|
+
navigations stream it.
|
|
678
847
|
|
|
679
848
|
## Execution matrix
|
|
680
849
|
|
|
681
|
-
| Phase | MISS (foreground) | Background capture
|
|
682
|
-
| ---------------- | ---------------------- |
|
|
683
|
-
| Middleware chain | runs (full) | **NOT re-run** — inherits the request's post-middleware context
|
|
684
|
-
| `router.match` | runs | re-runs under a derived context
|
|
685
|
-
| Handlers | run | run on UNCACHED segments; `cache()`d segments replay (mixed-chain)
|
|
686
|
-
| Loaders | run **fresh** | LIVE lane (`
|
|
687
|
-
| Flight render | full | full
|
|
688
|
-
| HTML production | full fizz | `prerender` + abort → prelude + postponed
|
|
689
|
-
| Shell store | schedules a bg capture | `putShell(key, …)`
|
|
690
|
-
| Prelude bytes | — | —
|
|
850
|
+
| Phase | MISS (foreground) | Background capture | HIT (foreground) |
|
|
851
|
+
| ---------------- | ---------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
852
|
+
| Middleware chain | runs (full) | **NOT re-run** — inherits the request's post-middleware context | runs (full) — commit point is after it |
|
|
853
|
+
| `router.match` | runs | re-runs under a derived context | runs (behind the flushed prelude) |
|
|
854
|
+
| Handlers | run | run on UNCACHED segments; `cache()`d segments replay (mixed-chain) | run (same mixed-chain rules as any render) |
|
|
855
|
+
| Loaders | run **fresh** | LIVE lane (no `ssr: false`): MASKED; BAKE lane (`ssr: false`): execute + snapshot-pin | run **fresh** (bake containers overlaid from the snapshot) |
|
|
856
|
+
| Flight render | full | full | full (hydration needs the whole payload — no Flight resume) |
|
|
857
|
+
| HTML production | full fizz | `prerender` + abort → prelude + postponed | `resume` only the holes — O(paths to holes) |
|
|
858
|
+
| Shell store | schedules a bg capture | `putShell(key, …)` | `getShell(key)`; a stale/SWR hit also schedules a recapture |
|
|
859
|
+
| Prelude bytes | — | — | flushed FIRST, before segment resolution starts |
|
|
691
860
|
|
|
692
861
|
Middleware is not re-run during capture because it already ran for the
|
|
693
862
|
triggering request — the capture's derived context inherits the
|
|
@@ -699,7 +868,8 @@ Because handlers on uncached segments EXECUTE during capture — and BAKE-lane
|
|
|
699
868
|
loaders now do too — the `cookies()`/`headers()` capture guard is load-bearing:
|
|
700
869
|
those reads THROW during a capture render (`assertNotInsideShellCapture`), so
|
|
701
870
|
identity can never leak into a shared shell through them. Live-lane loaders
|
|
702
|
-
(
|
|
871
|
+
(every loader without `ssr: false`) are exempt: masked at capture, they never
|
|
872
|
+
run there.
|
|
703
873
|
|
|
704
874
|
## allReady: the SEO/bot story
|
|
705
875
|
|
|
@@ -720,7 +890,7 @@ shell byte.
|
|
|
720
890
|
**(b) Identity can't leak via cookies/headers.** `cookies()` and `headers()`
|
|
721
891
|
THROW during the background capture render — in handlers AND in bake-lane
|
|
722
892
|
loaders (whose containers would bake). A shell that reads them is
|
|
723
|
-
PPR-ineligible by construction; the live lane (`
|
|
893
|
+
PPR-ineligible by construction; the live lane (no `ssr: false`) stays exempt.
|
|
724
894
|
|
|
725
895
|
**(c) Residual hazard — middleware-derived per-user state.** A `ctx` variable
|
|
726
896
|
set by an upstream auth middleware and rendered by shell material is
|
|
@@ -731,8 +901,8 @@ session object (`ctx.get("session")`) never calls `cookies()` itself, so the
|
|
|
731
901
|
guard cannot see it — whatever it returns as settled container data is
|
|
732
902
|
photographed as the CAPTURING user's state. If the
|
|
733
903
|
value is per-user: shell-cache only public/shared pages, keep per-user content
|
|
734
|
-
in nested pending promises or live-lane (`
|
|
735
|
-
bake-lane container — or key per variant at the CDN tier.
|
|
904
|
+
in nested pending promises or live-lane loaders (no `ssr: false`) — NOT in
|
|
905
|
+
plain bake-lane container data — or key per variant at the CDN tier.
|
|
736
906
|
|
|
737
907
|
## What always stays on axis 1
|
|
738
908
|
|
|
@@ -764,7 +934,7 @@ silent.
|
|
|
764
934
|
`createRouter({ nonce })` is the canonical path — supply the nonce THERE, not
|
|
765
935
|
via a token write. The provider value is threaded into the router's own SSR
|
|
766
936
|
machinery: `NonceContext`/`useNonce()`, automatic nonce attributes on
|
|
767
|
-
`<Scripts />` and `<
|
|
937
|
+
`<Html.Scripts />` and `<Html.Meta />` output, and the inlined Flight payload
|
|
768
938
|
scripts. It ALSO sets the `nonce` token, so `ctx.get(nonce)` works in
|
|
769
939
|
middleware and handlers for the CSP response header. A direct
|
|
770
940
|
`ctx.set(nonce, value)` write in middleware is app-managed only: the router
|
|
@@ -814,37 +984,43 @@ A captured shell auto-carries the UNION of the non-loader tags recorded during
|
|
|
814
984
|
the capture render — every `cacheTag(...)` that ran as shell material, whether
|
|
815
985
|
from a `"use cache"` function, a `cache()` segment, or a render-callable
|
|
816
986
|
`cacheTag()` in a plain server component (no `"use cache"`/`cache()` in its
|
|
817
|
-
tree)
|
|
987
|
+
tree), plus the tags of cached loaders on the BAKE lane (they execute during
|
|
988
|
+
capture). Live-lane loader tags (every loader without `ssr: false`) never
|
|
989
|
+
attach — those loaders are masked at capture, and the holes are already live. `ppr.tags` adds
|
|
818
990
|
operational tags the render cannot know (a tenant id, a deploy marker).
|
|
819
991
|
|
|
820
|
-
| Lever
|
|
821
|
-
|
|
|
822
|
-
| `updateTag` / `revalidateTag` on a SHELL tag
|
|
823
|
-
| `updateTag` / `revalidateTag` on a LOADER tag | no — loader tags never attach to a shell
|
|
824
|
-
| `revalidate()` (named revalidation contract)
|
|
992
|
+
| Lever | Reaches the frozen shell? | Reaches the holes? |
|
|
993
|
+
| ------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------- |
|
|
994
|
+
| `updateTag` / `revalidateTag` on a SHELL tag | YES — drops the shell → MISS → recapture | n/a (holes are already live) |
|
|
995
|
+
| `updateTag` / `revalidateTag` on a live-lane LOADER tag | no — live-lane loader tags never attach to a shell | drops that loader's cached value (if it `cache()`s) |
|
|
996
|
+
| `revalidate()` (named revalidation contract) | **no** — re-runs segments/loaders for the PAYLOAD, never HTML | yes — the hole re-renders with fresh data |
|
|
825
997
|
|
|
826
998
|
A server action's automatic invalidation refreshes the CLIENT only — it re-runs
|
|
827
999
|
the holes and streams a fresh payload, but does NOT evict the server shell.
|
|
828
1000
|
Shell-baked data stays stale until the shell's TTL unless you tag-invalidate it
|
|
829
1001
|
(`updateTag` on a shell tag). Data baked into the shell WITHOUT a tag cannot be
|
|
830
|
-
evicted by tag at all — move always-fresh data
|
|
1002
|
+
evicted by tag at all — move always-fresh data into a live-lane loader (no
|
|
1003
|
+
`ssr: false`) or a nested promise.
|
|
831
1004
|
|
|
832
1005
|
## Pitfalls
|
|
833
1006
|
|
|
834
1007
|
- **A bake-lane loader that reads `cookies()`/`headers()`**: the capture is
|
|
835
1008
|
REFUSED (deterministic, once-per-key warned) — the route stays on axis 1.
|
|
836
|
-
Move identity
|
|
1009
|
+
Move identity onto the live lane (drop `ssr: false`, or split the read into
|
|
1010
|
+
a separate unflagged loader; give the reader a boundary). A nested promise
|
|
1011
|
+
does not help here: its body still runs during capture.
|
|
837
1012
|
- **A bake-lane container that must be fresh per document GET**: it is
|
|
838
1013
|
snapshot-pinned for the shell's lifetime by design. Use the live lane
|
|
839
|
-
(`
|
|
1014
|
+
(no `ssr: false`) or a nested promise instead.
|
|
840
1015
|
- **A bake-lane loader slower than `ppr.captureTimeout` (15s by default)**: the
|
|
841
1016
|
capture is refused rather than storing a partial shell. Increase the route's
|
|
842
1017
|
budget only when the deployment can keep the background/build work alive.
|
|
843
1018
|
- **Per-user value in shell material**: baked into the shared shell —
|
|
844
1019
|
deterministically, not by race (handler promises deep-settle at the ring-3
|
|
845
1020
|
write on cached chains; awaited/resolved values bake everywhere). Put
|
|
846
|
-
per-user data in a nested pending promise or a live-lane (
|
|
847
|
-
|
|
1021
|
+
per-user data in a nested pending promise or a live-lane loader (no
|
|
1022
|
+
`ssr: false`) — plain BAKE-lane loader data bakes just like handler
|
|
1023
|
+
material.
|
|
848
1024
|
- **The session-object bake trap (the guard cannot save you here)**: the
|
|
849
1025
|
capture guard sees `cookies()`/`headers()` calls ONLY. A bake-lane loader
|
|
850
1026
|
reading a middleware-provided session object (`ctx.get("session")`) refuses
|
|
@@ -863,7 +1039,8 @@ evicted by tag at all — move always-fresh data under a `loading()` hole.
|
|
|
863
1039
|
The remaining trap is returning per-user data as PLAIN container material:
|
|
864
1040
|
`return { user: session.user }` bakes it into the shared shell like any
|
|
865
1041
|
other settled value — deterministically, not by race. Wrap it in a promise
|
|
866
|
-
(even an already-resolved one) or put the loader on the live lane
|
|
1042
|
+
(even an already-resolved one) or put the loader on the live lane (drop
|
|
1043
|
+
`ssr: false`).
|
|
867
1044
|
|
|
868
1045
|
- **Theme on a HIT is capture-then-corrected**: the resume tree replays the
|
|
869
1046
|
CAPTURE's `initialTheme` (resume requires it to match the frozen prelude);
|
|
@@ -871,19 +1048,22 @@ evicted by tag at all — move always-fresh data under a `loading()` hole.
|
|
|
871
1048
|
re-synced post-mount by ThemeProvider. Nothing to configure — but a themed
|
|
872
1049
|
component in the shell may briefly render the captured theme's markup before
|
|
873
1050
|
the post-mount re-sync.
|
|
874
|
-
- **Shell shows CAPTURE-time data for the shell's lifetime**: a
|
|
875
|
-
cache"` value baked into the shell is PINNED at capture (the
|
|
876
|
-
snapshot) and replayed on every HIT, so the shell stays
|
|
877
|
-
frozen prelude even after that cache entry expires
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1051
|
+
- **Shell shows CAPTURE-time data for the shell's lifetime**: a
|
|
1052
|
+
`cache()`/`"use cache"` value baked into the shell is PINNED at capture (the
|
|
1053
|
+
capture data snapshot) and replayed on every HIT, so the shell stays
|
|
1054
|
+
byte-identical to the frozen prelude even after that cache entry expires or
|
|
1055
|
+
is recomputed. This is deliberate — parity beats freshness inside the shell.
|
|
1056
|
+
Tags are the link back: every tag a `cache()`/`"use cache"` entry carried when
|
|
1057
|
+
the capture read it also rides the shell, so `updateTag` on that tag drops the
|
|
1058
|
+
shell too (next request MISSes and recaptures). An untagged entry has no such
|
|
1059
|
+
link. If a shell region needs to be fresh, put it under a hole — a live-lane
|
|
1060
|
+
loader (no `ssr: false`) behind `loading()` or an inline `<Suspense>`, or an
|
|
1061
|
+
un-awaited promise under the consumer's `<Suspense>`
|
|
881
1062
|
(holes are never pinned) — or make the SHELL itself invalidatable by tagging
|
|
882
|
-
it: call `cacheTag(...)` from the shell-material render
|
|
883
|
-
lever), or add the tag to `ppr.tags` (operational tags
|
|
884
|
-
a tenant id, a deploy marker).
|
|
885
|
-
|
|
886
|
-
the shell untagged. Rango does not warn for that choice; with
|
|
1063
|
+
it: tag the cached read, call `cacheTag(...)` from the shell-material render
|
|
1064
|
+
code (the render-time lever), or add the tag to `ppr.tags` (operational tags
|
|
1065
|
+
the render cannot know — a tenant id, a deploy marker). Tags are optional: if
|
|
1066
|
+
TTL/SWR is the complete freshness policy, leave the shell untagged. Rango does not warn for that choice; with
|
|
887
1067
|
`debugShellCapture` enabled, a stored event reports `untaggedBake: true` when
|
|
888
1068
|
bake-lane loader material uses TTL/SWR-only invalidation.
|
|
889
1069
|
- **Uncached nondeterminism in the shell is a hydration hazard**: a raw
|
|
@@ -906,13 +1086,28 @@ cache"` value baked into the shell is PINNED at capture (the capture data
|
|
|
906
1086
|
500/redirect after the first shell byte — error UI renders inline via
|
|
907
1087
|
Suspense/error boundaries (the same property any streamed SSR page has after
|
|
908
1088
|
its shell flushes).
|
|
1089
|
+
- **A shell component that throws during capture refuses it**: an async
|
|
1090
|
+
server component (or a client component during SSR) inside a Suspense
|
|
1091
|
+
boundary that throws does not fail the capture render, so its errored
|
|
1092
|
+
boundary would bake into the shared prelude. The capture stores nothing
|
|
1093
|
+
instead, logs `[ShellCache] capture` with the error (it goes to `onError`
|
|
1094
|
+
as `cache-write` unless the router already reported that same error during
|
|
1095
|
+
the render), and backs the key off like any refused capture; the next
|
|
1096
|
+
capture after the window stores normally. The request that triggered the
|
|
1097
|
+
capture is served as before. Only a throw during the capture counts: a
|
|
1098
|
+
component that failed in the live render and succeeds when captured is
|
|
1099
|
+
stored.
|
|
909
1100
|
|
|
910
1101
|
## Related
|
|
911
1102
|
|
|
912
1103
|
- `/defer-hydration` — keep the full body HTML in the shell while moving a
|
|
913
1104
|
heavy subtree's hydration off the initial main-thread task (gated boundary,
|
|
914
1105
|
content-as-fallback)
|
|
915
|
-
- `/document-cache` — whole-response
|
|
916
|
-
- `/caching` and `/cache
|
|
1106
|
+
- `/document-cache` — store-backed whole-response caching (no live holes)
|
|
1107
|
+
- `/caching` — `cache()` segment caching and stores; `/use-cache` — function caching
|
|
917
1108
|
- `/shell-manifest` — replayed handles as cache metadata read by live loaders
|
|
918
|
-
-
|
|
1109
|
+
- `/prerender` — `Prerender` + `ppr` bakes the shell at build time
|
|
1110
|
+
- `/cache-guide` — where the shell layer sits among the other cache layers
|
|
1111
|
+
- `/deployment-caching` — why this is in-function PPR, not a CDN shell
|
|
1112
|
+
- Design doc: `docs/design/ppr-shell-resume.md` in the router repository (not
|
|
1113
|
+
shipped in the npm package)
|