@rangojs/router 0.16.0 → 0.18.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 +255 -89
- package/dist/testing/vitest.js +252 -0
- package/dist/types/browser/cookie-name.d.ts +6 -0
- package/dist/types/browser/history-state.d.ts +16 -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-key-utils.d.ts +17 -2
- package/dist/types/cache/cache-policy.d.ts +17 -0
- package/dist/types/cache/cache-scope.d.ts +132 -19
- package/dist/types/cache/cache-tag.d.ts +92 -7
- package/dist/types/cache/cf/cf-base64.d.ts +6 -0
- package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
- package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
- package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
- package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
- package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
- package/dist/types/cache/handle-capture.d.ts +41 -3
- package/dist/types/cache/handle-snapshot.d.ts +54 -13
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- package/dist/types/cache/segment-codec.d.ts +10 -3
- package/dist/types/cache/shell-memo.d.ts +178 -0
- package/dist/types/cache/shell-snapshot.d.ts +110 -21
- package/dist/types/cache/tag-invalidation.d.ts +30 -3
- package/dist/types/cache/taint.d.ts +1 -1
- package/dist/types/cache/types.d.ts +220 -44
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
- 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/prerender/build-shell-capture.d.ts +11 -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/instrument.d.ts +8 -0
- package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
- package/dist/types/router/middleware-types.d.ts +9 -3
- 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 +66 -1
- package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +23 -12
- package/dist/types/router/segment-resolution/mask-nested.d.ts +67 -26
- 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/full-payload.d.ts +14 -0
- package/dist/types/rsc/handler.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-build-manifest.d.ts +8 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +61 -6
- package/dist/types/rsc/shell-capture.d.ts +151 -19
- package/dist/types/rsc/shell-serve.d.ts +140 -17
- package/dist/types/rsc/ssr-setup.d.ts +6 -6
- package/dist/types/rsc/types.d.ts +17 -0
- package/dist/types/server/context.d.ts +139 -15
- package/dist/types/server/cookie-store.d.ts +21 -0
- package/dist/types/server/handle-store.d.ts +26 -2
- package/dist/types/server/request-context.d.ts +182 -53
- package/dist/types/ssr/index.d.ts +24 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/static-handler.d.ts +4 -1
- package/dist/types/testing/flight.entry.d.ts +7 -0
- 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/run-loader.d.ts +18 -3
- package/dist/types/testing/serve-shell-request.d.ts +132 -0
- package/dist/types/testing/shell-status.d.ts +23 -10
- 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 +28 -1
- package/dist/types/types/loader-types.d.ts +8 -6
- package/dist/types/urls/pattern-types.d.ts +22 -18
- 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/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +1420 -1348
- package/package.json +2 -2
- package/skills/api-client/SKILL.md +22 -10
- package/skills/breadcrumbs/SKILL.md +75 -22
- package/skills/bundle-analysis/SKILL.md +22 -22
- package/skills/cache-guide/SKILL.md +306 -124
- package/skills/caching/SKILL.md +530 -98
- package/skills/catalog.json +16 -16
- package/skills/client-urls/SKILL.md +65 -38
- package/skills/cloudflare/SKILL.md +87 -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 +75 -57
- 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 +52 -31
- package/skills/deployment-caching/SKILL.md +11 -3
- package/skills/document-cache/SKILL.md +98 -28
- package/skills/fonts/SKILL.md +16 -8
- package/skills/handler-use/SKILL.md +55 -17
- package/skills/hooks/SKILL.md +28 -20
- package/skills/hooks/data.md +23 -3
- package/skills/hooks/handle-and-actions.md +34 -17
- package/skills/hooks/navigation.md +39 -10
- package/skills/hooks/outlets.md +13 -6
- package/skills/hooks/state.md +39 -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 +386 -128
- package/skills/middleware/SKILL.md +129 -26
- package/skills/migrate-nextjs/SKILL.md +156 -70
- package/skills/migrate-nextjs/backend-host-swap.md +5 -2
- package/skills/migrate-react-router/SKILL.md +51 -39
- package/skills/migrate-react-router/component-migration.md +28 -7
- package/skills/migrate-react-router/data-and-actions.md +21 -6
- package/skills/migrate-react-router/route-mapping.md +19 -10
- package/skills/mime-routes/SKILL.md +12 -7
- package/skills/observability/SKILL.md +92 -14
- package/skills/parallel/SKILL.md +88 -47
- package/skills/ppr/SKILL.md +866 -379
- package/skills/prerender/SKILL.md +210 -108
- package/skills/rango/SKILL.md +115 -65
- package/skills/react-compiler/SKILL.md +6 -5
- package/skills/response-routes/SKILL.md +133 -25
- package/skills/route/SKILL.md +170 -60
- package/skills/router-setup/SKILL.md +420 -240
- package/skills/scripts/SKILL.md +39 -21
- package/skills/server-actions/SKILL.md +231 -59
- package/skills/shell-manifest/SKILL.md +28 -14
- package/skills/streams-and-websockets/SKILL.md +32 -14
- package/skills/tailwind/SKILL.md +37 -33
- package/skills/testing/SKILL.md +73 -33
- package/skills/testing/cache-prerender.md +60 -33
- 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 +26 -6
- 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/serve-shell-request.md +188 -0
- 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 +155 -30
- package/skills/typesafety/SKILL.md +20 -9
- package/skills/typesafety/env-and-bindings.md +137 -50
- 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 +224 -83
- package/skills/vercel/SKILL.md +35 -13
- package/skills/view-transitions/SKILL.md +42 -21
- package/src/__augment-tests__/augmented.check.ts +26 -1
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/history-state.ts +23 -0
- 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 +6 -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 +23 -8
- package/src/cache/cache-policy.ts +22 -0
- package/src/cache/cache-runtime.ts +264 -68
- package/src/cache/cache-scope.ts +419 -43
- package/src/cache/cache-tag.ts +375 -11
- package/src/cache/cf/cf-base64.ts +36 -4
- package/src/cache/cf/cf-cache-constants.ts +12 -0
- package/src/cache/cf/cf-cache-store.ts +990 -348
- package/src/cache/cf/cf-cache-types.ts +32 -4
- package/src/cache/cf/cf-shell-frame.ts +258 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
- package/src/cache/document-cache.ts +15 -0
- package/src/cache/handle-capture.ts +90 -10
- package/src/cache/handle-snapshot.ts +133 -28
- package/src/cache/index.ts +2 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/segment-codec.ts +26 -7
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +217 -27
- package/src/cache/tag-invalidation.ts +108 -22
- package/src/cache/taint.ts +1 -1
- package/src/cache/types.ts +225 -44
- package/src/cache/vercel/vercel-cache-store.ts +545 -61
- 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 +73 -22
- package/src/reverse.ts +44 -4
- package/src/route-definition/dsl-helpers.ts +121 -33
- 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 +71 -28
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +141 -43
- package/src/router/loader-resolution.ts +115 -39
- package/src/router/match-api.ts +40 -26
- package/src/router/match-handlers.ts +5 -0
- package/src/router/match-middleware/background-revalidation.ts +161 -96
- package/src/router/match-middleware/cache-lookup.ts +110 -10
- package/src/router/match-middleware/cache-store.ts +48 -101
- package/src/router/middleware-types.ts +9 -6
- package/src/router/middleware.ts +75 -13
- package/src/router/prerender-match.ts +59 -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 +62 -21
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +407 -76
- package/src/router/segment-resolution/loader-mask.ts +12 -22
- package/src/router/segment-resolution/loader-snapshot.ts +208 -56
- package/src/router/segment-resolution/mask-nested.ts +138 -44
- package/src/router/segment-resolution/revalidation.ts +61 -16
- package/src/router/segment-wrappers.ts +4 -0
- package/src/rsc/full-payload.ts +25 -1
- package/src/rsc/handler.ts +14 -2
- package/src/rsc/progressive-enhancement.ts +3 -2
- package/src/rsc/render-pipeline.ts +3 -0
- package/src/rsc/response-cache-serve.ts +9 -6
- package/src/rsc/response-route-handler.ts +11 -3
- package/src/rsc/rsc-rendering.ts +608 -225
- package/src/rsc/shell-build-manifest.ts +27 -9
- package/src/rsc/shell-capture-constants.ts +73 -6
- package/src/rsc/shell-capture.ts +1068 -296
- package/src/rsc/shell-serve.ts +316 -34
- package/src/rsc/ssr-setup.ts +14 -8
- package/src/rsc/types.ts +18 -0
- package/src/server/context.ts +300 -32
- package/src/server/cookie-store.ts +87 -61
- package/src/server/handle-store.ts +232 -7
- package/src/server/request-context.ts +297 -67
- package/src/ssr/index.tsx +136 -44
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/static-handler.ts +4 -1
- package/src/testing/flight-runtime.d.ts +21 -0
- package/src/testing/flight.entry.ts +14 -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 +68 -6
- package/src/testing/serve-shell-request.ts +378 -0
- package/src/testing/shell-status.ts +34 -12
- package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
- package/src/testing/vitest.ts +62 -1
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/theme/ThemeScript.tsx +3 -3
- package/src/types/cache-types.ts +14 -10
- package/src/types/handler-context.ts +28 -1
- package/src/types/loader-types.ts +8 -6
- package/src/urls/pattern-types.ts +22 -18
- package/src/urls/response-types.ts +3 -23
- package/src/use-loader.tsx +12 -3
- package/src/vite/discovery/shell-prerender-phase.ts +9 -0
- package/src/vite/plugins/use-cache-transform.ts +118 -74
- package/src/vite/rango.ts +7 -3
- package/src/vite/router-discovery.ts +14 -4
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
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
tail rendering. It then resumes React's HTML renderer for just the live holes.
|
|
13
|
+
The browser sees one ordinary streamed document; live loaders (no `ssr: false`)
|
|
14
|
+
stay fresh on every request. A normal render (no shell) is untouched, and every
|
|
15
|
+
request the shell cannot serve falls open to it.
|
|
16
|
+
|
|
17
|
+
Use it when a page has a stable layout that should reach the browser instantly
|
|
18
|
+
(the first byte comes from cache) while specific regions stay per-request:
|
|
19
|
+
prices, a basket, recommendations. Opt in per page route with the `ppr` path
|
|
20
|
+
option; the holes are decided by the shape of your tree (see "The hole
|
|
21
|
+
doctrine").
|
|
16
22
|
|
|
17
23
|
Compare `/document-cache`, which freezes the WHOLE response including loader
|
|
18
|
-
output
|
|
19
|
-
|
|
24
|
+
output, and the `cache()` DSL (`/caching`), which caches Flight segments but
|
|
25
|
+
still renders HTML on every request. Shell caching is for pages that mix a
|
|
26
|
+
stable shell with live data: the shell is shared per host+URL (and per request
|
|
27
|
+
partition, when the route's `cache({ key })` or the store's `keyGenerator`
|
|
28
|
+
partitions its record), the holes are 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
|
|
40
|
+
build-time shell capture bakes the HTML shell while loaders without
|
|
41
|
+
`ssr: false` stay 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 renders normally (no shell) with a once-per-key warning.
|
|
43
55
|
|
|
44
56
|
```typescript
|
|
45
57
|
import { createRouter, urls } from "@rangojs/router";
|
|
@@ -61,95 +73,169 @@ 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
|
-
|
|
75
|
-
|
|
85
|
+
The live price above is a hole because it is LOADER data read under a
|
|
86
|
+
boundary. Everything the handler layer produces is shell material, the way it
|
|
87
|
+
is under `cache()`:
|
|
76
88
|
|
|
77
|
-
###
|
|
89
|
+
### Handler output always bakes, promises included
|
|
78
90
|
|
|
79
|
-
A
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
boundary; the boundary postpones at capture and becomes the hole:
|
|
91
|
+
A promise a handler passes to a component under `<Suspense>` is NOT a hole. The
|
|
92
|
+
capture waits for it, bakes the settled value into the prelude, and serves that
|
|
93
|
+
value for the shell's lifetime:
|
|
83
94
|
|
|
84
95
|
```typescript
|
|
85
|
-
// Handler:
|
|
96
|
+
// Handler: the fetch is not awaited here, but the capture awaits it.
|
|
86
97
|
function ProductPage(ctx: HandlerContext) {
|
|
87
|
-
const reviews = fetchReviews(ctx.params.id); // Promise<Review[]>
|
|
98
|
+
const reviews = fetchReviews(ctx.params.id); // Promise<Review[]>
|
|
88
99
|
return (
|
|
89
100
|
<main>
|
|
90
|
-
<h1>Product {ctx.params.id}</h1> {/* shell
|
|
91
|
-
<ReviewsSection promise={reviews} /> {/*
|
|
101
|
+
<h1>Product {ctx.params.id}</h1> {/* shell */}
|
|
102
|
+
<ReviewsSection promise={reviews} /> {/* shell too: the capture waits for
|
|
103
|
+
`reviews`, and every HIT shows
|
|
104
|
+
the capture's reviews */}
|
|
92
105
|
</main>
|
|
93
106
|
);
|
|
94
107
|
}
|
|
95
108
|
```
|
|
96
109
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
The same holds for an async server component (with or without a `<Suspense>`
|
|
111
|
+
above it), a promise nested in a handle the handler pushes
|
|
112
|
+
(`ctx.use(Handle)({ x: promise })`), and a loader the handler awaits
|
|
113
|
+
(`await ctx.use(Loader)`). The capture waits for all of it, bounded by ONE
|
|
114
|
+
deadline, `ppr.captureTimeout` (15s by default); handler output that has not
|
|
115
|
+
settled by then stores no shell (the once-per-key warning names the cause: "did
|
|
116
|
+
not settle within ppr.captureTimeout"). A shell HIT never runs a handler: it
|
|
117
|
+
replays the handler layer the capture recorded.
|
|
118
|
+
|
|
119
|
+
To keep a region fresh per request, load it in a loader WITHOUT `ssr: false`
|
|
120
|
+
and read it with `useLoader` under `loading()` or an inline `<Suspense>` (a live
|
|
121
|
+
hole, see "The hole doctrine"); inside a `loader(Def, { ssr: false })`, return
|
|
122
|
+
the live part as a nested promise.
|
|
123
|
+
|
|
124
|
+
A route WITHOUT the `ppr` option renders normally (no shell): no store read, no
|
|
125
|
+
capture, no logs, zero cost. `ppr` is per page route — declaring it on a layout
|
|
126
|
+
is not supported (subtree inheritance is a possible follow-up).
|
|
127
|
+
|
|
128
|
+
### Migration from the old promise-hole model
|
|
129
|
+
|
|
130
|
+
Earlier versions treated a pending handler promise under `<Suspense>` as a hole
|
|
131
|
+
and re-ran handlers on some HITs. That is gone:
|
|
132
|
+
|
|
133
|
+
Everything a handler produces is shell material, as it is under `cache()`: a
|
|
134
|
+
promise it passes to a component under `<Suspense>`, an async server component,
|
|
135
|
+
a nested promise in a handle it pushes, and a loader it awaits are all awaited
|
|
136
|
+
at capture (bounded by `ppr.captureTimeout`) and served frozen for the shell's
|
|
137
|
+
lifetime. A HIT never runs a handler. To keep a value fresh per request, load
|
|
138
|
+
it in a loader without `ssr: false` and read it with `useLoader` under
|
|
139
|
+
`loading()` or an inline `<Suspense>`; inside an `ssr: false` loader, return
|
|
140
|
+
that part as a nested promise. Request-scoped reads the capture now waits for
|
|
141
|
+
refuse it: `cookies()`, `headers()`, a `{ cache: false }` variable, and
|
|
142
|
+
`ctx.dynamic()` inside a handler promise, an async component, a handle push, or
|
|
143
|
+
a loader a handler awaits. A normal `ctx.get()` value is not guarded: shell
|
|
144
|
+
material is shared per host+URL and request partition. `cache(false)`, or a
|
|
145
|
+
`condition()` that returns false, on a ppr route now renders the request like a
|
|
146
|
+
cache miss (no shell).
|
|
147
|
+
|
|
148
|
+
### Request-partitioned shells
|
|
149
|
+
|
|
150
|
+
A route whose `cache()` record is partitioned by the request partitions its
|
|
151
|
+
shell the same way: the route's `cache({ key })`, or the store's
|
|
152
|
+
`keyGenerator`, decides which shell a request reads, captures and replays.
|
|
153
|
+
Each partition captures its own on its first request and never serves
|
|
154
|
+
another's:
|
|
102
155
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
<Inner promise={promise} />
|
|
110
|
-
</Suspense>
|
|
111
|
-
);
|
|
112
|
-
}
|
|
156
|
+
```tsx
|
|
157
|
+
cache(
|
|
158
|
+
{ ttl: 300, key: (ctx) => `tier:${ctx.request.headers.get("x-tier")}` },
|
|
159
|
+
() => [path("/pricing", PricingPage, { ppr: true })],
|
|
160
|
+
);
|
|
161
|
+
// gold and silver visitors each get their own /pricing shell.
|
|
113
162
|
```
|
|
114
163
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
164
|
+
The partition follows nested scopes (issue #970): a `ppr` route under a
|
|
165
|
+
`cache()` nested in a keyed one is partitioned by the outer `key()` when the
|
|
166
|
+
inner boundary has none, and by both composed (outermost first, each
|
|
167
|
+
URI-encoded, joined by `|`) when it has its own. The route's record adds the
|
|
168
|
+
inner boundary's own default key when it has no `key`; the shell partition
|
|
169
|
+
does not, since the shell key already carries the URL (a store
|
|
170
|
+
`keyGenerator`'s result is kept when it adds anything). See `/caching`,
|
|
171
|
+
"Keys nest".
|
|
119
172
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
173
|
+
```tsx
|
|
174
|
+
cache({ ttl: 300, key: (ctx) => `tier:${tierOf(ctx)}` }, () => [
|
|
175
|
+
layout(TierLayout, () => [
|
|
176
|
+
cache({ ttl: 60 }, () => [path("/pricing", PricingPage, { ppr: true })]),
|
|
177
|
+
]),
|
|
178
|
+
]);
|
|
179
|
+
// gold and silver still get their own /pricing shell and record.
|
|
180
|
+
```
|
|
127
181
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
182
|
+
This is the sanctioned way to vary shell material by request: the capture
|
|
183
|
+
guards still refuse `cookies()`/`headers()` in handler work, while the key
|
|
184
|
+
function reads what it needs (`cookies()` included). A `key()` runs once per
|
|
185
|
+
request: the shell read, the record lookup and the capture share its result
|
|
186
|
+
(each `key()` of a nested chain once).
|
|
187
|
+
One that throws serves no shell (like the record path renders uncached); a
|
|
188
|
+
store `keyGenerator` that returns the default key unchanged partitions
|
|
189
|
+
nothing. A partitioned route never serves a build-time shell (the build
|
|
190
|
+
captured one partition): each partition captures at runtime, and when the
|
|
191
|
+
route has a build shell a once-per-route warning says so. A `keyGenerator`
|
|
192
|
+
that returns the default key unchanged keeps the build shell.
|
|
193
|
+
|
|
194
|
+
Keep partition values to a small, known set: normalize what the key reads
|
|
195
|
+
(a tier, a locale) to the values you serve before returning it. Every
|
|
196
|
+
distinct value is a full capture and a stored shell, so a key that returns
|
|
197
|
+
raw header or cookie text lets any client mint new shells.
|
|
198
|
+
|
|
199
|
+
Query strings multiply shells the same way: the shell key includes the sorted
|
|
200
|
+
search, so a PDP reached as `/products/1?color=red&size=m` and
|
|
201
|
+
`?color=blue&size=m` has one shell per distinct query, and every appended
|
|
202
|
+
tracking param (`utm_*`, a click id) mints another. The router's
|
|
203
|
+
`cache.searchParams` option (`createRouter({ cache: { store, searchParams } })`)
|
|
204
|
+
drops params from the key, shell included
|
|
205
|
+
(`{ exclude: TRACKING_SEARCH_PARAMS }`, or an `{ include: [...] }` allowlist;
|
|
206
|
+
see `/caching`, "Search param key filtering"). Exclude only a param the shell
|
|
207
|
+
does not render from: otherwise the first variant captured is served for every
|
|
208
|
+
value of it.
|
|
209
|
+
|
|
210
|
+
In tests, `serveShellRequest` reports the key the serve path resolved
|
|
211
|
+
(`result.key`, partition included); `shellCacheKey(url, searchParams,
|
|
212
|
+
partition)` builds the same key from the partition. Under nested keyed
|
|
213
|
+
scopes, pass the `key()` results as an array, outermost first
|
|
214
|
+
(`shellCacheKey(url, undefined, ["tier:gold", "v:a"])`).
|
|
131
215
|
|
|
132
216
|
## Where PPR sits: the cache onion
|
|
133
217
|
|
|
134
|
-
Rango's caches layer like an onion — each
|
|
218
|
+
Rango's caches layer like an onion — each layer stores a progressively more
|
|
135
219
|
"cooked" representation of the same page. From innermost (raw values) to
|
|
136
220
|
outermost (final bytes):
|
|
137
221
|
|
|
138
|
-
|
|
|
222
|
+
| Layer | Primitive | What is stored | What stays live on a hit |
|
|
139
223
|
| ----------------------- | ---------------------------------------- | -------------------------------------------------- | -------------------------------------- |
|
|
140
224
|
| 1. Function values | `"use cache"` | a function's return value | everything around the call |
|
|
141
225
|
| 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 `
|
|
226
|
+
| 3. Segments (Flight) | `cache()` route / build-time `Prerender` | serialized rendered segments + replayed handles | loaders, HTML render |
|
|
143
227
|
| 4. **HTML shell (PPR)** | `ppr` path option | rendered prelude bytes + React postponed state | the holes, hydration payload |
|
|
144
228
|
| 5. Whole response | `/document-cache` | final response bytes, headers included | nothing — all-or-nothing |
|
|
145
229
|
|
|
146
|
-
PPR is ORTHOGONAL to `cache()` (
|
|
147
|
-
handlers run
|
|
148
|
-
segments
|
|
149
|
-
|
|
150
|
-
|
|
230
|
+
PPR is ORTHOGONAL to `cache()` (the segments layer): a ppr route may be
|
|
231
|
+
uncached (its handlers run on a MISS and during capture), fully `cache()`d
|
|
232
|
+
(the capture replays its segments), or mixed. Either way a HIT runs no
|
|
233
|
+
handler: the shell entry is itself a `cache()` of the handler layer, recorded
|
|
234
|
+
by the capture and replayed on every HIT. That is why handler promises cannot stay live under ppr:
|
|
235
|
+
the segment codec **deep-settles promises** when it writes a segment record,
|
|
236
|
+
under `cache()` and under the shell alike.
|
|
151
237
|
|
|
152
|
-
Invalidation crosses
|
|
238
|
+
Invalidation crosses layers: `updateTag()`/`revalidateTag()` reach segment,
|
|
153
239
|
shell, loader, and item entries in the same store, and shell entries
|
|
154
240
|
additionally self-invalidate on `React.version` change.
|
|
155
241
|
|
|
@@ -163,49 +249,99 @@ On a document GET to a ppr route the router runs:
|
|
|
163
249
|
them: any rejection/redirect/401 wins before a single shell byte, on MISS
|
|
164
250
|
and on a warmed HIT alike;
|
|
165
251
|
3. **shell lookup** — `getShell(key)` on the app store (key =
|
|
166
|
-
host+pathname+sorted search)
|
|
252
|
+
host+pathname+sorted search). `CFCacheStore` stores the entry prelude-first
|
|
253
|
+
and the serve path reads only its head and prelude here, with the tag-marker
|
|
254
|
+
read running alongside; the capture snapshot is read off the commit path.
|
|
255
|
+
`CFCacheStore` and `VercelCacheStore` keep the last fresh read of each shell
|
|
256
|
+
in a memo for `memo.shellMs` (default 2 s): a warm isolate's next HITs skip
|
|
257
|
+
the store read, but never the tag-marker check, which reads its markers
|
|
258
|
+
through a per-isolate stale-while-revalidate memo (`memo.markerFreshMs`,
|
|
259
|
+
`memo.markerMaxStaleMs`). The user whose request ran `updateTag()` /
|
|
260
|
+
`revalidateTag()` gets a fresh-reads cookie that skips both memos; past
|
|
261
|
+
them each store's own consistency applies (`VercelCacheStore`: fresh in
|
|
262
|
+
any region, `expireTag` is global; `CFCacheStore` with KV: fresh in the
|
|
263
|
+
colo that ran it, other colos once KV propagates the marker and
|
|
264
|
+
`tagCacheTtl` expires; KV-less purge mode: once the purge reaches the colo).
|
|
265
|
+
Other users see the invalidation once the marker memo refreshes (KV-less
|
|
266
|
+
purge mode and another Vercel region: once the shell window passes; see the
|
|
267
|
+
caching skill);
|
|
167
268
|
4. **HIT** — the composed response is committed immediately: the stored prelude
|
|
168
|
-
bytes flush first, while segment resolution, the
|
|
169
|
-
full hydration payload — there is no Flight-side resume), and
|
|
170
|
-
`resume` of just the holes run BEHIND them inside the response
|
|
171
|
-
|
|
269
|
+
bytes flush first (in 32 KB chunks), while segment resolution, the Flight
|
|
270
|
+
render (the full hydration payload — there is no Flight-side resume), and
|
|
271
|
+
the fizz `resume` of just the holes run BEHIND them inside the response
|
|
272
|
+
stream, starting a macrotask after the commit so they cannot delay the
|
|
273
|
+
prelude's write. Segment resolution replays the handler layer from the
|
|
274
|
+
shell's own recorded segments — NO handler runs on a HIT — and runs the
|
|
275
|
+
live loaders fresh (a promise-free bake-lane loader is served from the
|
|
276
|
+
shell, see "On a shell HIT");
|
|
277
|
+
5. **MISS** — a normal render (no shell), tagged `x-rango-shell: MISS`, plus a
|
|
172
278
|
background capture (stampede-guarded, retry-in-place, exponential backoff).
|
|
173
279
|
|
|
174
280
|
`x-rango-shell: HIT | MISS` is the observability header. Because the commit
|
|
175
281
|
point is after the chain, an unauthorized request NEVER sees shell bytes — put
|
|
176
282
|
auth middleware anywhere (global or route DSL) and it guards PPR for free.
|
|
177
283
|
|
|
284
|
+
A route whose own `cache()` scope refuses THIS request — `cache(false)`, or a
|
|
285
|
+
`condition()` that returns false — skips steps 3-5: the document renders like a
|
|
286
|
+
cache miss (a normal render, no shell), with no `x-rango-shell` header and no
|
|
287
|
+
capture scheduled.
|
|
288
|
+
|
|
289
|
+
If a HIT cannot read the shell's recorded segments, it never falls back to
|
|
290
|
+
running handlers behind the committed prelude: it ends the response with a
|
|
291
|
+
small script that reloads the page once with a `_rsc_shell=miss` query marker,
|
|
292
|
+
and a request carrying the marker renders like a cache miss (no shell, no
|
|
293
|
+
capture), so the reload cannot repeat. The router drops the marker from the
|
|
294
|
+
request before anything reads it (middleware, handlers, loaders, cache keys
|
|
295
|
+
and `useSearchParams` see the clean URL), and the browser drops it from the
|
|
296
|
+
address bar before the page hydrates. When the stored value is corrupt it
|
|
297
|
+
also replaces the entry with a placeholder the next request treats as a MISS
|
|
298
|
+
and schedules a recapture that heals the key; a snapshot read that was only
|
|
299
|
+
slow leaves the entry alone.
|
|
300
|
+
|
|
178
301
|
### Soft navigation caches and reuses the handler layer
|
|
179
302
|
|
|
303
|
+
In short: client-side (soft) navigations to a `ppr` route reuse the handler
|
|
304
|
+
segments captured with the shell, while loaders without `ssr: false` stay live.
|
|
305
|
+
Nothing needs configuring; the rest of this section explains the
|
|
306
|
+
`x-rango-ppr-replay` header for when you are debugging replay behavior.
|
|
307
|
+
|
|
180
308
|
Ordinary partial RSC navigations to a `ppr` URL use the same handler-layer cache
|
|
181
309
|
contract even when no document request has captured an HTML shell yet. When a
|
|
182
|
-
shell snapshot exists, the server replays
|
|
310
|
+
shell snapshot exists, the server replays the page's recorded segments from it.
|
|
183
311
|
On a cold partial request, normal matching renders the response and schedules a
|
|
184
312
|
background navigation-only shell capture; later navigations and prefetches
|
|
185
313
|
replay its eligible snapshot. In both cases `matchPartial()`:
|
|
186
314
|
|
|
187
315
|
- preserves client-owned shared layouts by segment id;
|
|
188
316
|
- returns only new or revalidating destination segments;
|
|
189
|
-
- runs DSL loaders fresh with their normal `loading()` streaming
|
|
317
|
+
- runs live DSL loaders fresh with their normal `loading()` streaming
|
|
318
|
+
behavior, and serves bake-lane loaders from the pins of a shell captured by a
|
|
319
|
+
document request; a snapshot captured by a navigation alone carries no pins,
|
|
320
|
+
and its replay runs them fresh (see below);
|
|
190
321
|
- keeps the existing prefetch key, source scope, and in-flight lock unchanged.
|
|
191
322
|
|
|
192
323
|
This is deliberately invisible to the browser: the response is the same
|
|
193
324
|
`RscPayload` shape as any other partial navigation. Captured item/response values
|
|
194
|
-
|
|
195
|
-
|
|
325
|
+
are NOT replayed on this path. Bake-lane loader pins are, when the replayed
|
|
326
|
+
snapshot is a shell a document request captured, as on a document HIT: a
|
|
327
|
+
promise-free `ssr: false` loader is served from its pin without running
|
|
328
|
+
(unless the capture marked it to run: see "On a shell HIT"), and one whose
|
|
329
|
+
return holds promises runs with its baked parts overlaid. A snapshot captured
|
|
330
|
+
by a navigation alone carries no pins, and its replay runs them fresh. Loaders
|
|
331
|
+
without `ssr: false` stay live.
|
|
196
332
|
|
|
197
333
|
A route's own `cache()` scope — including one inherited from an ancestor, the
|
|
198
334
|
common app-wide storefront shape — COMPOSES with replay instead of disabling
|
|
199
335
|
it. The explicit tier stays authoritative: its lookup runs first with its
|
|
200
336
|
normal store, key, TTL, SWR, tags, and condition, and when it supplies the
|
|
201
337
|
match the response reports `BYPASS; reason=explicit-cache-hit` (never a false
|
|
202
|
-
replay `HIT`). Only when the explicit tier misses
|
|
203
|
-
|
|
204
|
-
possible, a capture of such a route records the
|
|
205
|
-
shell snapshot IN ADDITION to the scope's normal store write;
|
|
338
|
+
replay `HIT`). Only when the explicit tier misses do the page's recorded
|
|
339
|
+
segments in the shell snapshot supply the match and report `HIT`. To make that
|
|
340
|
+
possible, a capture of such a route records the page's segments into the
|
|
341
|
+
shell snapshot IN ADDITION to the scope's normal store write; that record rides
|
|
206
342
|
only inside the shell entry, never the real store. Two opt-outs stay absolute:
|
|
207
343
|
`cache(false)` and a `condition()` returning false mean "do not serve this
|
|
208
|
-
request's segments from any cache" — no
|
|
344
|
+
request's segments from any cache" — no segments are recorded for them and
|
|
209
345
|
the seeded fallback never rescues a refused read. `cache(false)` is static, so
|
|
210
346
|
replay reports `BYPASS; reason=cache-disabled` before performing a single
|
|
211
347
|
shell read; a `condition()` refusal is request-time state, decided at the
|
|
@@ -213,15 +349,16 @@ lookup itself and reported as the same `cache-disabled` post-match (the gate
|
|
|
213
349
|
must not pre-decide a predicate that could flap between the two evaluations).
|
|
214
350
|
An errored explicit read — a throwing `key()`, or a built-in store's swallowed
|
|
215
351
|
backend failure (`CACHE_READ_ERROR`) — also never falls back: it renders
|
|
216
|
-
uncached, exactly as without ppr.
|
|
352
|
+
uncached, exactly as without ppr. (Document requests treat the same two
|
|
353
|
+
opt-outs as a cache miss before any shell read: see the serve pipeline above.)
|
|
217
354
|
|
|
218
355
|
`transition({ when })` is evaluated from the
|
|
219
356
|
matched manifest before route handlers on every PPR match, so it can vary by
|
|
220
357
|
URL/params/action or middleware context without disabling replay; handler-set
|
|
221
|
-
context is unavailable by design. Intercepts,
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
358
|
+
context is unavailable by design. Intercepts, an active nonce, and an
|
|
359
|
+
absent/corrupt segment snapshot fall open to the ordinary partial path when
|
|
360
|
+
encountered by the shell capture. A transition already replayed from an
|
|
361
|
+
explicit cache tier remains frozen by that tier's normal semantics.
|
|
225
362
|
|
|
226
363
|
Two more decisions are made before any shell-store read, so probes and
|
|
227
364
|
prerendered routes never spend passive `getShell` I/O:
|
|
@@ -232,8 +369,8 @@ prerendered routes never spend passive `getShell` I/O:
|
|
|
232
369
|
with this in mind — such probes are not cache misses.
|
|
233
370
|
- A `Prerender()` route's partial is served from the build-time prerender
|
|
234
371
|
store inside matching (a better-than-HIT outcome); it reports
|
|
235
|
-
`BYPASS; reason=prerender-store`. Its captures never record
|
|
236
|
-
|
|
372
|
+
`BYPASS; reason=prerender-store`. Its captures never record the page's
|
|
373
|
+
segments (the prerender store short-circuits the cache write), so replay
|
|
237
374
|
seeding would be impossible anyway.
|
|
238
375
|
|
|
239
376
|
Fresh and stale-within-SWR runtime shells replay. The stale read is passive: it
|
|
@@ -276,17 +413,17 @@ consumer-invisible.
|
|
|
276
413
|
|
|
277
414
|
The bounded bypass tokens, grouped by when they are decided:
|
|
278
415
|
|
|
279
|
-
| Token | Decided | Meaning
|
|
280
|
-
| --------------------------------------------------------------------------------- | ----------------- |
|
|
281
|
-
| `method`, `dynamic`, `nonce`, `store-unavailable`, `passive-read-unsupported` | pre-read | request/route/store ineligible for replay
|
|
282
|
-
| `no-navigation-context` | pre-read | no `X-RSC-Router-Client-Path`/`Referer`; a partial match is impossible
|
|
283
|
-
| `prerender-store` | pre-read or match | `Prerender()` route served by its baked artifact (pre-read probe of the normal variant; reclassified post-match when the store actually served, either variant)
|
|
284
|
-
| `intercept` | match | the navigation resolved to an intercept — replay is never armed for intercepts (they keep their normal cache path); no heal capture
|
|
285
|
-
| `cache-disabled` | pre-read or match | `cache(false)` (pre-read, static) or `condition()` false (decided at the lookup); consumer opt-out is absolute
|
|
286
|
-
| `read-error`, `no-entry`, `invalid-version`, `corrupt-entry`, `stale-build-entry` | shell read | no usable shell entry (`no-entry`/`invalid-version`/`corrupt-entry`/`stale-build-entry` schedule the navigation-only heal capture)
|
|
287
|
-
| `
|
|
288
|
-
| `explicit-cache-hit` | match | the route's own `cache()` tier supplied the match
|
|
289
|
-
| `snapshot-miss` | match | an eligible snapshot was seeded but matching did not consume it
|
|
416
|
+
| Token | Decided | Meaning |
|
|
417
|
+
| --------------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
418
|
+
| `method`, `dynamic`, `nonce`, `store-unavailable`, `passive-read-unsupported` | pre-read | request/route/store ineligible for replay |
|
|
419
|
+
| `no-navigation-context` | pre-read | no `X-RSC-Router-Client-Path`/`Referer`; a partial match is impossible |
|
|
420
|
+
| `prerender-store` | pre-read or match | `Prerender()` route served by its baked artifact (pre-read probe of the normal variant; reclassified post-match when the store actually served, either variant) |
|
|
421
|
+
| `intercept` | match | the navigation resolved to an intercept — replay is never armed for intercepts (they keep their normal cache path); no heal capture |
|
|
422
|
+
| `cache-disabled` | pre-read or match | `cache(false)` (pre-read, static) or `condition()` false (decided at the lookup); consumer opt-out is absolute |
|
|
423
|
+
| `read-error`, `no-entry`, `invalid-version`, `corrupt-entry`, `stale-build-entry` | shell read | no usable shell entry (`no-entry`/`invalid-version`/`corrupt-entry`/`stale-build-entry` schedule the navigation-only heal capture) |
|
|
424
|
+
| `no-segment-snapshot` | eligibility | entry exists but its snapshot has no recorded segments for the page; on a route with an enabled `cache()` scope, it heals when the lookup did not refuse (a `condition()` false-at-capture entry becomes replayable) |
|
|
425
|
+
| `explicit-cache-hit` | match | the route's own `cache()` tier supplied the match |
|
|
426
|
+
| `snapshot-miss` | match | an eligible snapshot was seeded but matching did not consume it |
|
|
290
427
|
|
|
291
428
|
### Capture-generation invalidation
|
|
292
429
|
|
|
@@ -298,11 +435,14 @@ out of render code if you want the shell to persist.
|
|
|
298
435
|
|
|
299
436
|
### Opting out per request with `ctx.dynamic()`
|
|
300
437
|
|
|
301
|
-
Middleware and handlers can call `ctx.dynamic()` to force this request
|
|
302
|
-
|
|
303
|
-
shell lookup, HIT serving, and MISS capture for that request.
|
|
438
|
+
Middleware and handlers can call `ctx.dynamic()` to force this request onto a
|
|
439
|
+
normal render (no shell). In middleware it runs before the PPR commit point, so
|
|
440
|
+
the router skips shell lookup, HIT serving, and MISS capture for that request.
|
|
441
|
+
In handlers it is
|
|
304
442
|
too late to prevent a MISS render from already happening, but it still prevents
|
|
305
|
-
the follow-up shell capture.
|
|
443
|
+
the follow-up shell capture. A `ctx.dynamic()` call that only happens during the
|
|
444
|
+
capture itself (for example inside a handler promise, after an await) refuses
|
|
445
|
+
that capture: a dynamic render has no shell.
|
|
306
446
|
|
|
307
447
|
During `Prerender` + `ppr` build-shell capture, middleware is replayed with
|
|
308
448
|
`ctx.build === true`, `ctx.waitUntil()` inert, and the same `ctx.dynamic()`
|
|
@@ -321,7 +461,7 @@ the write — a write before it still throws.
|
|
|
321
461
|
```ts
|
|
322
462
|
function catalogPage(ctx) {
|
|
323
463
|
ctx.dynamic(); // declare live -> refuses capture AND clears the header latch
|
|
324
|
-
ctx.headers.set("x-
|
|
464
|
+
ctx.headers.set("x-catalog-mode", "live");
|
|
325
465
|
return <Catalog />;
|
|
326
466
|
}
|
|
327
467
|
```
|
|
@@ -339,9 +479,10 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
|
|
|
339
479
|
- Runtime-captured route: first document GET is `MISS`, plus a background
|
|
340
480
|
capture; a later request becomes a `HIT`.
|
|
341
481
|
- `Prerender + ppr` route: the shell is produced during `vite build`, so the
|
|
342
|
-
first production document request can already be a `HIT`. In dev,
|
|
343
|
-
runs on demand and can return a `HIT`; if capture
|
|
344
|
-
foreground wait, the request falls open to `MISS` and
|
|
482
|
+
first production document request can already be a `HIT`. In dev, the
|
|
483
|
+
build-time shell capture runs on demand and can return a `HIT`; if capture
|
|
484
|
+
outlasts the bounded foreground wait, the request falls open to `MISS` and
|
|
485
|
+
runtime capture.
|
|
345
486
|
- Production (workerd/node): the SECOND request is a `HIT`.
|
|
346
487
|
- Dev: expect a few extra MISSes — cold module transforms abort the capture
|
|
347
488
|
window (per-attempt breadcrumbs: start the server with
|
|
@@ -352,8 +493,9 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
|
|
|
352
493
|
your baked shell, with hole fallbacks in place), then
|
|
353
494
|
`<div hidden id="S:0">…` segments as the holes resume, per request.
|
|
354
495
|
- A ppr-declared route that CANNOT be honored (missing shell store family,
|
|
355
|
-
per-request nonce) serves
|
|
356
|
-
key — no header + a declared `ppr` means look for that
|
|
496
|
+
per-request nonce) serves a normal render (no shell) with NO header and
|
|
497
|
+
warns once per key — no header + a declared `ppr` means look for that
|
|
498
|
+
warning.
|
|
357
499
|
- On Cloudflare, `CFCacheStore` reads PPR shells from the per-colo Cache API,
|
|
358
500
|
falls through to KV on a miss, and promotes the KV hit back into that colo.
|
|
359
501
|
WITHOUT a KV namespace the family runs L1-only (edge-only ppr): every colo
|
|
@@ -377,23 +519,28 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
|
|
|
377
519
|
captures outrank queued navigation-only snapshots, but never interrupt the
|
|
378
520
|
active capture — the skip event and the `rango.background` span carry
|
|
379
521
|
`queuePriority`/`queueAhead` (class and backlog at enqueue) so a parked
|
|
380
|
-
capture diagnoses itself. A stored attempt reports `
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
`
|
|
522
|
+
capture diagnoses itself. A stored attempt reports `recordSettleMs`
|
|
523
|
+
(`record=` in the log line): how long the capture waited for the handler
|
|
524
|
+
layer to settle (promises it passes or pushes, async server components,
|
|
525
|
+
loaders it awaits) and its segment record to encode; `bakeWaitMs` (`bake=`):
|
|
526
|
+
how long the slowest remaining bake source (a top-level pushed handle promise
|
|
527
|
+
or a bake-lane loader container) held the capture gate; and `entryBytes`
|
|
528
|
+
(`entry=`): the stored entry's size. In dev, past 2s, the slow source is also
|
|
529
|
+
named once per key in a console warning with the remedies (`cache()` the
|
|
530
|
+
work / move it into a live loader / return it as a nested promise or drop
|
|
531
|
+
`ssr: false` in a bake-lane loader). In dev, with
|
|
532
|
+
`debugPerformance` on, the last capture outcome for a key also rides the
|
|
533
|
+
next document GET's `Server-Timing` as `ppr-capture;desc="…"`.
|
|
387
534
|
- For deployed Cloudflare tier diagnostics, build with
|
|
388
535
|
`INTERNAL_RANGO_DEBUG=1` and run `wrangler tail`. `[CFCacheStore][shell]`
|
|
389
|
-
JSON events distinguish L1 hit/miss, KV fallback/promotion, tier
|
|
390
|
-
marker rejection, with `cf-ray`/colo and read timings. The flag is baked at
|
|
536
|
+
JSON events distinguish memo hits, L1 hit/miss, KV fallback/promotion, tier
|
|
537
|
+
writes, and marker rejection, with `cf-ray`/colo and read timings. The flag is baked at
|
|
391
538
|
build time; setting only a Worker runtime variable is too late.
|
|
392
539
|
|
|
393
540
|
### Unit / integration testing (public primitives)
|
|
394
541
|
|
|
395
542
|
Import from `@rangojs/router/testing` (Vitest) or `@rangojs/router/testing/e2e`
|
|
396
|
-
(Playwright)
|
|
543
|
+
(Playwright); `serveShellRequest` from `@rangojs/router/testing/flight`:
|
|
397
544
|
|
|
398
545
|
| Helper | Use for |
|
|
399
546
|
| ------------------------------------------------- | ------------------------------------------------------------ |
|
|
@@ -402,6 +549,7 @@ Import from `@rangojs/router/testing` (Vitest) or `@rangojs/router/testing/e2e`
|
|
|
402
549
|
| `parsePprReplayStatus(res)` | Read structured replay/bypass status or null |
|
|
403
550
|
| `shellCacheKey(url)` | Production store key for `store.getShell` / custom stores |
|
|
404
551
|
| `MemorySegmentCacheStore` + `getShell`/`putShell` | Custom store contract / tag eviction (no faked HIT) |
|
|
552
|
+
| `serveShellRequest(router, url, opts)` | A real MISS → capture → HIT (react-server Vitest) |
|
|
405
553
|
|
|
406
554
|
```ts
|
|
407
555
|
import { MemorySegmentCacheStore } from "@rangojs/router/cache";
|
|
@@ -417,53 +565,172 @@ expect(await store.getShell(key)).not.toBeNull();
|
|
|
417
565
|
assertShellStatus({ headers: new Headers(res.headers()) }, "HIT");
|
|
418
566
|
```
|
|
419
567
|
|
|
420
|
-
**Out of unit scope** (stay e2e):
|
|
421
|
-
browser resume of holes, build-time
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
`
|
|
568
|
+
**Out of unit scope** (stay e2e): real HTML (the prelude bytes and the fizz
|
|
569
|
+
resume), browser resume of holes, the build-time shell capture. A MISS →
|
|
570
|
+
capture → HIT runs in a unit test through `serveShellRequest` from
|
|
571
|
+
`@rangojs/router/testing/flight`: the shell as captured is `result.prelude`,
|
|
572
|
+
the live tail is `result.flight`. "A HIT runs no handler and shows the
|
|
573
|
+
capture's handler output" is assertable there: count handler runs or render a
|
|
574
|
+
per-run stamp, then compare the MISS and a later HIT. `dispatch` never runs
|
|
575
|
+
PPR. `renderHandler` only exposes `ctx.dynamic()` / `build` for the opt-out
|
|
576
|
+
path. Do not invent a HIT Response in unit tests. Full recipe: `/testing`
|
|
577
|
+
skill → `serve-shell-request.md` and `cache-prerender.md` (PPR shell section).
|
|
425
578
|
|
|
426
579
|
## The hole doctrine (encode this in your head)
|
|
427
580
|
|
|
428
|
-
Holes are **render-defined**, decided by the shape of the tree
|
|
581
|
+
Holes are **render-defined**, decided by the shape of the tree. There are two
|
|
582
|
+
classes, and the handler layer is always on the SHELL side:
|
|
583
|
+
|
|
584
|
+
| Class | What decides it | At capture | At serve (HIT) |
|
|
585
|
+
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
|
586
|
+
| **STRUCTURAL** | a LIVE-lane loader (no `ssr: false`) read under a boundary: `loading()` (the whole segment subtree under it) or an inline `<Suspense>` above the reader | live loaders masked; the boundary postpones; its fallback bakes in as route structure | loaders run fresh; resume fills the hole |
|
|
587
|
+
| **SHELL** | everything the handler layer produces — awaited data, a promise it passes as a prop (even under the consumer's `<Suspense>`), an async server component, handle pushes at any depth, a loader it awaits (`await ctx.use(Loader)`) — plus replayed `cache()` segments and the settled non-promise data of BAKE-lane (`ssr: false`) loaders | awaited (one deadline, `ppr.captureTimeout`) and baked into the prelude and the shell's recorded segments | served from the frozen prelude; no handler runs |
|
|
588
|
+
|
|
589
|
+
One hole lives INSIDE loader data, decided by shape: a promise nested in a
|
|
590
|
+
bake-lane loader's return value, or in a handle container a loader pushes, is
|
|
591
|
+
masked at capture however fast it settles, and postpones at the consumer's own
|
|
592
|
+
`<Suspense>`. Such a loader still runs on every HIT, so that promise streams in
|
|
593
|
+
fresh (a promise-free bake-lane loader does not run on a HIT; see "On a shell
|
|
594
|
+
HIT").
|
|
595
|
+
|
|
596
|
+
The promise rule, by producer: **a promise a handler produces bakes, at any
|
|
597
|
+
depth; a promise nested in loader data stays live.** A
|
|
598
|
+
`loader(Def, { ssr: false })` is the BAKE lane (see the lane rule below): its
|
|
599
|
+
settled non-promise data is shell material, exactly like handler output, while
|
|
600
|
+
its nested promises are holes. A live-lane loader is a hole as a whole.
|
|
601
|
+
`ssr: false` on a loader is the same knob as `loading(fallback, { ssr: false })`:
|
|
602
|
+
the document does not stream a fallback for it but resolves the loader before
|
|
603
|
+
the first flush, so under `ppr` its value lands in the prelude and bakes into
|
|
604
|
+
the shell. Delivery details: `/loader` → "`ssr: false` — Guarantee a Loader in
|
|
605
|
+
the Document".
|
|
606
|
+
|
|
607
|
+
An async server component that never settles inside `ppr.captureTimeout`
|
|
608
|
+
(with or without `<Suspense>` above it) stores no shell; the warning names the
|
|
609
|
+
deadline. Make it cheaper (`cache()`/`"use cache"`), raise the budget, or move
|
|
610
|
+
its data into a live loader read under a boundary.
|
|
611
|
+
|
|
612
|
+
### The loader lane rule
|
|
613
|
+
|
|
614
|
+
**Only `loader(Def, { ssr: false })` bakes into the shell, and only the parts
|
|
615
|
+
of its returned data that are NOT promises.** The loader runs at capture; its
|
|
616
|
+
settled non-promise values are frozen into the shell (snapshot-pinned per
|
|
617
|
+
shell). Any promise inside the returned data is masked at capture and stays a
|
|
618
|
+
live hole, streamed fresh on every request at the consumer's own `<Suspense>`,
|
|
619
|
+
however fast it settles. The shape of the return value declares what is live:
|
|
620
|
+
a plain value bakes, a promise stays live. (The lane rule is about loaders the
|
|
621
|
+
route registers. A loader a HANDLER awaits with `ctx.use()` is handler output:
|
|
622
|
+
the handler's copy bakes whatever the flag says, even while the same loader's
|
|
623
|
+
registered read stays a live hole.)
|
|
624
|
+
|
|
625
|
+
- **Any depth, plain containers and JSX props.** The mask walks plain objects
|
|
626
|
+
(prototype `Object.prototype` or `null`), arrays and the props of React
|
|
627
|
+
elements at any depth — `{ items: [{ stock: fetchStock(sku) }] }` keeps
|
|
628
|
+
every `stock` live, and so does `{ reviews: <Reviews data={fetchReviews(id)} /> }`
|
|
629
|
+
for `data`. JSX without promises bakes like any plain value. A promise in
|
|
630
|
+
the props of a host element (`<p>`, `<div>`), `<Suspense>`/`<Fragment>` or a
|
|
631
|
+
client component is the hole and nothing else: the element's other props
|
|
632
|
+
bake and stay pinned on every HIT. That includes client components in JSX
|
|
633
|
+
the loader read back from `cache()` or `"use cache"`, which come back
|
|
634
|
+
wrapped in `lazy`. A server component (a function component, `memo` or
|
|
635
|
+
`forwardRef` around one, or a `lazy` that resolves to one) with a promise
|
|
636
|
+
anywhere in its props is the exception: the whole element is the hole and
|
|
637
|
+
renders from the
|
|
638
|
+
fresh loader run on a HIT, so the props it renders into the shell must come
|
|
639
|
+
out the same on every run. A `Map`, `Set`, class instance, or any other
|
|
640
|
+
non-plain object is a LEAF: a promise inside it is NOT masked and does NOT
|
|
641
|
+
become a live hole. Keep live promises in plain objects, arrays and JSX
|
|
642
|
+
props.
|
|
643
|
+
- **The consumer's `<Suspense>` is the hole.** A client component reads the
|
|
644
|
+
live property with `use(data.price)` inside its own `<Suspense>`; that
|
|
645
|
+
boundary postpones at capture and resumes with the fresh value.
|
|
646
|
+
- **Loader handle pushes follow the same shape rule.** The capture applies the
|
|
647
|
+
same mask to handle containers a loader pushes, so
|
|
648
|
+
`ctx.use(Handle)({ ..., x: promise })` inside a loader keeps `x` live. The
|
|
649
|
+
same push from a HANDLER bakes `x` (see "Handles").
|
|
650
|
+
|
|
651
|
+
**Every loader without `ssr: false` is entirely live**, whether or not it has
|
|
652
|
+
`loading()`: masked at capture with a never-resolving promise, postponed at its
|
|
653
|
+
boundary, and streamed fresh on every request. It needs a boundary —
|
|
654
|
+
`loading()` or an inline `<Suspense>` above the reader — or the shell is
|
|
655
|
+
refused: a masked read with no boundary above it root-postpones, the `<body>`
|
|
656
|
+
sanity gate refuses the capture, and the route stays an eternal MISS (warned
|
|
657
|
+
once per key). A route whose loaders are ALL `ssr: false` needs no
|
|
658
|
+
`loading()`: nothing masks, and the shell captures complete.
|
|
659
|
+
|
|
660
|
+
```typescript
|
|
661
|
+
export const ProductLoader = createLoader(async (ctx) => {
|
|
662
|
+
const product = await getProduct(ctx.params.id);
|
|
663
|
+
return {
|
|
664
|
+
name: product.name, // plain value: bakes
|
|
665
|
+
description: product.description, // plain value: bakes
|
|
666
|
+
price: fetchPrice(ctx.params.id), // promise: masked at capture, live hole
|
|
667
|
+
};
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
path("/products/:id", ProductPage, { name: "product", ppr: true }, () => [
|
|
671
|
+
loader(ProductLoader, { ssr: false }), // BAKE lane
|
|
672
|
+
loader(StockLoader), // LIVE lane: ProductPage reads it under an inline <Suspense>
|
|
673
|
+
]);
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
```tsx
|
|
677
|
+
// ProductDetails.tsx — the consumer of the live nested property.
|
|
678
|
+
"use client";
|
|
679
|
+
|
|
680
|
+
import { Suspense, use } from "react";
|
|
681
|
+
import { useLoader } from "@rangojs/router/client";
|
|
682
|
+
|
|
683
|
+
function Price({ price }: { price: Promise<number> }) {
|
|
684
|
+
return <PriceTag amount={use(price)} />;
|
|
685
|
+
}
|
|
429
686
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
687
|
+
export function ProductDetails() {
|
|
688
|
+
const { data } = useLoader(ProductLoader);
|
|
689
|
+
return (
|
|
690
|
+
<article>
|
|
691
|
+
<h1>{data.name}</h1> {/* baked */}
|
|
692
|
+
<p>{data.description}</p> {/* baked */}
|
|
693
|
+
<Suspense fallback={<PriceSkeleton />}>
|
|
694
|
+
<Price price={data.price} /> {/* hole — fresh on every request */}
|
|
695
|
+
</Suspense>
|
|
696
|
+
</article>
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
```
|
|
435
700
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
701
|
+
`name` and `description` are frozen into the shell; `price` is a hole under
|
|
702
|
+
the consumer's `<Suspense>`, fresh on every request. `StockLoader` is masked at
|
|
703
|
+
capture and postpones at the inline `<Suspense>` around its reader. A
|
|
704
|
+
`loading()` on this entry would also be a valid boundary, but its fallback
|
|
705
|
+
wraps the entry's whole subtree: a live read with no inline `<Suspense>` of its
|
|
706
|
+
own takes the entire entry — baked `name`/`description` included — into that
|
|
707
|
+
hole.
|
|
440
708
|
|
|
441
|
-
**`loading()` is NOT the gate for holes — it is
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
`loading()` picks the lane: present = the GUARANTEED live lane (masked at
|
|
447
|
-
capture, fresh every serve, immune to fast resolution); absent = the bake lane
|
|
448
|
-
(the container is shell material, nested promises stay live).
|
|
709
|
+
**`loading()` is NOT the lane switch and NOT the gate for holes** — it is one
|
|
710
|
+
of the two boundary kinds a live loader postpones at; an inline `<Suspense>`
|
|
711
|
+
above the reader is the other. A route with no loader has no holes: its whole
|
|
712
|
+
page is shell, which is a valid ppr route (the shell is served instantly and
|
|
713
|
+
recaptured on TTL/SWR or tag invalidation).
|
|
449
714
|
|
|
450
|
-
The
|
|
715
|
+
The promise positions, side by side:
|
|
451
716
|
|
|
452
717
|
```typescript
|
|
453
718
|
async function Handler(ctx: HandlerContext) {
|
|
454
719
|
const push = ctx.use(MyHandle);
|
|
455
720
|
|
|
456
|
-
push(fetchBadge());
|
|
457
|
-
push({ label: "x", stat: fetchStat() }); //
|
|
458
|
-
// stat streams → HOLE (consumer Suspends it)
|
|
721
|
+
push(fetchBadge()); // top-level handler push: awaited at capture → BAKED
|
|
722
|
+
push({ label: "x", stat: fetchStat() }); // nested in a handler push: awaited at capture → BAKED
|
|
459
723
|
|
|
460
|
-
const data = await fetchHeader(ctx);
|
|
724
|
+
const data = await fetchHeader(ctx); // awaited by the handler → BAKED
|
|
461
725
|
|
|
462
726
|
return (
|
|
463
727
|
<section>
|
|
464
|
-
<Header data={data} />
|
|
465
|
-
<StatsPanel promise={fetchStats(ctx)} />
|
|
466
|
-
|
|
728
|
+
<Header data={data} /> {/* shell */}
|
|
729
|
+
<StatsPanel promise={fetchStats(ctx)} /> {/* un-awaited prop under the consumer's
|
|
730
|
+
<Suspense>: awaited at capture → BAKED */}
|
|
731
|
+
<LiveStats /> {/* "use client": useLoader(StatsLoader)
|
|
732
|
+
under <Suspense>, StatsLoader registered
|
|
733
|
+
without ssr: false → HOLE */}
|
|
467
734
|
</section>
|
|
468
735
|
);
|
|
469
736
|
}
|
|
@@ -471,58 +738,65 @@ async function Handler(ctx: HandlerContext) {
|
|
|
471
738
|
|
|
472
739
|
### Choosing the hole mechanism
|
|
473
740
|
|
|
474
|
-
| Your live region is… | Use
|
|
475
|
-
| ----------------------------------------------- |
|
|
476
|
-
|
|
|
477
|
-
| loader data, shell container + live parts |
|
|
478
|
-
| handler-fetched
|
|
479
|
-
|
|
|
480
|
-
| already-resolved / instant / synchronous values | `loader(() => Promise.resolve(x))`
|
|
481
|
-
| none of the above | nothing
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
741
|
+
| Your live region is… | Use | Why |
|
|
742
|
+
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
743
|
+
| data that must be fresh EVERY serve | `loader()` (the live lane) + `useLoader` under `loading()` or an inline `<Suspense>` | the guaranteed hole — masked at capture, fresh every serve, immune to fast resolution |
|
|
744
|
+
| 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>` |
|
|
745
|
+
| handler-fetched data (db, fetch) | nothing to choose: it bakes, whether the handler awaits it or passes the promise down | handler output is shell material; move the fetch into a live-lane loader to make it live |
|
|
746
|
+
| handle metadata that must be live | push it from a loader: a live-lane loader pushes per request; a bake-lane loader's nested promise stays a hole | handler pushes bake at any depth; loader pushes keep "nesting = liveness" |
|
|
747
|
+
| already-resolved / instant / synchronous values | `loader(() => Promise.resolve(x))` (no `ssr: false`) + a boundary | live-lane loaders are masked at capture no matter how fast they settle |
|
|
748
|
+
| none of the above | nothing | it bakes — that is what the shell is for |
|
|
749
|
+
|
|
750
|
+
Why a handler promise cannot be a hole: every HIT replays the handler layer the
|
|
751
|
+
capture recorded, and no handler runs, so nothing on the HIT could produce a
|
|
752
|
+
fresh value for it. The capture therefore waits for it and bakes it, exactly as
|
|
753
|
+
`cache()` does. Loader-side holes do not depend on timing either: the capture
|
|
754
|
+
MASKS every promise nested in a bake-lane container regardless of how fast it
|
|
755
|
+
settles (`maskNestedContainerThenables`), so the consuming boundary always
|
|
756
|
+
postpones and every HIT streams the FRESH value — the promise SHAPE is the
|
|
757
|
+
liveness declaration, not a bet on latency. (Before the mask, a nested promise
|
|
758
|
+
that settled inside the capture window pinned its capture-time value into the
|
|
759
|
+
shared shell; found live as a storefront basket — with the capturing session's
|
|
760
|
+
identifiers — served to anonymous visitors.)
|
|
761
|
+
|
|
762
|
+
### Handles: handler pushes bake; loader pushes keep "nesting = liveness"
|
|
763
|
+
|
|
764
|
+
- `ctx.use(H)(promise)` from a handler — a TOP-LEVEL pushed promise is awaited
|
|
765
|
+
at capture and BAKED into the shell (bounded by `ppr.captureTimeout`, 15s by
|
|
766
|
+
default).
|
|
767
|
+
- `ctx.use(H)({ x: promise })` from a HANDLER — also baked. The capture waits
|
|
768
|
+
for every promise nested in a handler's push, at any depth of plain objects,
|
|
769
|
+
arrays and JSX props, before it records the handles every HIT replays. Keep
|
|
770
|
+
promises in plain objects, arrays and JSX props: one inside a `Map`, `Set`,
|
|
771
|
+
or class instance is not waited for, and if it has not settled when the handles are encoded the
|
|
772
|
+
capture is refused.
|
|
773
|
+
- `ctx.use(H)({ x: promise })` from a LOADER — nesting = liveness. A bake-lane
|
|
774
|
+
(`ssr: false`) loader's container settles and bakes, while the nested promise
|
|
775
|
+
is masked at capture regardless of settle timing and streams fresh to the
|
|
776
|
+
consumer's `<Suspense>` on every HIT (the same mask as bake-lane loader data,
|
|
777
|
+
at any depth of plain objects, arrays and JSX props; a promise inside a
|
|
778
|
+
`Map`, `Set`, or class instance is not masked). A live-lane
|
|
779
|
+
loader never runs at capture, so all of its pushes happen per request.
|
|
509
780
|
|
|
510
781
|
### Want a hole for already-resolved data?
|
|
511
782
|
|
|
512
|
-
Put it in a loader: `loader(() => Promise.resolve(x))`
|
|
513
|
-
|
|
514
|
-
how fast the value settles.
|
|
783
|
+
Put it in a loader without `ssr: false`: `loader(() => Promise.resolve(x))`
|
|
784
|
+
under `loading()` or an inline `<Suspense>`. Live-lane loaders are masked at
|
|
785
|
+
capture and fresh on every serve, no matter how fast the value settles.
|
|
515
786
|
|
|
516
|
-
### The bake lane:
|
|
787
|
+
### The bake lane: `loader(Def, { ssr: false })`
|
|
517
788
|
|
|
518
|
-
A loader
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
capture
|
|
524
|
-
|
|
525
|
-
|
|
789
|
+
A loader registered with `{ ssr: false }` EXECUTES during capture (the capture
|
|
790
|
+
gate holds open for its real latency, bounded by `ppr.captureTimeout`, 15s by
|
|
791
|
+
default). The flag's document promise — "this loader's data is in the HTML
|
|
792
|
+
before first flush" — maps to the frozen prelude under ppr. Its settled
|
|
793
|
+
non-promise data bakes into the prelude; every promise nested in it is masked
|
|
794
|
+
at capture (regardless of how fast it settles) and postpones at the consumer's
|
|
795
|
+
own `<Suspense>` — a hole. The capture records the container into the shell
|
|
796
|
+
snapshot's loader family so every HIT payload matches the frozen prelude
|
|
797
|
+
outside the holes (see "On a shell HIT" below; a server component holding a
|
|
798
|
+
promise is the one element that is a hole as a whole, see "Any depth" above).
|
|
799
|
+
The return shape is the declaration:
|
|
526
800
|
|
|
527
801
|
```typescript
|
|
528
802
|
export const StorefrontContextLoader = createLoader(async (ctx) => {
|
|
@@ -532,62 +806,152 @@ export const StorefrontContextLoader = createLoader(async (ctx) => {
|
|
|
532
806
|
basket: fetchBasket(ctx), // hole — consumer <Suspense>s it, fresh per request
|
|
533
807
|
};
|
|
534
808
|
});
|
|
809
|
+
|
|
810
|
+
layout(StoreLayout, () => [
|
|
811
|
+
loader(StorefrontContextLoader, { ssr: false }), // the flag selects the bake lane
|
|
812
|
+
path("/", HomePage, { name: "home", ppr: true }),
|
|
813
|
+
]);
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
The lane is decided PER LOADER, by its own `ssr: false` flag — `loading()` on
|
|
817
|
+
the same entry, a parent, or a child never changes it. `loading()` only decides
|
|
818
|
+
where a live loader's hole sits, and it IS valid on layout and parallel
|
|
819
|
+
entries, not just routes.
|
|
820
|
+
|
|
821
|
+
#### On a shell HIT
|
|
822
|
+
|
|
823
|
+
What a bake-lane loader does on a HIT depends on whether its recorded
|
|
824
|
+
container carried holes. A client navigation that replays a shell captured by
|
|
825
|
+
a document request (`x-rango-ppr-replay: HIT`) does the same, for every
|
|
826
|
+
`ssr: false` loader, including one on an entry with `loading()` (before, a
|
|
827
|
+
navigation ran every loader and served its fresh values). A snapshot captured
|
|
828
|
+
by a navigation alone carries no pins, and its replay runs these loaders fresh.
|
|
829
|
+
|
|
830
|
+
- **Hole-free record (the return had no promises): served from the shell.**
|
|
831
|
+
The response uses the pinned container immediately and the loader body does
|
|
832
|
+
NOT run (unless the capture marked it to run: see the handle pushes note
|
|
833
|
+
below): a HIT is rendered from the shell, like the handlers it replays, so
|
|
834
|
+
the loader costs nothing per HIT. Side effects in its body happen once per
|
|
835
|
+
capture, not per request. The output changes only when the shell is
|
|
836
|
+
recaptured (TTL/SWR expiry or tag invalidation).
|
|
837
|
+
- **Hole-carrying record (the return had promises).** The loader runs on every
|
|
838
|
+
HIT, ON the critical path — only the body can create the live promises that
|
|
839
|
+
fill the holes — and the recorded baked paths are overlaid onto the fresh
|
|
840
|
+
result, so the payload matches the prelude while the nested promises stream
|
|
841
|
+
fresh. A fresh rejection skips the overlay and goes to the loader's error
|
|
842
|
+
boundary. Give it its own cache,
|
|
843
|
+
`loader(Def, { ssr: false }, () => [cache({ ttl })])`, so that run reads
|
|
844
|
+
through the loader cache.
|
|
845
|
+
|
|
846
|
+
Whether a return "has promises" is decided ONCE, at capture, by the shape of
|
|
847
|
+
the value, never by timing: `maskNestedContainerThenables`
|
|
848
|
+
(`router/segment-resolution/mask-nested.ts`) walks the returned data through
|
|
849
|
+
plain objects, arrays and JSX element props and turns every thenable into a
|
|
850
|
+
hole; the drain stores the result as the record's `holes` bit. A promise that
|
|
851
|
+
resolves in 1 ms is still a hole, so `{ product: await getProduct() }` bakes
|
|
852
|
+
and does not run on a HIT, while `{ product: getProduct() }` is a hole and
|
|
853
|
+
runs on every HIT. A promise inside a `Map`, `Set` or class instance is not
|
|
854
|
+
walked and is not a hole: the capture's record encode waits for it and bakes
|
|
855
|
+
its settled value, and a HIT serves that value without running the loader.
|
|
856
|
+
|
|
857
|
+
The granularity is the WHOLE loader: a bake-lane loader returning
|
|
858
|
+
`{ product, reviews: promise }` runs its entire body on every HIT, the product
|
|
859
|
+
fetch included, even though the captured product overlays its result. Split
|
|
860
|
+
it into a promise-free `ssr: false` loader plus a live loader for the
|
|
861
|
+
per-request part, or give it its own `cache()` so that run reads through it:
|
|
862
|
+
|
|
863
|
+
```tsx
|
|
864
|
+
loader(ProductLoader, { ssr: false }), // promise-free: baked, not run on a HIT
|
|
865
|
+
loader(ReviewsLoader), // live: the hole, under loading()
|
|
535
866
|
```
|
|
536
867
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
868
|
+
**Handle pushes from a bake-lane loader appear once.** The capture records
|
|
869
|
+
the settled, thenable-free handle pushes of every loader body it runs (the
|
|
870
|
+
prelude rendered them): the `ssr: false` loader's own, those of the loaders
|
|
871
|
+
it awaits with `ctx.use()`, and those its own `cache()` entry replays. Every HIT restores them with the handler layer. A
|
|
872
|
+
loader that does run on the HIT (a hole-carrying bake-lane loader, or a live
|
|
873
|
+
loader awaiting the same loader) replaces its restored pushes in place, so
|
|
874
|
+
any handle, deduping or not, shows each value once; a push that lands after
|
|
875
|
+
the document's handle snapshot reaches the client after hydration. A push the
|
|
876
|
+
capture cannot record (a deferred push, or one holding a promise) marks the
|
|
877
|
+
loader records to run: those
|
|
878
|
+
bodies then still run on each HIT, in the background, so the push reaches the
|
|
879
|
+
page.
|
|
540
880
|
|
|
541
881
|
Four hard edges (each e2e/unit-pinned):
|
|
542
882
|
|
|
543
883
|
- **Header writes throw (issue #713).** ppr is a document-scoped `cache()`:
|
|
544
884
|
in any cached scenario ONLY MIDDLEWARE writes response headers. A handler
|
|
545
885
|
or loader on a ppr route calling `ctx.headers.set()`, `cookies().set()`,
|
|
546
|
-
`ctx.
|
|
547
|
-
dev and prod,
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
886
|
+
`ctx.setTheme()`, or the request-context `header()`/`setStatus()` throws on
|
|
887
|
+
EVERY render — dev and prod, first render, same guard family as the
|
|
888
|
+
`cache()` boundary guard. Handler output is replayed on HITs and no handler
|
|
889
|
+
runs there (the write would silently differ between MISS and HIT); loaders
|
|
890
|
+
are live but settle AFTER the response headers flushed with the shell (dead
|
|
891
|
+
letters). Move the write into route middleware — it runs
|
|
552
892
|
on every request, including HITs, and its headers/cookies merge into every
|
|
553
893
|
response. The one exception: a handler that calls `ctx.dynamic()` FIRST
|
|
554
894
|
re-permits its own header/cookie write (#735) — a dynamic() render never
|
|
555
895
|
HITs, so the write is deterministic (see "Opting out per request").
|
|
556
896
|
- **Identity refuses.** `cookies()`/`headers()` inside a bake-lane loader
|
|
557
897
|
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
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
898
|
+
warned) — identity can never bake into the shared shell. Drop the loader's
|
|
899
|
+
`ssr: false` flag (the live lane is exempt; give it a boundary) or move the
|
|
900
|
+
identity-dependent part into a separate loader without `ssr: false`. A
|
|
901
|
+
nested promise does not help here: its body still runs during capture and
|
|
902
|
+
trips the guard. The same refusal covers everything else the capture waits
|
|
903
|
+
for (a handler, a promise it passes or pushes, an async server component, a
|
|
904
|
+
loader a handler awaits), a `{ cache: false }` variable
|
|
905
|
+
(`createVar({ cache: false })` or `ctx.set(..., { cache: false })`) read
|
|
906
|
+
there, and the visitor's theme (`ctx.theme`, `getRequestContext().theme`,
|
|
907
|
+
#971); see "Security". Read the theme with `useTheme()` in a client
|
|
908
|
+
component, or in a live loader with `cookies().get("theme")`; the shell's
|
|
909
|
+
`initialTheme` is the no-cookie default. Per-user state read from a NORMAL
|
|
910
|
+
middleware-provided variable (`ctx.get("session")`) does NOT refuse — it
|
|
911
|
+
bakes silently as the capturing user's data (see Pitfalls: the
|
|
912
|
+
session-object bake trap).
|
|
913
|
+
- **A rejecting bake-lane loader refuses.** Error UI never bakes. So does a
|
|
914
|
+
value that fails to encode: a function, a class instance, a promise inside a
|
|
915
|
+
`Map` that rejects, or a server component in the value that throws. The
|
|
916
|
+
capture stores nothing, logs the error, reports `cache-write` and backs the
|
|
917
|
+
key off, the same as a shell component that throws (below). The capture
|
|
918
|
+
encodes the value a second time to pin it for HITs, so a server component
|
|
919
|
+
that throws only on that run refuses it too. A build-time capture skips the
|
|
920
|
+
shell (`SHELL SKIP`) and the route keeps runtime capture.
|
|
565
921
|
- **Baked containers show CAPTURE-time data** for the shell's lifetime on
|
|
566
|
-
document GETs
|
|
567
|
-
|
|
568
|
-
lane's meaning; if a value
|
|
569
|
-
live lane (`
|
|
922
|
+
document GETs, and on soft navigations that replay a shell captured by a
|
|
923
|
+
document request (`x-rango-ppr-replay: HIT`). Live loaders and their
|
|
924
|
+
item/response reads remain fresh. That IS the bake lane's meaning; if a value
|
|
925
|
+
must be fresh on every serve, it belongs on the live lane (no `ssr: false`) or
|
|
926
|
+
in a nested promise.
|
|
570
927
|
|
|
571
928
|
### The layout-with-loaders playbook (the storefront case)
|
|
572
929
|
|
|
573
930
|
The most common real-app shape: an app-wide layout registers per-user loaders
|
|
574
931
|
(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
|
-
|
|
932
|
+
Unflagged, those loaders are on the LIVE lane: masked at capture, so every
|
|
933
|
+
read needs a boundary (`loading()` or an inline `<Suspense>`) or the capture
|
|
934
|
+
refuses and the page stays an eternal MISS. The question is which parts of
|
|
935
|
+
their data should bake vs stay live. Your levers, in order of preference:
|
|
936
|
+
|
|
937
|
+
1. **Flag the loader and shape the return value.**
|
|
938
|
+
`loader(SessionContextLoader, { ssr: false })` puts it on the bake lane:
|
|
939
|
+
shared/config data returns as plain values (bakes, pinned per shell);
|
|
940
|
+
per-request data returns as NESTED promises consumed under the widget's own
|
|
941
|
+
`<Suspense>` (live holes). No `loading()`, no restructuring. One wall: a
|
|
942
|
+
bake-lane loader that reads `cookies()`/`headers()` refuses the capture —
|
|
943
|
+
identity belongs on the live lane (a separate unflagged loader); a nested
|
|
944
|
+
promise inside the flagged loader still runs during capture and trips the
|
|
945
|
+
guard.
|
|
946
|
+
2. **Do NOT use `loading()` on the layout itself as the live loaders'
|
|
947
|
+
boundary** — any live read without an inline `<Suspense>` of its own
|
|
948
|
+
suspends to it, and the LoaderBoundary fallback wraps the layout's ENTIRE
|
|
949
|
+
subtree: chrome (header, nav, footer) falls out of the shell into the
|
|
950
|
+
skeleton. Technically PPR, practically pointless.
|
|
951
|
+
3. **Guaranteed-fresh widgets: live loaders with a widget-sized boundary.**
|
|
952
|
+
An unflagged loader read under an inline `<Suspense>` in the widget is
|
|
953
|
+
enough. Alternatively, a parallel slot with its OWN `loading()`:
|
|
954
|
+
parallel-owned loaders get their own per-slot boundary (`fresh.ts` tags
|
|
591
955
|
them with the slot's loading; `segment-system.tsx` builds a per-slot
|
|
592
956
|
LoaderBoundary), so the chrome bakes into the shell and each widget is an
|
|
593
957
|
independent, widget-sized hole:
|
|
@@ -613,81 +977,98 @@ stay live. Your levers, in order of preference:
|
|
|
613
977
|
]),
|
|
614
978
|
```
|
|
615
979
|
|
|
616
|
-
Slot-owned loaders are masked at capture and
|
|
617
|
-
use this where the bake lane's
|
|
980
|
+
Slot-owned loaders without `ssr: false` are masked at capture and
|
|
981
|
+
GUARANTEED fresh per serve — use this where the bake lane's pinning
|
|
618
982
|
(capture-time data for the shell's lifetime) is not acceptable, at the cost
|
|
619
983
|
of a widget-sized fallback in the shell. The slot handler must hand the
|
|
620
|
-
loader to a CLIENT component (`useLoader` in a `"use client"` component)
|
|
621
|
-
|
|
622
|
-
`await ctx.use(
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
4. **Shared layout data
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
984
|
+
loader to a CLIENT component (`useLoader` in a `"use client"` component):
|
|
985
|
+
that client-side read is the hole. A server-side
|
|
986
|
+
`await ctx.use(WishlistLoader)` in the slot handler is handler output: it
|
|
987
|
+
runs at capture, its value bakes into the shell for every visitor, and if
|
|
988
|
+
the loader reads `cookies()`/`headers()` (the usual case for a per-user
|
|
989
|
+
loader) the capture is REFUSED and the page stays on MISS.
|
|
990
|
+
|
|
991
|
+
4. **Shared layout data does not need a loader at all**: render it from the
|
|
992
|
+
handler (awaited, passed down as a promise, or pushed — it all bakes) and
|
|
993
|
+
wrap the expensive part in `cache()`/`"use cache"` so captures stay cheap
|
|
994
|
+
and the shell is tag-invalidatable.
|
|
995
|
+
|
|
996
|
+
The identity rule, stated once: per-user data on a PPR page lives in a
|
|
997
|
+
live-lane loader (no `ssr: false`) consumed CLIENT-side with `useLoader` under
|
|
998
|
+
`loading()` or an inline `<Suspense>`, or in a NESTED promise inside a
|
|
999
|
+
bake-lane loader (a hole, fresh per request). A nested promise's body still
|
|
1000
|
+
runs at capture, so it must not call `cookies()`/`headers()` itself — identity
|
|
1001
|
+
reads belong in the live-lane loader. Reading `cookies()`, `headers()`, or a
|
|
1002
|
+
`{ cache: false }` variable anywhere the capture waits for refuses the capture
|
|
1003
|
+
by construction: handler/render code, a promise the handler passes or pushes,
|
|
1004
|
+
an async server component, a bake-lane loader, and a loader a handler awaits
|
|
1005
|
+
(`await ctx.use(Loader)`). There is no exemption for handler-invoked loaders:
|
|
1006
|
+
every HIT replays the capture's copy of their value, so an identity read there
|
|
1007
|
+
would show the capturing request's user to every visitor.
|
|
640
1008
|
|
|
641
1009
|
### Designing routes for cheap captures (the cost model)
|
|
642
1010
|
|
|
643
1011
|
The hole doctrine has a cost corollary that bites silently: **everything that
|
|
644
1012
|
bakes is awaited at capture, and that wait recurs on EVERY capture of the
|
|
645
|
-
route**.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1013
|
+
route**. That now includes every promise a handler produces: a prop promise, an
|
|
1014
|
+
async server component's data, a nested value in a handle push, a loader the
|
|
1015
|
+
handler awaits. A handler pushing a handle promise that takes 5s and a Meta
|
|
1016
|
+
promise chained 2s further make every capture occupy the per-isolate serialized
|
|
1017
|
+
queue for ~7s — while the served response still reads 13ms TTFB, because
|
|
1018
|
+
captures are detached. You will not see this cost in any request timing; you
|
|
1019
|
+
see it as slow MISS→HIT flips, `skip-queue-timeout` under prefetch pressure,
|
|
1020
|
+
and (in dev, past 2s) the bake-cost warning naming the source.
|
|
651
1021
|
|
|
652
1022
|
Three levers, in preference order:
|
|
653
1023
|
|
|
654
|
-
1. **
|
|
655
|
-
|
|
656
|
-
capture cost.
|
|
1024
|
+
1. **Make it live**: move the value into a live-lane loader (no `ssr: false`)
|
|
1025
|
+
read with `useLoader` under `loading()` or an inline `<Suspense>` — a hole,
|
|
1026
|
+
per request, zero capture cost. Inside a bake-lane loader, return the slow
|
|
1027
|
+
part as a nested promise instead. Nesting a promise inside a HANDLER's push
|
|
1028
|
+
or props no longer does this: handler output is always awaited.
|
|
657
1029
|
2. **`cache()` the work inside it**: the value stays baked (shell material,
|
|
658
1030
|
tag-invalidatable), but the capture replays the cached value instead of
|
|
659
1031
|
re-executing the expensive body (mixed-chain). Choose this for shared,
|
|
660
1032
|
expensive-to-compute shell content.
|
|
661
|
-
3.
|
|
662
|
-
|
|
663
|
-
|
|
1033
|
+
3. **Drop `ssr: false`** (loaders only): moves the loader to the live lane —
|
|
1034
|
+
masked at capture, fresh per serve — at the cost of a fallback in the shell
|
|
1035
|
+
(a live loader needs `loading()` or an inline `<Suspense>` above its
|
|
1036
|
+
reader).
|
|
664
1037
|
|
|
665
1038
|
Head material (Meta) pushed by HANDLERS cannot be a hole — the head is shell —
|
|
666
|
-
so those promises bake by design; make them cheap with lever 2.
|
|
667
|
-
on the capture debug event
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1039
|
+
so those promises bake by design; make them cheap with lever 2.
|
|
1040
|
+
`recordSettleMs` and `bakeWaitMs` on the capture debug event tell you what each
|
|
1041
|
+
capture actually paid. A
|
|
1042
|
+
Meta pushed by a LIVE-lane loader is different: those loaders are masked at
|
|
1043
|
+
capture, so the push happens at request time and applies client-side
|
|
1044
|
+
(`metadata.handlesLate`) — it is never in the cached shell's head, by
|
|
1045
|
+
construction. A bake-lane (`ssr: false`) loader executes at capture, so its
|
|
1046
|
+
settled pushes are shell material like a handler's.
|
|
1047
|
+
|
|
1048
|
+
`loader(Def, { ssr: false })` (the document-render await, `/loader`) is the
|
|
1049
|
+
bake-lane switch under PPR, not a knob for normal renders only: the capture
|
|
1050
|
+
render awaits the flagged loader too, so its settled non-promise data — handle
|
|
1051
|
+
pushes included — freezes into the stored shell and recurs as capture cost on
|
|
1052
|
+
every capture (levers 1 and 2 apply to it). On a MISS the flag keeps its
|
|
1053
|
+
normal-render meaning: document renders await it before first flush, client
|
|
1054
|
+
navigations stream it. A shell HIT, and a navigation that replays a shell
|
|
1055
|
+
captured by a document request, serve a promise-free one from the shell
|
|
1056
|
+
without running it (unless the capture marked it to run: see "On a shell
|
|
1057
|
+
HIT"); a snapshot captured by a navigation alone carries no pins, and its
|
|
1058
|
+
replay runs it fresh.
|
|
678
1059
|
|
|
679
1060
|
## Execution matrix
|
|
680
1061
|
|
|
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
|
|
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 | — | —
|
|
1062
|
+
| Phase | MISS (foreground) | Background capture | HIT (foreground) |
|
|
1063
|
+
| ---------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
1064
|
+
| Middleware chain | runs (full) | **NOT re-run** — inherits the request's post-middleware context | runs (full) — commit point is after it |
|
|
1065
|
+
| `router.match` | runs | re-runs under a derived context | runs (behind the flushed prelude) |
|
|
1066
|
+
| Handlers | run | run on UNCACHED segments (`cache()`d segments replay); everything they produce — promises, async server components, handle pushes, awaited loaders — settles before the freeze (`ppr.captureTimeout`) | **never run** — the handler layer is replayed from the shell's recorded segments |
|
|
1067
|
+
| Loaders | run **fresh** | LIVE lane (no `ssr: false`): MASKED; BAKE lane (`ssr: false`): execute + snapshot-pin | live: run **fresh**; bake: pinned, runs only if its record has holes |
|
|
1068
|
+
| Flight render | full | full, from the capture's own recorded segments (the same bytes every HIT replays) | full (hydration needs the whole payload — no Flight resume) |
|
|
1069
|
+
| HTML production | full fizz | `prerender` + abort → prelude + postponed | `resume` only the holes — O(paths to holes) |
|
|
1070
|
+
| Shell store | schedules a bg capture | `putShell(key, …)` | `getShell(key)`; a stale/SWR hit also schedules a recapture |
|
|
1071
|
+
| Prelude bytes | — | — | flushed FIRST, before segment resolution starts |
|
|
691
1072
|
|
|
692
1073
|
Middleware is not re-run during capture because it already ran for the
|
|
693
1074
|
triggering request — the capture's derived context inherits the
|
|
@@ -695,51 +1076,66 @@ post-middleware state (`ctx` variables included, which is what makes
|
|
|
695
1076
|
middleware-derived shell content photograph correctly). Guarding is
|
|
696
1077
|
serve-time: the commit point runs the full chain on EVERY serve.
|
|
697
1078
|
|
|
698
|
-
Because handlers on uncached segments EXECUTE during capture —
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
1079
|
+
Because handlers on uncached segments EXECUTE during capture — along with
|
|
1080
|
+
everything they produce and every BAKE-lane loader — the capture guard is
|
|
1081
|
+
load-bearing: `cookies()`, `headers()`, a theme read, and a `{ cache: false }`
|
|
1082
|
+
variable read THROW during a capture render, and the capture
|
|
1083
|
+
is refused even when the code catches that throw, so identity can never leak
|
|
1084
|
+
into a shared shell through them. Live-lane loaders (every loader without
|
|
1085
|
+
`ssr: false`) are exempt: masked at capture, they never run there.
|
|
703
1086
|
|
|
704
1087
|
## allReady: the SEO/bot story
|
|
705
1088
|
|
|
706
1089
|
`ssr: { resolveStreaming: ... }` returning `"allReady"` (e.g. for bot user
|
|
707
1090
|
agents) bypasses PPR entirely — the request gets one complete, fully-buffered
|
|
708
|
-
|
|
709
|
-
regular users get the streamed shell. No configuration
|
|
710
|
-
wins.
|
|
1091
|
+
document (a normal render, no shell). Crawlers that dislike streamed shells
|
|
1092
|
+
get a finished page; regular users get the streamed shell. No configuration
|
|
1093
|
+
interaction: allReady wins.
|
|
711
1094
|
|
|
712
1095
|
## Security
|
|
713
1096
|
|
|
714
|
-
Shell caching shares one shell per host+URL
|
|
1097
|
+
Shell caching shares one shell per host+URL (and request partition, see
|
|
1098
|
+
"Request-partitioned shells") across all users:
|
|
715
1099
|
|
|
716
1100
|
**(a) Access control is sound by construction.** The commit point is after ALL
|
|
717
1101
|
middleware on every serve. A 401/redirect short-circuit returns before any
|
|
718
1102
|
shell byte.
|
|
719
1103
|
|
|
720
|
-
**(b)
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1104
|
+
**(b) Request-scoped reads refuse the capture.** During the background capture
|
|
1105
|
+
render, `cookies()`, `headers()`, the visitor's theme (`ctx.theme`,
|
|
1106
|
+
`getRequestContext().theme`), and `ctx.get()` of a `{ cache: false }` variable
|
|
1107
|
+
(`createVar({ cache: false })`, or a value written with
|
|
1108
|
+
`ctx.set(..., { cache: false })`) THROW, wherever the capture waits for them:
|
|
1109
|
+
a handler, a promise it passes or pushes, an async server component, a
|
|
1110
|
+
bake-lane loader, and a loader a handler awaits (`await ctx.use(Loader)`). The
|
|
1111
|
+
capture is refused even if your code catches the throw, and the route keeps
|
|
1112
|
+
serving MISSes with a once-per-key warning. `ctx.dynamic()` called there
|
|
1113
|
+
refuses it too. The live lane (no `ssr: false`) stays exempt: masked at capture, it never
|
|
1114
|
+
runs there.
|
|
1115
|
+
|
|
1116
|
+
**(c) Residual hazard — middleware-derived per-user state.** A NORMAL `ctx`
|
|
1117
|
+
variable (no `cache: false`) set by an upstream auth middleware and rendered by
|
|
1118
|
+
shell material is photographed into the SHARED shell (the capture inherits
|
|
1119
|
+
post-middleware state), and so is a raw `ctx.request.headers` read. The guard
|
|
1120
|
+
cannot see either. That is scope fidelity working as designed — for shared
|
|
1121
|
+
values. It reaches handlers and BAKE-LANE LOADERS alike: a loader reading a
|
|
1122
|
+
middleware-provided session object (`ctx.get("session")`) never calls
|
|
1123
|
+
`cookies()` itself, so whatever it returns as settled container data is
|
|
1124
|
+
photographed as the CAPTURING user's state. If the value is per-user:
|
|
1125
|
+
shell-cache only public/shared pages, keep per-user content in live-lane
|
|
1126
|
+
loaders (no `ssr: false`) or nested promises in bake-lane loader data — NOT in
|
|
1127
|
+
handler output or plain bake-lane container data — mark the variable
|
|
1128
|
+
`{ cache: false }` so a stray shell read refuses the capture, or key per
|
|
1129
|
+
variant at the CDN tier.
|
|
1130
|
+
|
|
1131
|
+
## What always renders without a shell
|
|
738
1132
|
|
|
739
1133
|
Non-GET, non-partial RSC/action/loader fetches, partial requests without an
|
|
740
1134
|
eligible captured segment snapshot, per-request CSP nonce, `streamMode:
|
|
741
|
-
"allReady"`, redirects, 404s, error renders, routes without `ppr`,
|
|
742
|
-
|
|
1135
|
+
"allReady"`, redirects, 404s, error renders, routes without `ppr`, a document
|
|
1136
|
+
request the route's own `cache()` scope refuses (`cache(false)`, or a
|
|
1137
|
+
`condition()` returning false for it), and any store without the shell family.
|
|
1138
|
+
A stored shell is invalidated when
|
|
743
1139
|
`React.version` changes (postponed state is build-coupled), so deploys
|
|
744
1140
|
self-heal via recapture.
|
|
745
1141
|
|
|
@@ -756,15 +1152,15 @@ point (which runs after the whole middleware chain), so a middleware-set nonce
|
|
|
756
1152
|
blocks capture the same as a provider one. Because the route DECLARED `ppr`
|
|
757
1153
|
but cannot be honored, it logs a once-per-key worker warning (same
|
|
758
1154
|
declared-intent-cannot-be-honored doctrine as the missing-store warning) and
|
|
759
|
-
serves
|
|
760
|
-
silent.
|
|
1155
|
+
serves a normal render (no shell) with no `x-rango-shell` header. An
|
|
1156
|
+
undeclared route stays silent.
|
|
761
1157
|
|
|
762
1158
|
### The proper way to supply a nonce
|
|
763
1159
|
|
|
764
1160
|
`createRouter({ nonce })` is the canonical path — supply the nonce THERE, not
|
|
765
1161
|
via a token write. The provider value is threaded into the router's own SSR
|
|
766
1162
|
machinery: `NonceContext`/`useNonce()`, automatic nonce attributes on
|
|
767
|
-
`<Scripts />` and `<
|
|
1163
|
+
`<Html.Scripts />` and `<Html.Meta />` output, and the inlined Flight payload
|
|
768
1164
|
scripts. It ALSO sets the `nonce` token, so `ctx.get(nonce)` works in
|
|
769
1165
|
middleware and handlers for the CSP response header. A direct
|
|
770
1166
|
`ctx.set(nonce, value)` write in middleware is app-managed only: the router
|
|
@@ -787,13 +1183,38 @@ path(
|
|
|
787
1183
|
);
|
|
788
1184
|
```
|
|
789
1185
|
|
|
790
|
-
| Field | Default | Notes
|
|
791
|
-
| ------------------ | ------- |
|
|
792
|
-
| `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default)
|
|
793
|
-
| `swr` | — | stale window: serve the stale shell + background recapture
|
|
794
|
-
| `tags` | — | operational tags UNIONED with the tags the capture
|
|
795
|
-
| `maxSnapshotBytes` | 8 MiB | cap on the entry's
|
|
796
|
-
| `captureTimeout` | 15000ms | capture
|
|
1186
|
+
| Field | Default | Notes |
|
|
1187
|
+
| ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1188
|
+
| `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
|
|
1189
|
+
| `swr` | — | stale window: serve the stale shell + background recapture |
|
|
1190
|
+
| `tags` | — | operational tags UNIONED with the tags the capture collects — see "Invalidation" below |
|
|
1191
|
+
| `maxSnapshotBytes` | 8 MiB | cap on the loader pins in the entry's snapshot (bake-lane loader records and the cached items/responses those loaders read). The recorded handler layer is exempt. Over the cap the pins are dropped and the shell is stored with its handler layer (warned once per key); bake-lane loaders then read the live store, and drift is repaired client-side |
|
|
1192
|
+
| `captureTimeout` | 15000ms | ONE capture deadline: the handler layer settling (promises it passes or pushes, async server components, loaders it awaits), bake-lane loaders, and the prerender; a capture that misses it stores nothing rather than a partial shell |
|
|
1193
|
+
|
|
1194
|
+
A shell never outlives the route `cache()` entry it was captured from: under
|
|
1195
|
+
a route `cache()`, the shell is fresh no longer than that entry is (at most
|
|
1196
|
+
`ppr.ttl`) and served no longer than that entry can be (at most `ppr.ttl` +
|
|
1197
|
+
`ppr.swr`), so a document HIT and a client navigation (which reads the
|
|
1198
|
+
`cache()` entry first) show the same handler output. An entry already in its
|
|
1199
|
+
`swr` window gives a shell that is stale from the start, served while it
|
|
1200
|
+
recaptures. Each isolate recaptures a stale shell at most once per second
|
|
1201
|
+
(`SHELL_MIN_RECAPTURE_INTERVAL_MS`): a HIT within a second of the shell's
|
|
1202
|
+
capture serves it without starting another. When the entry a capture read
|
|
1203
|
+
runs out before the store (it was near its end), the capture retries once on
|
|
1204
|
+
a fresh match. Nothing is stored when the retry's entry runs out too, when
|
|
1205
|
+
the capture budget leaves no time to retry, or when the capture wrote the
|
|
1206
|
+
entry itself and it ran out before the store (its `ttl` + `swr` is no longer
|
|
1207
|
+
than the rest of the capture): the URL backs off, and a warning names the
|
|
1208
|
+
route and the entry's `ttl`/`swr`, once per route (dev and production). In dev an explicit `ppr.ttl`/`ppr.swr` the entry reduces
|
|
1209
|
+
warns once per route, stating what the shell is stored with; with no `ppr`
|
|
1210
|
+
window set, the cap applies silently.
|
|
1211
|
+
|
|
1212
|
+
Separately from `maxSnapshotBytes`, the whole entry (prelude, postponed state,
|
|
1213
|
+
and snapshot) must fit the store's value limit — 25 MiB by default, Cloudflare
|
|
1214
|
+
KV's; `VercelCacheStore` derives its own from its item cap. A bigger entry
|
|
1215
|
+
refuses the capture (warned once per key) instead of failing the store write:
|
|
1216
|
+
shrink what the shell bakes or move large regions under a live loader's
|
|
1217
|
+
boundary.
|
|
797
1218
|
|
|
798
1219
|
The shell store is always the app-level `createRouter({ cache })` store; the
|
|
799
1220
|
default key is `${host}${pathname}${sortedSearch}:shell` (host-scoped so
|
|
@@ -810,47 +1231,81 @@ output is shared; see `/deployment-caching`.
|
|
|
810
1231
|
`updateTag()`/`revalidateTag()` is the ONLY lever that changes the frozen shell
|
|
811
1232
|
HTML; `revalidate()` is a DATA lever that never touches it.
|
|
812
1233
|
|
|
813
|
-
A captured shell
|
|
814
|
-
|
|
815
|
-
from a `"use cache"` function, a `cache()` segment, or a
|
|
816
|
-
`cacheTag()` in a plain server component (no
|
|
817
|
-
|
|
1234
|
+
A captured shell carries the tags of what renders into it: its segment
|
|
1235
|
+
record's tags — every `cacheTag(...)` the handler layer ran as shell material,
|
|
1236
|
+
whether from a `"use cache"` function, a `cache()` segment, or a
|
|
1237
|
+
render-callable `cacheTag()` in a plain server component (no
|
|
1238
|
+
`"use cache"`/`cache()` in its tree) — plus the tags of loaders on the BAKE
|
|
1239
|
+
lane (they execute during capture and their data is in the shell). A tag
|
|
1240
|
+
recorded outside what the shell renders is not on it. A live-lane loader's tags (every loader without `ssr: false`)
|
|
1241
|
+
attach only when a handler consumes its value with `ctx.use()`, which bakes
|
|
1242
|
+
it; one read only by the client stays off, since it is masked at capture and
|
|
1243
|
+
its hole is already live. `ppr.tags` adds
|
|
818
1244
|
operational tags the render cannot know (a tenant id, a deploy marker).
|
|
819
1245
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1246
|
+
When the route has its own `cache()`, the capture replays that record instead
|
|
1247
|
+
of re-rendering, so nothing it covers runs at capture. The record stores the
|
|
1248
|
+
tags its content recorded when the foreground wrote it and re-records them on
|
|
1249
|
+
the HIT, so the shell carries the same union. That includes a `loading()`
|
|
1250
|
+
slot's handler output (a HIT replays it from the record) and any loader a
|
|
1251
|
+
handler consumes with `ctx.use()`; a loader read only by `useLoader()` under
|
|
1252
|
+
`loading()` stays off. `updateTag()` of such a tag drops the record together
|
|
1253
|
+
with the shell, so the recapture renders fresh.
|
|
1254
|
+
|
|
1255
|
+
| Lever | Reaches the frozen shell? | Reaches the holes? |
|
|
1256
|
+
| ------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------- |
|
|
1257
|
+
| `updateTag` / `revalidateTag` on a SHELL tag | YES — drops the shell → MISS → recapture | n/a (holes are already live) |
|
|
1258
|
+
| `updateTag` / `revalidateTag` on a live-lane LOADER tag | only if a handler consumes the loader (`ctx.use()`) | drops that loader's cached value (if it `cache()`s) |
|
|
1259
|
+
| `revalidate()` (named revalidation contract) | **no** — re-runs segments/loaders for the PAYLOAD, never HTML | yes — the hole re-renders with fresh data |
|
|
825
1260
|
|
|
826
1261
|
A server action's automatic invalidation refreshes the CLIENT only — it re-runs
|
|
827
1262
|
the holes and streams a fresh payload, but does NOT evict the server shell.
|
|
828
1263
|
Shell-baked data stays stale until the shell's TTL unless you tag-invalidate it
|
|
829
1264
|
(`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
|
|
1265
|
+
evicted by tag at all — move always-fresh data into a live-lane loader (no
|
|
1266
|
+
`ssr: false`) or a nested promise in bake-lane loader data.
|
|
831
1267
|
|
|
832
1268
|
## Pitfalls
|
|
833
1269
|
|
|
834
1270
|
- **A bake-lane loader that reads `cookies()`/`headers()`**: the capture is
|
|
835
|
-
REFUSED (deterministic, once-per-key warned) — the route
|
|
836
|
-
Move identity
|
|
1271
|
+
REFUSED (deterministic, once-per-key warned) — the route keeps serving
|
|
1272
|
+
MISSes. Move identity onto the live lane (drop `ssr: false`, or split the read into
|
|
1273
|
+
a separate unflagged loader; give the reader a boundary). A nested promise
|
|
1274
|
+
does not help here: its body still runs during capture.
|
|
837
1275
|
- **A bake-lane container that must be fresh per document GET**: it is
|
|
838
1276
|
snapshot-pinned for the shell's lifetime by design. Use the live lane
|
|
839
|
-
(`
|
|
840
|
-
- **
|
|
841
|
-
capture
|
|
842
|
-
|
|
1277
|
+
(no `ssr: false`) or a nested promise instead.
|
|
1278
|
+
- **Handler output or a bake-lane loader slower than `ppr.captureTimeout`
|
|
1279
|
+
(15s by default)**: the capture stores nothing rather than a partial shell
|
|
1280
|
+
(the warning says the output "did not settle within ppr.captureTimeout").
|
|
1281
|
+
Make the slow part cheaper (`cache()`/`"use cache"`), move it into a live
|
|
1282
|
+
loader, or increase the route's budget only when the deployment can keep the
|
|
1283
|
+
background/build work alive.
|
|
1284
|
+
- **A pending handler promise expected to be a hole**: it is not. A promise
|
|
1285
|
+
the handler passes under `<Suspense>`, an async server component, or a
|
|
1286
|
+
promise nested in a handler's handle push is awaited at capture and served
|
|
1287
|
+
frozen on every HIT. Move per-request data into a live-lane loader read with
|
|
1288
|
+
`useLoader` under `loading()` or an inline `<Suspense>` (see "Migration from
|
|
1289
|
+
the old promise-hole model").
|
|
1290
|
+
- **`cache(false)` or a false `condition()` on a ppr route**: the document
|
|
1291
|
+
request renders like a cache miss — a normal render, no `x-rango-shell`
|
|
1292
|
+
header, no capture. A `condition()` that is false only for some requests
|
|
1293
|
+
renders those requests normally (no shell) while the others keep serving the
|
|
1294
|
+
shell.
|
|
843
1295
|
- **Per-user value in shell material**: baked into the shared shell —
|
|
844
|
-
deterministically, not by race (handler promises
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
loader —
|
|
1296
|
+
deterministically, not by race (handler output, promises included, is
|
|
1297
|
+
awaited at capture; awaited/resolved values bake everywhere). Put per-user
|
|
1298
|
+
data in a live-lane loader (no `ssr: false`) or a nested promise in
|
|
1299
|
+
bake-lane loader data — plain BAKE-lane loader data bakes just like handler
|
|
1300
|
+
material.
|
|
848
1301
|
- **The session-object bake trap (the guard cannot save you here)**: the
|
|
849
|
-
capture guard sees `cookies()
|
|
850
|
-
reading a middleware-provided
|
|
851
|
-
|
|
1302
|
+
capture guard sees `cookies()`, `headers()`, and `{ cache: false }` variable
|
|
1303
|
+
reads ONLY. A handler or bake-lane loader reading a NORMAL middleware-provided
|
|
1304
|
+
session object (`ctx.get("session")`) refuses nothing. Inside a bake-lane
|
|
1305
|
+
loader, per-user data survives ONLY behind a nested promise — the shape is
|
|
852
1306
|
the declaration, and it holds for BOTH branches regardless of settle timing
|
|
853
|
-
(nested thenables are masked at capture):
|
|
1307
|
+
(nested thenables are masked at capture). In a handler nothing survives: a
|
|
1308
|
+
handler's output bakes, promises included.
|
|
854
1309
|
|
|
855
1310
|
```typescript
|
|
856
1311
|
const CartLoader = createLoader(async (ctx) => {
|
|
@@ -863,36 +1318,53 @@ evicted by tag at all — move always-fresh data under a `loading()` hole.
|
|
|
863
1318
|
The remaining trap is returning per-user data as PLAIN container material:
|
|
864
1319
|
`return { user: session.user }` bakes it into the shared shell like any
|
|
865
1320
|
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
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
the
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
it
|
|
883
|
-
|
|
884
|
-
a
|
|
885
|
-
|
|
886
|
-
|
|
1321
|
+
(even an already-resolved one) or put the loader on the live lane (drop
|
|
1322
|
+
`ssr: false`).
|
|
1323
|
+
|
|
1324
|
+
- **Theme on a HIT is default-then-corrected**: the shell's `initialTheme` is
|
|
1325
|
+
the no-cookie default (`defaultTheme`), whoever captured it, and the resume
|
|
1326
|
+
tree replays it (resume requires it to match the frozen prelude). A visitor
|
|
1327
|
+
with a stored theme gets it pre-paint from the FOUC script and in
|
|
1328
|
+
`useTheme()` after ThemeProvider re-syncs post-mount. Nothing to configure —
|
|
1329
|
+
but a themed component in the shell may briefly render the default theme's
|
|
1330
|
+
markup before the post-mount re-sync.
|
|
1331
|
+
- **Shell shows CAPTURE-time data for the shell's lifetime**: a
|
|
1332
|
+
`cache()`/`"use cache"` value baked into the shell is PINNED at capture (the
|
|
1333
|
+
capture data snapshot) and replayed on every HIT, so the shell stays
|
|
1334
|
+
byte-identical to the frozen prelude even after that cache entry expires or
|
|
1335
|
+
is recomputed. This is deliberate — parity beats freshness inside the shell.
|
|
1336
|
+
Tags are the link back: every tag a `cache()`/`"use cache"` entry carried when
|
|
1337
|
+
the capture read it also rides the shell, so `updateTag` on that tag drops the
|
|
1338
|
+
shell too (next request MISSes and recaptures). An untagged entry has no such
|
|
1339
|
+
link. If a shell region needs to be fresh, put it under a hole — a live-lane
|
|
1340
|
+
loader (no `ssr: false`) behind `loading()` or an inline `<Suspense>`, or a
|
|
1341
|
+
nested promise in bake-lane loader data (holes are never pinned) — or make
|
|
1342
|
+
the SHELL itself invalidatable by tagging
|
|
1343
|
+
it: tag the cached read, call `cacheTag(...)` from the shell-material render
|
|
1344
|
+
code (the render-time lever), or add the tag to `ppr.tags` (operational tags
|
|
1345
|
+
the render cannot know — a tenant id, a deploy marker). Tags are optional: if
|
|
1346
|
+
TTL/SWR is the complete freshness policy, leave the shell untagged. Rango does not warn for that choice; with
|
|
887
1347
|
`debugShellCapture` enabled, a stored event reports `untaggedBake: true` when
|
|
888
1348
|
bake-lane loader material uses TTL/SWR-only invalidation.
|
|
889
|
-
- **
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1349
|
+
- **A `"use cache"` value the shell and a live hole both read comes from the
|
|
1350
|
+
cache store in the hole**: the shell keeps the capture-time value, and a
|
|
1351
|
+
live-lane loader under `loading()` or an inline `<Suspense>` that reads the
|
|
1352
|
+
same `"use cache"` entry reads it from the store on every HIT. It is still
|
|
1353
|
+
cached under its own profile; once the entry expires or is invalidated and
|
|
1354
|
+
refreshes, the hole shows the refreshed value next to the shell's old one. The
|
|
1355
|
+
capture stores only what a HIT reads: every HIT replays the handler layer
|
|
1356
|
+
from the captured segment record, so the `"use cache"` items only handler
|
|
1357
|
+
code read are not stored. When a bake-lane loader read the same entry at
|
|
1358
|
+
capture, the shell keeps it, and the hole gets that pinned value too.
|
|
1359
|
+
- **Uncached nondeterminism in server output is frozen, not drifting**: a raw
|
|
1360
|
+
`Date.now()` / `Math.random()` / uncached `fetch` in a handler or server
|
|
1361
|
+
component renders once per capture, and the prelude and every HIT show that
|
|
1362
|
+
same value for the shell's lifetime (an uncached async server component used
|
|
1363
|
+
to render twice per capture, so the prelude and the HIT payload disagreed).
|
|
1364
|
+
If the value must change per request, move it under a hole (a live-lane
|
|
1365
|
+
loader read under `loading()` or an inline `<Suspense>`). A CLIENT component
|
|
1366
|
+
that renders such a value during SSR still mismatches on hydration, as on
|
|
1367
|
+
any SSR page.
|
|
896
1368
|
- **Stacking with `/document-cache` or HTTP CDN caching**: both cache the
|
|
897
1369
|
completed composite, including live-hole output, so PPR becomes redundant on
|
|
898
1370
|
a hit. A platform CDN also bypasses every Rango middleware. Restrict this to
|
|
@@ -906,13 +1378,28 @@ cache"` value baked into the shell is PINNED at capture (the capture data
|
|
|
906
1378
|
500/redirect after the first shell byte — error UI renders inline via
|
|
907
1379
|
Suspense/error boundaries (the same property any streamed SSR page has after
|
|
908
1380
|
its shell flushes).
|
|
1381
|
+
- **A shell component that throws during capture refuses it**: an async
|
|
1382
|
+
server component (or a client component during SSR) inside a Suspense
|
|
1383
|
+
boundary that throws does not fail the capture render, so its errored
|
|
1384
|
+
boundary would bake into the shared prelude. The capture stores nothing
|
|
1385
|
+
instead, logs `[ShellCache] capture` with the error (it goes to `onError`
|
|
1386
|
+
as `cache-write` unless the router already reported that same error during
|
|
1387
|
+
the render), and backs the key off like any refused capture; the next
|
|
1388
|
+
capture after the window stores normally. The request that triggered the
|
|
1389
|
+
capture is served as before. Only a throw during the capture counts: a
|
|
1390
|
+
component that failed in the live render and succeeds when captured is
|
|
1391
|
+
stored.
|
|
909
1392
|
|
|
910
1393
|
## Related
|
|
911
1394
|
|
|
912
1395
|
- `/defer-hydration` — keep the full body HTML in the shell while moving a
|
|
913
1396
|
heavy subtree's hydration off the initial main-thread task (gated boundary,
|
|
914
1397
|
content-as-fallback)
|
|
915
|
-
- `/document-cache` — whole-response
|
|
916
|
-
- `/caching` and `/cache
|
|
1398
|
+
- `/document-cache` — store-backed whole-response caching (no live holes)
|
|
1399
|
+
- `/caching` — `cache()` segment caching and stores; `/use-cache` — function caching
|
|
917
1400
|
- `/shell-manifest` — replayed handles as cache metadata read by live loaders
|
|
918
|
-
-
|
|
1401
|
+
- `/prerender` — `Prerender` + `ppr` bakes the shell at build time
|
|
1402
|
+
- `/cache-guide` — where the shell layer sits among the other cache layers
|
|
1403
|
+
- `/deployment-caching` — why this is in-function PPR, not a CDN shell
|
|
1404
|
+
- Design doc: `docs/design/ppr-shell-resume.md` in the router repository (not
|
|
1405
|
+
shipped in the npm package)
|