@rangojs/router 0.0.0-experimental.143 → 0.0.0-experimental.145
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/dist/vite/index.js +24 -6
- package/package.json +2 -2
- package/skills/cache-guide/SKILL.md +3 -1
- package/skills/caching/SKILL.md +23 -2
- package/skills/catalog.json +6 -0
- package/skills/defer-hydration/SKILL.md +235 -0
- package/skills/loader/SKILL.md +5 -0
- package/skills/migrate-nextjs/SKILL.md +4 -2
- package/skills/parallel/SKILL.md +2 -0
- package/skills/ppr/SKILL.md +63 -33
- package/skills/rango/SKILL.md +10 -0
- package/skills/use-cache/SKILL.md +12 -2
- package/src/browser/logging.ts +18 -0
- package/src/browser/partial-update.ts +7 -0
- package/src/browser/rsc-router.tsx +43 -0
- package/src/cache/cache-key-utils.ts +29 -0
- package/src/cache/cache-runtime.ts +41 -51
- package/src/cache/cache-scope.ts +2 -17
- package/src/cache/cache-tag.ts +60 -14
- package/src/cache/cf/cf-cache-store.ts +58 -20
- package/src/cache/document-cache.ts +17 -11
- package/src/cache/types.ts +18 -4
- package/src/cache/vercel/vercel-cache-store.ts +15 -20
- package/src/redirect-origin.ts +14 -0
- package/src/route-map-builder.ts +17 -3
- package/src/router/lazy-includes.ts +8 -2
- package/src/router/loader-resolution.ts +14 -2
- package/src/router/match-handlers.ts +11 -6
- package/src/router/middleware.ts +4 -1
- package/src/router/segment-resolution/loader-cache.ts +19 -3
- package/src/router/segment-resolution/loader-mask.ts +4 -11
- package/src/router/segment-resolution/loader-snapshot.ts +14 -6
- package/src/router/segment-resolution/mask-nested.ts +83 -0
- package/src/router/telemetry.ts +9 -1
- package/src/router.ts +7 -8
- package/src/rsc/handler.ts +9 -2
- package/src/rsc/redirect-guard.ts +2 -1
- package/src/rsc/rsc-rendering.ts +122 -18
- package/src/rsc/shell-capture.ts +125 -20
- package/src/rsc/shell-serve.ts +37 -6
- package/src/segment-loader-promise.ts +18 -0
- package/src/segment-system.tsx +90 -6
- package/src/server/context.ts +47 -9
- package/src/server/cookie-store.ts +26 -5
- package/src/server/request-context.ts +22 -0
- package/src/ssr/index.tsx +160 -113
- package/src/ssr/inject-rsc-eager.ts +167 -0
- package/src/testing/dispatch.ts +7 -0
- package/src/vite/index.ts +7 -0
- package/src/vite/inject-client-debug.ts +64 -12
- package/src/vite/router-discovery.ts +9 -1
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
2396
|
+
version: "0.0.0-experimental.145",
|
|
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.
|
|
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
|
|
4601
|
-
if (!id.includes("internal-debug")) return
|
|
4600
|
+
function isRouterInternalDebugId(id) {
|
|
4601
|
+
if (!id.includes("internal-debug")) return false;
|
|
4602
4602
|
const norm = id.replace(/\\/g, "/");
|
|
4603
|
-
|
|
4604
|
-
|
|
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;
|
|
@@ -8200,6 +8217,7 @@ function poke() {
|
|
|
8200
8217
|
};
|
|
8201
8218
|
}
|
|
8202
8219
|
export {
|
|
8220
|
+
directoryClientChunks,
|
|
8203
8221
|
poke,
|
|
8204
8222
|
rango
|
|
8205
8223
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.0.0-experimental.
|
|
3
|
+
"version": "0.0.0-experimental.145",
|
|
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.
|
|
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)`
|
|
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.
|
package/skills/caching/SKILL.md
CHANGED
|
@@ -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
|
|
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 }, () => [
|
package/skills/catalog.json
CHANGED
|
@@ -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
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -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
|
|
451
|
-
`
|
|
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
|
package/skills/parallel/SKILL.md
CHANGED
|
@@ -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.
|
package/skills/ppr/SKILL.md
CHANGED
|
@@ -251,18 +251,18 @@ async function Handler(ctx: HandlerContext) {
|
|
|
251
251
|
| already-resolved / instant / synchronous values | `loader(() => Promise.resolve(x))` + `loading()` | a raw promise that settles inside the quiet window BAKES; only the live lane guarantees live |
|
|
252
252
|
| none of the above | nothing | it bakes — that is what the shell is for |
|
|
253
253
|
|
|
254
|
-
The physics caveat in one line: promise
|
|
255
|
-
genuinely pending at capture
|
|
256
|
-
read, warmed cache), it may bake into the shell
|
|
257
|
-
guaranteed rather than probable, use
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
254
|
+
The physics caveat in one line: HANDLER-created promise props are holes only
|
|
255
|
+
because the I/O is genuinely pending at capture — if the value can resolve
|
|
256
|
+
near-instantly (memory read, warmed cache), it may bake into the shell; when
|
|
257
|
+
liveness must be guaranteed rather than probable, use a loader. BAKE-LANE
|
|
258
|
+
NESTED promises are exempt from that race: the capture MASKS every thenable
|
|
259
|
+
nested in a bake-lane container regardless of settle timing
|
|
260
|
+
(`maskNestedContainerThenables`, loader-cache.ts), so the consuming boundary
|
|
261
|
+
always postpones as a hole and every HIT streams the FRESH value — the
|
|
262
|
+
promise SHAPE is the liveness declaration, not a bet on latency. (Before the
|
|
263
|
+
mask, a nested promise that settled inside the window pinned its capture-time
|
|
264
|
+
value into the shared shell; found live as a storefront basket — with the
|
|
265
|
+
capturing session's identifiers — served to anonymous visitors.)
|
|
266
266
|
|
|
267
267
|
### Handles: "nesting = liveness"
|
|
268
268
|
|
|
@@ -272,7 +272,11 @@ as PINNED for correctness purposes.
|
|
|
272
272
|
by the capture's 5s guard).
|
|
273
273
|
- `ctx.use(H)({ x: promise })` — the container passes through verbatim
|
|
274
274
|
(resolution is shallow); the nested promise streams to the consumer, who must
|
|
275
|
-
`<Suspense>` it. Under capture that boundary postpones — a hole
|
|
275
|
+
`<Suspense>` it. Under capture that boundary postpones — a hole — REGARDLESS
|
|
276
|
+
of settle timing: the capture masks nested thenables in pushed handle
|
|
277
|
+
containers (the capture store's push wrap, shell-capture.ts), so even an
|
|
278
|
+
already-resolved nested promise holes instead of baking its value into the
|
|
279
|
+
shared shell. Same shape-is-the-declaration rule as bake-lane loaders.
|
|
276
280
|
|
|
277
281
|
### Want a hole for already-resolved data?
|
|
278
282
|
|
|
@@ -284,8 +288,9 @@ how fast the value settles.
|
|
|
284
288
|
|
|
285
289
|
A loader on an entry with no renderable `loading()` EXECUTES during capture
|
|
286
290
|
(the capture gate holds open for its real latency, bounded by the 5s guard).
|
|
287
|
-
Its settled container bakes into the prelude; every promise
|
|
288
|
-
|
|
291
|
+
Its settled container bakes into the prelude; every promise nested in it is
|
|
292
|
+
masked at capture (regardless of how fast it settles) and postpones at the
|
|
293
|
+
consumer's own `<Suspense>` — a hole. On every HIT the
|
|
289
294
|
capture snapshot's loader family overlays the recorded container onto the
|
|
290
295
|
fresh run, so the payload matches the frozen prelude byte-for-byte while the
|
|
291
296
|
nested promises run fresh. The return shape is the declaration:
|
|
@@ -368,16 +373,27 @@ stay live. Your levers, in order of preference:
|
|
|
368
373
|
Slot-owned loaders are masked at capture and GUARANTEED fresh per serve —
|
|
369
374
|
use this where the bake lane's physics (a fast resolve bakes) or pinning
|
|
370
375
|
(capture-time data for the shell's lifetime) is not acceptable, at the cost
|
|
371
|
-
of a widget-sized fallback in the shell.
|
|
376
|
+
of a widget-sized fallback in the shell. The slot handler must hand the
|
|
377
|
+
loader to a CLIENT component (`useLoader` in a `"use client"` component)
|
|
378
|
+
for the freshness guarantee to reach the rendered value: server-side
|
|
379
|
+
`await ctx.use(...)` in the handler is the BAKED lane (the consumption-lane
|
|
380
|
+
rule, `/rango` → Invariants) — it executes at capture with identity reads
|
|
381
|
+
permitted, but the value it renders is a capture-time copy wherever it is
|
|
382
|
+
not shielded by the slot's masked LoaderBoundary.
|
|
372
383
|
|
|
373
384
|
4. **Shared layout data can also leave the loader lane entirely**: an
|
|
374
385
|
un-awaited handler promise under the consumer's `<Suspense>` (a physics
|
|
375
386
|
hole) or `cache()`/`"use cache"` to bake it with tag-invalidation.
|
|
376
387
|
|
|
377
388
|
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()`
|
|
379
|
-
Reading `cookies()`/`headers()` where the value
|
|
380
|
-
|
|
389
|
+
promise (a hole, fresh per request) or behind `loading()` with CLIENT-side
|
|
390
|
+
consumption (the live lane). Reading `cookies()`/`headers()` where the value
|
|
391
|
+
would bake as SEGMENT material — handler/render code or a bake-lane loader
|
|
392
|
+
container — refuses the capture by construction. The one exemption is
|
|
393
|
+
handler-INVOKED loader bodies (`await ctx.use(loader)`): they execute at
|
|
394
|
+
capture with identity reads permitted, and the value bakes as a shared
|
|
395
|
+
capture-time copy — mirroring `cache()` semantics (the consumption-lane
|
|
396
|
+
rule; semantic-matrix row PPR3).
|
|
381
397
|
|
|
382
398
|
## Execution matrix
|
|
383
399
|
|
|
@@ -505,10 +521,11 @@ multi-tenant shells never collide).
|
|
|
505
521
|
HTML; `revalidate()` is a DATA lever that never touches it.
|
|
506
522
|
|
|
507
523
|
A captured shell auto-carries the UNION of the non-loader tags recorded during
|
|
508
|
-
the capture render — every `cacheTag(...)`
|
|
509
|
-
`cache()` segment
|
|
510
|
-
|
|
511
|
-
|
|
524
|
+
the capture render — every `cacheTag(...)` that ran as shell material, whether
|
|
525
|
+
from a `"use cache"` function, a `cache()` segment, or a render-callable
|
|
526
|
+
`cacheTag()` in a plain server component (no `"use cache"`/`cache()` in its
|
|
527
|
+
tree). Loader tags never attach (the holes are already live). `ppr.tags` adds
|
|
528
|
+
operational tags the render cannot know (a tenant id, a deploy marker).
|
|
512
529
|
|
|
513
530
|
| Lever | Reaches the frozen shell? | Reaches the holes? |
|
|
514
531
|
| --------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------- |
|
|
@@ -516,6 +533,12 @@ know (a tenant id, a deploy marker).
|
|
|
516
533
|
| `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
534
|
| `revalidate()` (named revalidation contract) | **no** — re-runs segments/loaders for the PAYLOAD, never HTML | yes — the hole re-renders with fresh data |
|
|
518
535
|
|
|
536
|
+
A server action's automatic invalidation refreshes the CLIENT only — it re-runs
|
|
537
|
+
the holes and streams a fresh payload, but does NOT evict the server shell.
|
|
538
|
+
Shell-baked data stays stale until the shell's TTL unless you tag-invalidate it
|
|
539
|
+
(`updateTag` on a shell tag). Data baked into the shell WITHOUT a tag cannot be
|
|
540
|
+
evicted by tag at all — move always-fresh data under a `loading()` hole.
|
|
541
|
+
|
|
519
542
|
## Pitfalls
|
|
520
543
|
|
|
521
544
|
- **A bake-lane loader that reads `cookies()`/`headers()`**: the capture is
|
|
@@ -534,21 +557,22 @@ know (a tenant id, a deploy marker).
|
|
|
534
557
|
- **The session-object bake trap (the guard cannot save you here)**: the
|
|
535
558
|
capture guard sees `cookies()`/`headers()` calls ONLY. A bake-lane loader
|
|
536
559
|
reading a middleware-provided session object (`ctx.get("session")`) refuses
|
|
537
|
-
nothing
|
|
560
|
+
nothing. Per-user data survives ONLY behind a nested promise — the shape is
|
|
561
|
+
the declaration, and it holds for BOTH branches regardless of settle timing
|
|
562
|
+
(nested thenables are masked at capture):
|
|
538
563
|
|
|
539
564
|
```typescript
|
|
540
565
|
const CartLoader = createLoader(async (ctx) => {
|
|
541
566
|
const basketId = ctx.get("session")!.get("basketId");
|
|
542
|
-
if (!basketId) return { cart: Promise.resolve(null) }; //
|
|
543
|
-
return { cart: fetchBasket(basketId) }; //
|
|
567
|
+
if (!basketId) return { cart: Promise.resolve(null) }; // nested thenable → masked → hole, fresh per HIT
|
|
568
|
+
return { cart: fetchBasket(basketId) }; // nested thenable → masked → hole, fresh per HIT
|
|
544
569
|
});
|
|
545
570
|
```
|
|
546
571
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
widget, a parallel slot with its own `loading()` (playbook lever 3).
|
|
572
|
+
The remaining trap is returning per-user data as PLAIN container material:
|
|
573
|
+
`return { user: session.user }` bakes it into the shared shell like any
|
|
574
|
+
other settled value — deterministically, not by race. Wrap it in a promise
|
|
575
|
+
(even an already-resolved one) or put the loader on the live lane.
|
|
552
576
|
|
|
553
577
|
- **Theme on a HIT is capture-then-corrected**: the resume tree replays the
|
|
554
578
|
CAPTURE's `initialTheme` (resume requires it to match the frozen prelude);
|
|
@@ -563,8 +587,11 @@ cache"` value baked into the shell is PINNED at capture (the capture data
|
|
|
563
587
|
tag-invalidated. This is deliberate — parity beats freshness inside the shell.
|
|
564
588
|
If a shell region needs to be fresh, put it under a hole — `loading()` for
|
|
565
589
|
loader data, or an un-awaited promise under the consumer's `<Suspense>`
|
|
566
|
-
(holes are never pinned) — or make the SHELL itself invalidatable by
|
|
567
|
-
|
|
590
|
+
(holes are never pinned) — or make the SHELL itself invalidatable by tagging
|
|
591
|
+
it: call `cacheTag(...)` from the shell-material render code (the render-time
|
|
592
|
+
lever), or add the tag to `ppr.tags` (operational tags the render cannot know —
|
|
593
|
+
a tenant id, a deploy marker). Ring-1/ring-3 tag invalidation does NOT drop the
|
|
594
|
+
shell.
|
|
568
595
|
- **Uncached nondeterminism in the shell is a hydration hazard**: a raw
|
|
569
596
|
`Date.now()` / `Math.random()` / uncached `fetch` rendered directly in shell
|
|
570
597
|
material (outside any cache ring) drifts between capture and hit and the
|
|
@@ -586,6 +613,9 @@ cache"` value baked into the shell is PINNED at capture (the capture data
|
|
|
586
613
|
|
|
587
614
|
## Related
|
|
588
615
|
|
|
616
|
+
- `/defer-hydration` — keep the full body HTML in the shell while moving a
|
|
617
|
+
heavy subtree's hydration off the initial main-thread task (gated boundary,
|
|
618
|
+
content-as-fallback)
|
|
589
619
|
- `/document-cache` — whole-response edge caching (no live holes)
|
|
590
620
|
- `/caching` and `/cache-guide` — segment/function caching (axis 1 data)
|
|
591
621
|
- `/shell-manifest` — replayed handles as cache metadata read by live loaders
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -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
|
|