@rangojs/router 0.0.0-experimental.143 → 0.0.0-experimental.144

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/vite/index.js +23 -6
  2. package/package.json +2 -2
  3. package/skills/cache-guide/SKILL.md +3 -1
  4. package/skills/caching/SKILL.md +23 -2
  5. package/skills/catalog.json +6 -0
  6. package/skills/defer-hydration/SKILL.md +235 -0
  7. package/skills/loader/SKILL.md +5 -0
  8. package/skills/migrate-nextjs/SKILL.md +4 -2
  9. package/skills/parallel/SKILL.md +2 -0
  10. package/skills/ppr/SKILL.md +34 -10
  11. package/skills/rango/SKILL.md +10 -0
  12. package/skills/use-cache/SKILL.md +12 -2
  13. package/src/browser/partial-update.ts +7 -0
  14. package/src/cache/cache-key-utils.ts +29 -0
  15. package/src/cache/cache-scope.ts +2 -17
  16. package/src/cache/cache-tag.ts +60 -14
  17. package/src/cache/cf/cf-cache-store.ts +54 -20
  18. package/src/cache/document-cache.ts +17 -11
  19. package/src/cache/vercel/vercel-cache-store.ts +9 -19
  20. package/src/redirect-origin.ts +14 -0
  21. package/src/route-map-builder.ts +17 -3
  22. package/src/router/lazy-includes.ts +8 -2
  23. package/src/router/loader-resolution.ts +14 -2
  24. package/src/router/match-handlers.ts +11 -6
  25. package/src/router/middleware.ts +4 -1
  26. package/src/router/telemetry.ts +9 -1
  27. package/src/router.ts +7 -8
  28. package/src/rsc/handler.ts +9 -2
  29. package/src/rsc/redirect-guard.ts +2 -1
  30. package/src/rsc/rsc-rendering.ts +35 -2
  31. package/src/rsc/shell-capture.ts +93 -20
  32. package/src/server/context.ts +47 -9
  33. package/src/server/cookie-store.ts +26 -5
  34. package/src/server/request-context.ts +22 -0
  35. package/src/ssr/index.tsx +145 -107
  36. package/src/testing/dispatch.ts +7 -0
  37. package/src/vite/inject-client-debug.ts +64 -12
  38. package/src/vite/router-discovery.ts +9 -1
@@ -2393,7 +2393,7 @@ import { resolve } from "node:path";
2393
2393
  // package.json
2394
2394
  var package_default = {
2395
2395
  name: "@rangojs/router",
2396
- version: "0.0.0-experimental.143",
2396
+ version: "0.0.0-experimental.144",
2397
2397
  description: "Django-inspired RSC router with composable URL patterns",
2398
2398
  keywords: [
2399
2399
  "react",
@@ -2594,7 +2594,7 @@ var package_default = {
2594
2594
  "@types/react-dom": "catalog:",
2595
2595
  esbuild: "^0.28.1",
2596
2596
  "happy-dom": "^20.10.1",
2597
- jiti: "^2.6.1",
2597
+ jiti: "^2.7.0",
2598
2598
  react: "catalog:",
2599
2599
  "react-dom": "catalog:",
2600
2600
  typescript: "^5.3.0",
@@ -4597,17 +4597,33 @@ import { createRequire as createRequire3, register } from "node:module";
4597
4597
  import { pathToFileURL as pathToFileURL2 } from "node:url";
4598
4598
 
4599
4599
  // src/vite/inject-client-debug.ts
4600
- function injectClientDebugFlag(id) {
4601
- if (!id.includes("internal-debug")) return null;
4600
+ function isRouterInternalDebugId(id) {
4601
+ if (!id.includes("internal-debug")) return false;
4602
4602
  const norm = id.replace(/\\/g, "/");
4603
- const isInternalDebug = /\/internal-debug\.[cm]?[jt]sx?(\?|$)/.test(norm) && (norm.includes("/@rangojs/router/") || norm.includes("/packages/rangojs-router/"));
4604
- if (!isInternalDebug) return null;
4603
+ return /\/internal-debug\.[cm]?[jt]sx?(\?|$)/.test(norm) && (norm.includes("/@rangojs/router/") || norm.includes("/packages/rangojs-router/"));
4604
+ }
4605
+ function injectClientDebugFlag(id) {
4606
+ if (!isRouterInternalDebugId(id)) return null;
4605
4607
  return {
4606
4608
  code: `export const INTERNAL_RANGO_DEBUG = ${!!process.env.INTERNAL_RANGO_DEBUG};
4607
4609
  `,
4608
4610
  map: null
4609
4611
  };
4610
4612
  }
4613
+ function internalDebugNoCacheMiddleware() {
4614
+ return function rangoInternalDebugNoCache(req, res, next) {
4615
+ if (req.url && isRouterInternalDebugId(req.url)) {
4616
+ const setHeader = res.setHeader.bind(res);
4617
+ res.setHeader = (name, value) => {
4618
+ return setHeader(
4619
+ name,
4620
+ name.toLowerCase() === "cache-control" ? "no-cache" : value
4621
+ );
4622
+ };
4623
+ }
4624
+ next();
4625
+ };
4626
+ }
4611
4627
 
4612
4628
  // src/vite/plugins/virtual-stub-plugin.ts
4613
4629
  function createVirtualStubPlugin() {
@@ -6843,6 +6859,7 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
6843
6859
  if (s.isBuildMode) return;
6844
6860
  if (globalThis.__rscRouterDiscoveryActive) return;
6845
6861
  s.devServer = server;
6862
+ server.middlewares.use(internalDebugNoCacheMiddleware());
6846
6863
  let resolveDiscovery;
6847
6864
  const discoveryPromise = new Promise((resolve12) => {
6848
6865
  resolveDiscovery = resolve12;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.143",
3
+ "version": "0.0.0-experimental.144",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -201,7 +201,7 @@
201
201
  "@types/react-dom": "catalog:",
202
202
  "esbuild": "^0.28.1",
203
203
  "happy-dom": "^20.10.1",
204
- "jiti": "^2.6.1",
204
+ "jiti": "^2.7.0",
205
205
  "react": "catalog:",
206
206
  "react-dom": "catalog:",
207
207
  "typescript": "^5.3.0",
@@ -20,7 +20,9 @@ caching:
20
20
  1. **Stored-value freshness** — _is a cached value still good?_
21
21
  → `"use cache"` (fn/component), `cache()` (segment), loader `cache()` (loader data).
22
22
  Entries expire by **TTL/SWR** and can be tagged (`cache({ tags })` or runtime
23
- `cacheTag(...tags)`). Built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`)
23
+ `cacheTag(...tags)` inside `"use cache"` it tags that entry; called during a
24
+ request render outside `"use cache"` it tags the document/shell artifact).
25
+ Built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`)
24
26
  index by tag; invalidate on demand with `updateTag(...tags)` (awaitable,
25
27
  read-your-own-writes) or `revalidateTag(...tags)` (background, non-blocking).
26
28
  Both hard-purge; the difference is awaitability, not stale-serving.
@@ -111,7 +111,7 @@ Caching") over caching the rendered segment.
111
111
 
112
112
  ## Tag-Based Invalidation
113
113
 
114
- Tag cached entries, then invalidate them on demand. Tags can be attached three ways:
114
+ Tag cached entries, then invalidate them on demand. Tags can be attached four ways:
115
115
 
116
116
  ```typescript
117
117
  // 1. Static tags in the cache() DSL
@@ -129,8 +129,22 @@ async function getProduct(id: string) {
129
129
  cacheTag(`product:${id}`, "products"); // variadic, additive
130
130
  return db.getProduct(id);
131
131
  }
132
+
133
+ // 4. Render-callable — a plain server component (no "use cache" in its tree)
134
+ // records onto the request's document/shell artifact.
135
+ function CampaignBanner() {
136
+ cacheTag("campaign:spring"); // rides ctx._requestTags → shell/document entry
137
+ return <aside>Spring sale</aside>;
138
+ }
132
139
  ```
133
140
 
141
+ Form 4 is how you make a PPR shell or a `/document-cache` page tag-invalidatable
142
+ without wrapping anything in `"use cache"`: the tag rides the request's
143
+ `_requestTags` onto the shell/document entry, and `revalidateTag` then evicts it.
144
+ On a route that is neither PPR nor document-cached the tag records where nothing
145
+ reads it — a silent no-op, so don't expect a bare `cacheTag()` to tag an ordinary
146
+ uncached page.
147
+
134
148
  Invalidate with one of two server-only verbs (both variadic, imported from
135
149
  `@rangojs/router`):
136
150
 
@@ -171,7 +185,8 @@ converge within `tagCacheTtl` (the **maximum extra cross-colo invalidation
171
185
  latency** when no purge is wired). Keep it small (e.g. 30–60), or wire a purge
172
186
  (below) and set it large. (Contrast `tagInvalidationTtl`, which must be _large_
173
187
  — it bounds how long the KV marker itself lives and must exceed your max entry
174
- TTL+SWR.)
188
+ TTL+SWR. Left unset there is no expiry: KV markers accumulate unbounded under
189
+ high-cardinality tags, so set it above your largest entry TTL+SWR to bound them.)
175
190
 
176
191
  To make other colos prompt without a short `tagCacheTtl`, pass `onRevalidateTag`:
177
192
  each cached marker carries a namespaced Cloudflare `Cache-Tag`, and the hook is
@@ -435,6 +450,12 @@ is **not** guarded. `ctx.use()` is a server-side escape hatch for non-rendered
435
450
  uses (set a ctx var, make a routing decision); never render its result inside a
436
451
  cached handler.
437
452
 
453
+ This is the **consumption-lane rule**, and it holds identically for every
454
+ shared artifact — `cache()`, `"use cache"`, and the PPR shell (`/ppr`):
455
+ handler consumption = baked copy with identity reads permitted; client-side
456
+ `useLoader` = live. Stated once in `/rango` → Invariants; pinned by
457
+ semantic-matrix row PPR3 and the `e2e/cache.test.ts` "baked copy" case.
458
+
438
459
  ```typescript
439
460
  // WRONG — throws: cookies() read directly in a cached handler
440
461
  cache({ ttl: 60 }, () => [
@@ -57,6 +57,12 @@
57
57
  "argumentHint": "",
58
58
  "path": "skills/debug-manifest/SKILL.md"
59
59
  },
60
+ {
61
+ "name": "defer-hydration",
62
+ "description": "Keep the full body HTML in a PPR shell's first paint while moving a heavy subtree's hydration off the initial main-thread task — a gated Suspense boundary with the content as its own fallback, released on first idle. Use when a shell HIT paints fast but one long hydration task blocks the main thread, TTI/INP is poor despite instant paint, or a plain Suspense boundary left an empty hole in the frozen prelude.",
63
+ "argumentHint": "",
64
+ "path": "skills/defer-hydration/SKILL.md"
65
+ },
60
66
  {
61
67
  "name": "document-cache",
62
68
  "description": "Cache the whole HTTP response at the edge with Cache-Control headers. Use when caching an entire page or response at a CDN edge, setting Cache-Control headers, or cutting origin hits for public pages — not for caching a single segment or function.",
@@ -0,0 +1,235 @@
1
+ ---
2
+ name: defer-hydration
3
+ description: Keep the full body HTML in a PPR shell's first paint while moving a heavy subtree's hydration off the initial main-thread task — a gated Suspense boundary with the content as its own fallback, released on first idle. Use when a shell HIT paints fast but one long hydration task blocks the main thread, TTI/INP is poor despite instant paint, or a plain Suspense boundary left an empty hole in the frozen prelude.
4
+ argument-hint:
5
+ ---
6
+
7
+ # Deferred hydration: gated boundary, content-as-fallback
8
+
9
+ PPR (`/ppr`) makes first paint instant — the frozen prelude flushes before any
10
+ render work. It does nothing about what happens next: React hydrates the page
11
+ as one synchronous main-thread task, and on a content-heavy page that task can
12
+ block every click and scroll handler for seconds. Measured on a production
13
+ storefront homepage (shell HIT, production build, M-series laptop): a single
14
+ 2543ms task. On the dev server the same page produced a 7.2s task — the page
15
+ was dead for ~11s. Fast paint, frozen page.
16
+
17
+ The obvious fix — wrap the heavy subtree in `<Suspense>` so it hydrates later —
18
+ trades the paint away: under shell capture that boundary postpones, and the
19
+ frozen prelude ships an empty `<main>`. This recipe gets both: the full body
20
+ HTML in the prelude AND its hydration off the initial task, released on first
21
+ idle at retry-lane priority.
22
+
23
+ | | baseline | plain `fallback={null}` boundary | gated content-as-fallback |
24
+ | -------------------------------- | -------- | -------------------------------- | ------------------------- |
25
+ | body in first paint | yes | **no — empty `<main>`** | yes |
26
+ | worst main-thread task | 2543ms | 844ms | **386ms** |
27
+ | total blocked time (>50ms tasks) | 2705ms | 1436ms | **554ms** |
28
+ | interactive (menu click works) | ~3.5s | ~1.6s | ~1.6s |
29
+
30
+ Measure your own page before and after — the win depends on how much of the
31
+ hydration cost lives under the boundary (see "Verifying and measuring").
32
+
33
+ ## The recipe
34
+
35
+ ~40 lines, plain React, no rango imports — copy it into your app:
36
+
37
+ ```tsx
38
+ "use client";
39
+ import type { ReactNode } from "react";
40
+ import { Suspense, startTransition, use, useEffect } from "react";
41
+
42
+ let released = false;
43
+ let releaseFn: (() => void) | undefined;
44
+ const gate = new Promise<void>((resolve) => {
45
+ releaseFn = resolve;
46
+ });
47
+
48
+ function releaseHydrationGate() {
49
+ if (released) return;
50
+ released = true;
51
+ // Transition so the boundary retry/hydration is scheduled non-urgent,
52
+ // never as a sync flush from the idle callback.
53
+ startTransition(() => releaseFn?.());
54
+ }
55
+
56
+ function HydrationGate() {
57
+ // Server: inert (children SSR normally). Client before release: suspend,
58
+ // so React skips hydrating the boundary and KEEPS the server DOM.
59
+ if (typeof window !== "undefined" && !released) use(gate);
60
+ return null;
61
+ }
62
+
63
+ function ReleaseHydrationGate() {
64
+ // Sibling of the boundary — NEVER under it (its effect would deadlock
65
+ // holding its own key). Hydrates with the early pass.
66
+ useEffect(() => {
67
+ if (released) return;
68
+ if ("requestIdleCallback" in window) {
69
+ requestIdleCallback(releaseHydrationGate, { timeout: 1500 });
70
+ } else {
71
+ setTimeout(releaseHydrationGate, 200);
72
+ }
73
+ }, []);
74
+ return null;
75
+ }
76
+
77
+ export function DeferredHydration({ children }: { children: ReactNode }) {
78
+ return (
79
+ <>
80
+ <ReleaseHydrationGate />
81
+ <Suspense fallback={children}>
82
+ <HydrationGate />
83
+ {children}
84
+ </Suspense>
85
+ </>
86
+ );
87
+ }
88
+ ```
89
+
90
+ Wrap the heavy subtree — typically the page body under the app chrome:
91
+
92
+ ```tsx
93
+ <DeferredHydration>
94
+ <HomePageBody />
95
+ </DeferredHydration>
96
+ ```
97
+
98
+ The chrome (header, nav — whatever must respond to the first click) stays
99
+ outside the boundary and hydrates in the early, now-small task. Everything
100
+ inside hydrates after first idle.
101
+
102
+ ## Why `fallback={children}` is load-bearing
103
+
104
+ This is not a style choice; it is the half of the recipe that makes it
105
+ PPR-compatible.
106
+
107
+ Shell capture aborts on flight byte-quiet (`src/rsc/shell-capture.ts`,
108
+ `FLIGHT_QUIET_HOPS`): once the Flight source has been byte-silent for the
109
+ quiet window, the fizz render freezes. A big HTML subtree under _any_
110
+ `<Suspense>` boundary cannot finish inside that window, so the boundary always
111
+ postpones — boundary placement cannot fix it. Verified both ways: wrapping the
112
+ client island from outside AND placing the boundary inside the island both
113
+ baked `<!--$?--><template id="B:…">` into `<main>`, i.e. an empty body in the
114
+ frozen prelude.
115
+
116
+ With the content as the fallback, the unavoidable postpone _becomes the
117
+ delivery mechanism_: the shell bakes the fallback, and the fallback IS the
118
+ body. In `/ppr` hole-doctrine terms, this is the PHYSICS class exploited
119
+ deliberately — you cannot stop the boundary from becoming a hole, so you make
120
+ the hole's baked fallback carry the real markup.
121
+
122
+ ## Why the client gate is free
123
+
124
+ Suspending during hydration keeps the **server DOM**, not the fallback. When
125
+ `HydrationGate` suspends on the client, React skips hydrating that boundary
126
+ and leaves the baked HTML in place — visible, styled, inert. On release, the
127
+ boundary retries on the retry lane (interruptible, non-urgent thanks to the
128
+ `startTransition` in `releaseHydrationGate`), and the existing DOM hydrates in
129
+ place. No blank, no flicker, no re-paint.
130
+
131
+ ## The sync-update trap (scar tissue)
132
+
133
+ A **synchronous** update that reaches into a dehydrated boundary makes React
134
+ abandon hydration and client-render it instead. That client render suspends on
135
+ the gate and renders the fallback. With `fallback={children}` this is a visual
136
+ no-op (but wasted work); with `fallback={null}` it blanks the page.
137
+
138
+ The corollary: provider data syncs that land right after the chrome hydrates —
139
+ basket, wishlist, auth state read from storage in an effect — MUST be
140
+ `startTransition`-wrapped. This was measured, not theorized: without the
141
+ transitions, the boundary was force-hydrated synchronously and the split
142
+ evaporated (the 2543ms task survived intact).
143
+
144
+ ```tsx
145
+ useEffect(() => {
146
+ const stored = readBasketFromStorage();
147
+ startTransition(() => setBasket(stored)); // NOT a bare setBasket(stored)
148
+ }, []);
149
+ ```
150
+
151
+ ## Pre-release interaction semantics
152
+
153
+ Between paint and release (window ≈ one idle, capped by the `requestIdleCallback`
154
+ timeout — 1500ms in the reference):
155
+
156
+ - **Native anchors work** — they are plain HTML in the server DOM, plus any
157
+ click-delegation living above the boundary.
158
+ - **React `onClick`s inside the gated subtree queue** via React's event replay
159
+ and fire on hydration after release.
160
+
161
+ If the gated subtree's first-click latency matters more than idle timing,
162
+ release on interaction instead (see Variations).
163
+
164
+ ## Known cost: the body rides twice (measure it, don't guess)
165
+
166
+ On a shell HIT the gated subtree's HTML is in the response twice — once as the
167
+ baked fallback in the prelude, once as the resumed hole content (the resume
168
+ re-renders and re-ships it; there is no bake-through). Homepage measurement:
169
+ 234KB → 302KB gzipped (+68KB, +29%; raw +1.05MB). It is post-paint bandwidth,
170
+ not render-blocking — the visible prelude streams first — but it is real bytes
171
+ on every document GET. Weigh it per page; on a small body the recipe may not
172
+ pay for itself.
173
+
174
+ ## Verifying and measuring
175
+
176
+ Production build only — dev-server hydration numbers are noise (module
177
+ transforms dominate; the 7.2s dev task above vs 2543ms in production).
178
+
179
+ **Body in the prelude.** Fetch the document and check a distinctive piece of
180
+ body markup appears BEFORE the first resumed segment:
181
+
182
+ ```
183
+ curl -s -H "Accept: text/html" http://localhost:4173/ \
184
+ | awk '{ if (match($0, /<div hidden id="S:/)) { print substr($0, 1, RSTART); exit } print }' \
185
+ | grep -c "Best Sellers" # any string unique to the gated body
186
+ ```
187
+
188
+ `0` with `<!--$?--><template id="B:` markers inside `<main>` means an empty
189
+ hole baked instead — the fallback is not the content (wrong boundary, or a
190
+ plain `fallback={null}`/skeleton boundary).
191
+
192
+ **Main-thread tasks.** Paste a longtask observer in the console before
193
+ reloading, then compare the worst task with the recipe on and off:
194
+
195
+ ```js
196
+ new PerformanceObserver((l) =>
197
+ l
198
+ .getEntries()
199
+ .forEach((e) => console.log("longtask", Math.round(e.duration))),
200
+ ).observe({ entryTypes: ["longtask"] });
201
+ ```
202
+
203
+ **Interactivity.** Click the chrome (menu, nav) immediately after paint — it
204
+ should respond while the gated body is still inert.
205
+
206
+ ## Variations
207
+
208
+ - **Release on visible** — an `IntersectionObserver` per boundary instead of
209
+ `requestIdleCallback`: below-the-fold sections hydrate only when scrolled
210
+ near.
211
+ - **Release on first interaction** — a capture-phase listener
212
+ (`pointerdown`/`keydown` on `window`) that releases immediately: the queued
213
+ event replays into the touched boundary and React's selective hydration
214
+ prioritizes it. Best when the gated subtree is itself the interaction
215
+ target.
216
+ - **One shared gate vs per-boundary gates** — the reference uses one
217
+ module-level gate (first release wins, all boundaries hydrate together).
218
+ Multiple independent boundaries (visible-based, per-section) need one
219
+ gate/`released` pair per boundary — factor the module into a
220
+ `createHydrationGate()` if you go there.
221
+
222
+ ## What this deliberately is not
223
+
224
+ The duplicated payload has an obvious framework-level fix: a "bake-through"
225
+ boundary that bakes the boundary _content_ into the prelude and skips the
226
+ redundant hole resume. That is deliberately NOT part of this recipe — a recipe
227
+ has zero API commitment, and React's `<Activity>`/postpone work may land under
228
+ this exact space. The recipe survives that future; a primitive might not.
229
+
230
+ ## Related
231
+
232
+ - `/ppr` — the shell/hole mechanics this recipe rides on (hole doctrine:
233
+ PHYSICS class), and why the capture postpones any big Suspense subtree
234
+ - `src/rsc/shell-capture.ts` — the byte-quiet capture window
235
+ (`FLIGHT_QUIET_HOPS`) that makes `fallback={children}` mandatory
@@ -147,6 +147,11 @@ same memoized result — loaders never run twice per request.
147
147
  - The handler output depends on the loader data. If the route is inside
148
148
  `cache()`, the handler is cached with the loader result baked in —
149
149
  defeating the live data guarantee.
150
+ - The same holds under a PPR shell capture (`/ppr`): handler consumption is
151
+ the BAKED lane — the loader executes at capture (identity reads permitted)
152
+ and the rendered value is a capture-time copy; `useLoader` client-side is
153
+ the live lane. One rule across `cache()`, `"use cache"`, and PPR: the
154
+ consumption-lane rule (`/rango` → Invariants).
150
155
  - Non-cacheable variable reads (`createVar({ cache: false })`) inside the
151
156
  handler still throw, even if the data came from a loader.
152
157
  - Prefer DSL `loader()` + client `useLoader()` for data that depends on
@@ -447,8 +447,10 @@ Next.js conflates two things under "revalidation." Rango separates them — and
447
447
  tag-based cache invalidation now maps directly.
448
448
 
449
449
  **1. Cache invalidation (bust cached values) — direct equivalent.** Tag entries
450
- with `cache({ tags })` or, inside a `"use cache"` function, runtime
451
- `cacheTag(...tags)`. Then invalidate by tag:
450
+ with `cache({ tags })` or runtime `cacheTag(...tags)`. `cacheTag()` works inside a
451
+ `"use cache"` function (tags that entry) AND render-callable in a plain server
452
+ component (no `"use cache"` needed — it tags the document / PPR shell the component
453
+ renders into). Then invalidate by tag:
452
454
 
453
455
  ```typescript
454
456
  // Next.js Rango
@@ -239,6 +239,8 @@ layout(<AccountLayout />, () => [
239
239
 
240
240
  A slot's `loading()` (whether from `handler.use` or explicit) makes that slot an independent streaming unit, exactly as in the **Streaming Behavior** section above.
241
241
 
242
+ Under a shared artifact (`cache()`, `"use cache"`, a PPR shell), the server-side `await ctx.use(CartLoader)` above is the BAKED lane — the capture-time value (identity reads included) freezes into the artifact; consume the loader client-side (`useLoader` in a `"use client"` component) to keep the slot live per request. One rule, stated once: `/rango` → Invariants ("the consumption-lane rule").
243
+
242
244
  The `parallel` mount site has the narrowest allow-list for `handler.use` items — slots cannot bring their own middleware or layout, only `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, and `transition`. See [skills/handler-use](../handler-use/SKILL.md) for the full table and merge rules.
243
245
 
244
246
  `transition` is allowed in the slot allow-list, but slot-level rendering does **not** currently apply a `<ViewTransition>` wrapper — only the layout/route wraps take effect at render time. For a modal-only morph today, use an element-level React `<ViewTransition>` inside the slot's component. The reverse direction is the useful guarantee: a layout-level `transition()` fires when the layout's default outlet content changes but **not** when a `<ParallelOutlet />` mounts new content (modal opens are not subtree updates of the layout VT). See [skills/view-transitions](../view-transitions/SKILL.md) for the wrap rules and the intercept caveat.
@@ -368,16 +368,27 @@ stay live. Your levers, in order of preference:
368
368
  Slot-owned loaders are masked at capture and GUARANTEED fresh per serve —
369
369
  use this where the bake lane's physics (a fast resolve bakes) or pinning
370
370
  (capture-time data for the shell's lifetime) is not acceptable, at the cost
371
- of a widget-sized fallback in the shell.
371
+ of a widget-sized fallback in the shell. The slot handler must hand the
372
+ loader to a CLIENT component (`useLoader` in a `"use client"` component)
373
+ for the freshness guarantee to reach the rendered value: server-side
374
+ `await ctx.use(...)` in the handler is the BAKED lane (the consumption-lane
375
+ rule, `/rango` → Invariants) — it executes at capture with identity reads
376
+ permitted, but the value it renders is a capture-time copy wherever it is
377
+ not shielded by the slot's masked LoaderBoundary.
372
378
 
373
379
  4. **Shared layout data can also leave the loader lane entirely**: an
374
380
  un-awaited handler promise under the consumer's `<Suspense>` (a physics
375
381
  hole) or `cache()`/`"use cache"` to bake it with tag-invalidation.
376
382
 
377
383
  The identity rule, stated once: per-user data on a PPR page lives in a NESTED
378
- promise (a hole, fresh per request) or behind `loading()` (the live lane).
379
- Reading `cookies()`/`headers()` where the value would bake — handler shell
380
- material or a bake-lane containerrefuses the capture by construction.
384
+ promise (a hole, fresh per request) or behind `loading()` with CLIENT-side
385
+ consumption (the live lane). Reading `cookies()`/`headers()` where the value
386
+ would bake as SEGMENT materialhandler/render code or a bake-lane loader
387
+ container — refuses the capture by construction. The one exemption is
388
+ handler-INVOKED loader bodies (`await ctx.use(loader)`): they execute at
389
+ capture with identity reads permitted, and the value bakes as a shared
390
+ capture-time copy — mirroring `cache()` semantics (the consumption-lane
391
+ rule; semantic-matrix row PPR3).
381
392
 
382
393
  ## Execution matrix
383
394
 
@@ -505,10 +516,11 @@ multi-tenant shells never collide).
505
516
  HTML; `revalidate()` is a DATA lever that never touches it.
506
517
 
507
518
  A captured shell auto-carries the UNION of the non-loader tags recorded during
508
- the capture render — every `cacheTag(...)` from a `"use cache"` function or
509
- `cache()` segment that ran as shell material. Loader tags never attach (the
510
- holes are already live). `ppr.tags` adds operational tags the render cannot
511
- know (a tenant id, a deploy marker).
519
+ the capture render — every `cacheTag(...)` that ran as shell material, whether
520
+ from a `"use cache"` function, a `cache()` segment, or a render-callable
521
+ `cacheTag()` in a plain server component (no `"use cache"`/`cache()` in its
522
+ tree). Loader tags never attach (the holes are already live). `ppr.tags` adds
523
+ operational tags the render cannot know (a tenant id, a deploy marker).
512
524
 
513
525
  | Lever | Reaches the frozen shell? | Reaches the holes? |
514
526
  | --------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------- |
@@ -516,6 +528,12 @@ know (a tenant id, a deploy marker).
516
528
  | `updateTag` / `revalidateTag` on a LOADER tag | no — loader tags never attach to a shell | drops that loader's cached value (if it `cache()`s) |
517
529
  | `revalidate()` (named revalidation contract) | **no** — re-runs segments/loaders for the PAYLOAD, never HTML | yes — the hole re-renders with fresh data |
518
530
 
531
+ A server action's automatic invalidation refreshes the CLIENT only — it re-runs
532
+ the holes and streams a fresh payload, but does NOT evict the server shell.
533
+ Shell-baked data stays stale until the shell's TTL unless you tag-invalidate it
534
+ (`updateTag` on a shell tag). Data baked into the shell WITHOUT a tag cannot be
535
+ evicted by tag at all — move always-fresh data under a `loading()` hole.
536
+
519
537
  ## Pitfalls
520
538
 
521
539
  - **A bake-lane loader that reads `cookies()`/`headers()`**: the capture is
@@ -563,8 +581,11 @@ cache"` value baked into the shell is PINNED at capture (the capture data
563
581
  tag-invalidated. This is deliberate — parity beats freshness inside the shell.
564
582
  If a shell region needs to be fresh, put it under a hole — `loading()` for
565
583
  loader data, or an un-awaited promise under the consumer's `<Suspense>`
566
- (holes are never pinned) — or make the SHELL itself invalidatable by adding
567
- the tag to `ppr.tags`. Ring-1/ring-3 tag invalidation does NOT drop the shell.
584
+ (holes are never pinned) — or make the SHELL itself invalidatable by tagging
585
+ it: call `cacheTag(...)` from the shell-material render code (the render-time
586
+ lever), or add the tag to `ppr.tags` (operational tags the render cannot know —
587
+ a tenant id, a deploy marker). Ring-1/ring-3 tag invalidation does NOT drop the
588
+ shell.
568
589
  - **Uncached nondeterminism in the shell is a hydration hazard**: a raw
569
590
  `Date.now()` / `Math.random()` / uncached `fetch` rendered directly in shell
570
591
  material (outside any cache ring) drifts between capture and hit and the
@@ -586,6 +607,9 @@ cache"` value baked into the shell is PINNED at capture (the capture data
586
607
 
587
608
  ## Related
588
609
 
610
+ - `/defer-hydration` — keep the full body HTML in the shell while moving a
611
+ heavy subtree's hydration off the initial main-thread task (gated boundary,
612
+ content-as-fallback)
589
613
  - `/document-cache` — whole-response edge caching (no live holes)
590
614
  - `/caching` and `/cache-guide` — segment/function caching (axis 1 data)
591
615
  - `/shell-manifest` — replayed handles as cache metadata read by live loaders
@@ -117,6 +117,15 @@ stated, greppable contract.
117
117
  - `path()`/`include()` are always visible in `urls()`; config helpers are extractable.
118
118
  - **Cache decides freshness; `revalidate()` decides client-update.** Orthogonal; compose.
119
119
  - Loaders resolve fresh every request (even inside `cache()`) and never run twice/request.
120
+ - **The consumption-lane rule.** For every shared artifact (`cache()`,
121
+ `"use cache"`, the PPR shell): server-side handler consumption
122
+ (`await ctx.use(loader)`) yields a BAKED copy — identity reads
123
+ (`cookies()`/`headers()`) are permitted there and the capture-time value
124
+ freezes into the shared artifact (a documented footgun; see `/caching` →
125
+ "Cache purity & tainted objects"). Client-side consumption (`useLoader` in
126
+ a `"use client"` component) is the LIVE lane. DSL `loader()` segments
127
+ follow their lane machinery (live under renderable `loading()`, bake
128
+ otherwise). Pinned by semantic-matrix row PPR3.
120
129
  - Inside `"use cache"`: `cookies()`/`headers()` and `ctx` side-effects
121
130
  (`set`/`header`/`setTheme`/`onResponse`/`setLocationState`) throw; `ctx.use(Handle)`
122
131
  is captured on miss and replayed on hit. (The non-cacheable read guard is a
@@ -273,6 +282,7 @@ Grouped by concern — read when you need to…
273
282
  | `/scripts` | Inject third-party scripts (GTM/analytics) into head/body via the `Script` handle; nonce auto-applied to document-rendered scripts |
274
283
  | `/tailwind` | Set up Tailwind CSS v4 with `?url` imports |
275
284
  | `/view-transitions` | React View Transitions on layouts, routes, and parallel slots |
285
+ | `/defer-hydration` | Full body HTML in the PPR shell + hydration off the critical path (gated Suspense boundary, content-as-fallback) |
276
286
  | `/breadcrumbs` | Built-in Breadcrumbs handle for breadcrumb navigation |
277
287
  | `/react-compiler` | Enable React Compiler (opt-in) the vite-rsc way; client-only scope |
278
288
 
@@ -153,6 +153,13 @@ const locale = cookies().get("locale")?.value ?? "en";
153
153
  const data = await getCachedData(locale); // locale is now in the cache key
154
154
  ```
155
155
 
156
+ The guard does not reach into LOADER bodies consumed inside the cached
157
+ function (`await ctx.use(loader)`): loaders always run fresh, so their reads
158
+ are exempt — but the CONSUMED VALUE is captured into the shared cache entry
159
+ like any other computed data. Same rule across `cache()` and the PPR shell:
160
+ handler/cached-scope consumption = baked copy, client-side `useLoader` = live
161
+ (the consumption-lane rule, `/rango` → Invariants).
162
+
156
163
  ### Side-Effect Guards
157
164
 
158
165
  These ctx methods **throw** inside a `"use cache"` function because their effects
@@ -346,8 +353,11 @@ export async function getProducts() {
346
353
  Writes to the same `SegmentCacheStore` as `cache()` DSL, `Static()`, and `Prerender()`.
347
354
  One store, one configuration.
348
355
 
349
- Cache entries (and `cacheProfiles`) can be tagged via `cache({ tags })` or, inside
350
- a `"use cache"` function, runtime `cacheTag(...tags)`. The built-in
356
+ Cache entries (and `cacheProfiles`) can be tagged via `cache({ tags })` or runtime
357
+ `cacheTag(...tags)`. `cacheTag` has two forms: inside a `"use cache"` function it
358
+ tags that entry; called during a request render outside `"use cache"` it tags the
359
+ request's document/shell artifact (rides `_requestTags`) instead of throwing. The
360
+ built-in
351
361
  `MemorySegmentCacheStore` and `CFCacheStore` index by tag. Invalidate on demand
352
362
  with `updateTag(...tags)` (awaitable, read-your-own-writes; for server actions) or
353
363
  `revalidateTag(...tags)` (background, non-blocking; for route handlers/webhooks).
@@ -395,6 +395,13 @@ export function createPartialUpdater(
395
395
  return;
396
396
  }
397
397
  if (mode.type === "action") {
398
+ // An action refetch that lands on missing segments (navigated away /
399
+ // consolidation / HMR) drops rather than refetch-all: the action flow
400
+ // is storeOnly / skipLoadingState, so a full refetch here would fight
401
+ // it. Keep the stale-but-consistent tree; log so the drop is visible.
402
+ debugLog(
403
+ `[Browser] Action refetch: ${missingCount} segments missing; dropping (stale-but-consistent tree kept).`,
404
+ );
398
405
  return;
399
406
  }
400
407
  console.warn(
@@ -58,3 +58,32 @@ export function sortedRouteParams(
58
58
  if (!params) return "";
59
59
  return encodeKV(Object.entries(params), { sort: true });
60
60
  }
61
+
62
+ /**
63
+ * Host-namespaced cache key base: `${host}${pathname}[:params][?search]`.
64
+ *
65
+ * The ONE composition of the host-namespacing rule, shared by the segment tier
66
+ * (cache-scope.ts) and the document tier (document-cache.ts) so the rule cannot
67
+ * drift between them. Host prefixing matters because VercelCacheStore /
68
+ * MemorySegmentCacheStore key by the raw string (only CFCacheStore adds host
69
+ * internally) -- on a single function serving multiple domains an
70
+ * un-namespaced key bleeds tenant A's cached response to tenant B.
71
+ *
72
+ * Output is BYTE-STABLE by contract: changing the composition silently
73
+ * invalidates every persisted cache entry on upgrade. Callers append their own
74
+ * tier-specific suffixes (`:rsc`/`:html`, segment hash) after this base.
75
+ */
76
+ export function cacheKeyBase(
77
+ host: string,
78
+ pathname: string,
79
+ searchParams?: URLSearchParams,
80
+ params?: Record<string, string>,
81
+ ): string {
82
+ const paramStr = sortedRouteParams(params);
83
+ const searchStr = searchParams ? sortedSearchString(searchParams) : "";
84
+
85
+ let key = `${host}${pathname}`;
86
+ if (paramStr) key += `:${paramStr}`;
87
+ if (searchStr) key += `?${searchStr}`;
88
+ return key;
89
+ }