@rangojs/router 0.17.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 +3 -2
- package/dist/types/browser/cookie-name.d.ts +6 -0
- package/dist/types/browser/history-state.d.ts +16 -0
- 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 +98 -14
- 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-snapshot.d.ts +4 -3
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- 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/types.d.ts +207 -43
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
- package/dist/types/prerender/build-shell-capture.d.ts +11 -4
- package/dist/types/router/instrument.d.ts +8 -0
- package/dist/types/router/middleware-types.d.ts +6 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
- package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
- package/dist/types/rsc/full-payload.d.ts +14 -0
- package/dist/types/rsc/handler.d.ts +1 -1
- package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
- package/dist/types/rsc/shell-capture.d.ts +146 -15
- package/dist/types/rsc/shell-serve.d.ts +140 -17
- package/dist/types/rsc/types.d.ts +7 -0
- package/dist/types/server/context.d.ts +109 -15
- package/dist/types/server/cookie-store.d.ts +21 -0
- package/dist/types/server/request-context.d.ts +159 -33
- package/dist/types/ssr/index.d.ts +9 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/testing/flight.entry.d.ts +7 -0
- 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/types/handler-context.d.ts +14 -1
- package/dist/types/urls/pattern-types.d.ts +22 -18
- package/dist/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +46 -9
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +17 -7
- package/skills/cache-guide/SKILL.md +123 -51
- package/skills/caching/SKILL.md +253 -23
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +11 -6
- package/skills/cloudflare/SKILL.md +15 -0
- package/skills/comparison/references/framework-comparison.md +44 -32
- package/skills/defer-hydration/SKILL.md +50 -30
- package/skills/deployment-caching/SKILL.md +5 -2
- package/skills/document-cache/SKILL.md +8 -2
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/handle-and-actions.md +3 -1
- package/skills/hooks/state.md +3 -1
- package/skills/loader/SKILL.md +161 -39
- package/skills/middleware/SKILL.md +16 -9
- package/skills/migrate-nextjs/SKILL.md +67 -36
- package/skills/migrate-react-router/SKILL.md +2 -1
- package/skills/migrate-react-router/component-migration.md +3 -1
- package/skills/migrate-react-router/data-and-actions.md +3 -1
- package/skills/migrate-react-router/route-mapping.md +7 -4
- package/skills/observability/SKILL.md +64 -0
- package/skills/parallel/SKILL.md +13 -8
- package/skills/ppr/SKILL.md +655 -363
- package/skills/prerender/SKILL.md +27 -14
- package/skills/rango/SKILL.md +52 -33
- package/skills/route/SKILL.md +12 -7
- package/skills/router-setup/SKILL.md +4 -2
- package/skills/scripts/SKILL.md +8 -1
- package/skills/server-actions/SKILL.md +11 -0
- package/skills/shell-manifest/SKILL.md +21 -12
- package/skills/testing/SKILL.md +25 -21
- package/skills/testing/cache-prerender.md +15 -14
- package/skills/testing/loader.md +18 -1
- package/skills/testing/serve-shell-request.md +188 -0
- package/skills/theme/SKILL.md +52 -4
- package/skills/typesafety/env-and-bindings.md +10 -2
- package/skills/use-cache/SKILL.md +39 -16
- package/skills/vercel/SKILL.md +14 -2
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/history-state.ts +23 -0
- package/src/browser/rsc-router.tsx +5 -0
- package/src/cache/cache-key-utils.ts +22 -2
- package/src/cache/cache-policy.ts +22 -0
- package/src/cache/cache-runtime.ts +34 -7
- package/src/cache/cache-scope.ts +345 -33
- 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/handle-snapshot.ts +4 -3
- package/src/cache/index.ts +2 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +217 -27
- package/src/cache/tag-invalidation.ts +104 -19
- package/src/cache/types.ts +211 -43
- package/src/cache/vercel/vercel-cache-store.ts +545 -61
- package/src/prerender/build-shell-capture.ts +65 -11
- package/src/route-definition/dsl-helpers.ts +4 -3
- package/src/router/handler-context.ts +38 -4
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +19 -7
- package/src/router/loader-resolution.ts +63 -32
- package/src/router/match-api.ts +28 -9
- package/src/router/match-handlers.ts +5 -0
- package/src/router/match-middleware/cache-lookup.ts +17 -0
- package/src/router/match-middleware/cache-store.ts +31 -11
- package/src/router/middleware-types.ts +6 -1
- package/src/router/middleware.ts +23 -4
- package/src/router/prerender-match.ts +6 -0
- package/src/router/segment-resolution/fresh.ts +18 -9
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +227 -56
- package/src/router/segment-resolution/loader-snapshot.ts +203 -51
- package/src/router/segment-resolution/mask-nested.ts +136 -42
- package/src/router/segment-resolution/revalidation.ts +27 -10
- 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/response-cache-serve.ts +9 -6
- package/src/rsc/rsc-rendering.ts +601 -181
- package/src/rsc/shell-build-manifest.ts +27 -9
- package/src/rsc/shell-capture-constants.ts +60 -6
- package/src/rsc/shell-capture.ts +1001 -250
- package/src/rsc/shell-serve.ts +316 -34
- package/src/rsc/types.ts +7 -0
- package/src/server/context.ts +219 -28
- package/src/server/cookie-store.ts +87 -62
- package/src/server/request-context.ts +263 -38
- package/src/ssr/index.tsx +96 -37
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/testing/flight.entry.ts +14 -0
- package/src/testing/run-loader.ts +63 -5
- package/src/testing/serve-shell-request.ts +378 -0
- package/src/testing/shell-status.ts +34 -12
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/types/handler-context.ts +14 -1
- package/src/urls/pattern-types.ts +22 -18
- package/src/vite/discovery/shell-prerender-phase.ts +9 -0
- package/src/vite/router-discovery.ts +14 -4
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
*
|
|
5
5
|
* A bake-lane loader (lane rule: see resolveLoaderData, loader-cache.ts)
|
|
6
6
|
* executes during shell capture; its settled container bakes into the prelude
|
|
7
|
-
* while nested promises postpone as holes. To keep a HIT's fresh
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* while nested promises postpone as holes. To keep a HIT's fresh payload in
|
|
8
|
+
* agreement with that frozen prelude, the capture pins the container in the
|
|
9
|
+
* shell snapshot. Everything outside a hole is pinned; the one exception is a
|
|
10
|
+
* server-component element holding a promise, which is a hole as a whole (see
|
|
11
|
+
* elideLoaderContainer):
|
|
10
12
|
*
|
|
11
13
|
* - elide: deep-walk the settled container; a PENDING nested promise is a
|
|
12
14
|
* hole, replaced by {@link LOADER_HOLE_KEY} — and since
|
|
@@ -28,13 +30,20 @@
|
|
|
28
30
|
* (they cannot contradict prelude bytes that never rendered them).
|
|
29
31
|
*/
|
|
30
32
|
|
|
33
|
+
import { isValidElement, type ReactElement } from "react";
|
|
31
34
|
import { isThenable } from "../../handles/is-thenable.js";
|
|
35
|
+
import {
|
|
36
|
+
cloneElementWithProps,
|
|
37
|
+
isPlainDataObject,
|
|
38
|
+
maskNestedContainerThenables,
|
|
39
|
+
type MaskReport,
|
|
40
|
+
} from "./mask-nested.js";
|
|
32
41
|
|
|
33
42
|
// Capture-side nested-thenable masking lives in the LEAF module mask-nested.ts
|
|
34
43
|
// (shared with the capture handle-store push wrap in rsc/shell-capture.ts).
|
|
35
44
|
// Re-exported here so loader-cache and the unit tests keep one import site for
|
|
36
45
|
// the snapshot family.
|
|
37
|
-
export { maskNestedContainerThenables }
|
|
46
|
+
export { maskNestedContainerThenables };
|
|
38
47
|
|
|
39
48
|
/**
|
|
40
49
|
* Marker object standing in for a pending nested promise in a recorded loader
|
|
@@ -86,12 +95,6 @@ export function isLoaderSettledMarker(
|
|
|
86
95
|
);
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
90
|
-
if (typeof value !== "object" || value === null) return false;
|
|
91
|
-
const proto = Object.getPrototypeOf(value);
|
|
92
|
-
return proto === Object.prototype || proto === null;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
98
|
/**
|
|
96
99
|
* Probe whether a promise is already settled without waiting for it: races it
|
|
97
100
|
* against an immediately-resolved sentinel across two microtask hops (then
|
|
@@ -129,10 +132,14 @@ export type ElideResult =
|
|
|
129
132
|
* promises pin their value behind a settled marker (they baked, but consumers
|
|
130
133
|
* hold a promise-shaped key); pending ones become hole markers; a REJECTED
|
|
131
134
|
* nested promise poisons the record (error UI must never bake into a shared
|
|
132
|
-
* shell) — the caller refuses the capture.
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
135
|
+
* shell) — the caller refuses the capture. Plain data objects, arrays and the
|
|
136
|
+
* props of host, Suspense/Fragment and client-reference elements are
|
|
137
|
+
* traversed copy-on-write (an element through cloneElementWithProps); a shared
|
|
138
|
+
* subtree or a cycle resolves to the same elided result. An element Flight
|
|
139
|
+
* renders by calling user code (see rendersOnServer) is a leaf, or ONE hole
|
|
140
|
+
* marker when a thenable sits anywhere in its props: the pin encode calls that
|
|
141
|
+
* component, so a marker inside its props would reach it. Anything else
|
|
142
|
+
* (Date, Map, class instance, lazy node) is a pinned leaf.
|
|
136
143
|
*
|
|
137
144
|
* The ROOT container promise-chain unwraps with NO marker: loader-cache
|
|
138
145
|
* overlays against the AWAITED fresh container, so the recorded root must be
|
|
@@ -140,7 +147,7 @@ export type ElideResult =
|
|
|
140
147
|
*/
|
|
141
148
|
export async function elideLoaderContainer(
|
|
142
149
|
value: unknown,
|
|
143
|
-
seen:
|
|
150
|
+
seen: Map<object, ElideResult> = new Map(),
|
|
144
151
|
): Promise<ElideResult> {
|
|
145
152
|
if (isThenable(value)) {
|
|
146
153
|
const probed = await probeSettled(value);
|
|
@@ -155,7 +162,7 @@ export async function elideLoaderContainer(
|
|
|
155
162
|
|
|
156
163
|
async function elideNested(
|
|
157
164
|
value: unknown,
|
|
158
|
-
seen:
|
|
165
|
+
seen: Map<object, ElideResult>,
|
|
159
166
|
): Promise<ElideResult> {
|
|
160
167
|
if (isThenable(value)) {
|
|
161
168
|
const probed = await probeSettled(value);
|
|
@@ -170,44 +177,146 @@ async function elideNested(
|
|
|
170
177
|
: { [LOADER_SETTLED_KEY]: 1, value: inner.value };
|
|
171
178
|
return { state: "ok", value: marker, hasHole: inner.hasHole };
|
|
172
179
|
}
|
|
180
|
+
if (typeof value !== "object" || value === null) {
|
|
181
|
+
return { state: "ok", value, hasHole: false };
|
|
182
|
+
}
|
|
183
|
+
const cached = seen.get(value);
|
|
184
|
+
if (cached) return cached;
|
|
173
185
|
|
|
174
|
-
if (
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
186
|
+
if (isValidElement<Record<string, unknown>>(value)) {
|
|
187
|
+
if (rendersOnServer(value.type)) {
|
|
188
|
+
return elementHoldsThenable(value.props)
|
|
189
|
+
? { state: "ok", value: { [LOADER_HOLE_KEY]: 1 }, hasHole: true }
|
|
190
|
+
: { state: "ok", value, hasHole: false };
|
|
191
|
+
}
|
|
192
|
+
// Cached before the walk: a cycle back here pins the original.
|
|
193
|
+
const result: ElideResult & { state: "ok" } = {
|
|
194
|
+
state: "ok",
|
|
195
|
+
value,
|
|
196
|
+
hasHole: false,
|
|
197
|
+
};
|
|
198
|
+
seen.set(value, result);
|
|
199
|
+
const props = value.props;
|
|
200
|
+
let changed: Record<string, unknown> | undefined;
|
|
201
|
+
for (const key of Object.keys(props)) {
|
|
202
|
+
const r = await elideNested(props[key], seen);
|
|
181
203
|
if (r.state === "rejected") return r;
|
|
182
|
-
|
|
183
|
-
hasHole ||= r.hasHole;
|
|
204
|
+
if (r.value !== props[key]) (changed ??= {})[key] = r.value;
|
|
205
|
+
result.hasHole ||= r.hasHole;
|
|
184
206
|
}
|
|
185
|
-
|
|
207
|
+
if (changed) result.value = cloneElementWithProps(value, changed);
|
|
208
|
+
return result;
|
|
186
209
|
}
|
|
187
210
|
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
211
|
+
if (Array.isArray(value) || isPlainDataObject(value)) {
|
|
212
|
+
const source = value as Record<string, unknown>;
|
|
213
|
+
const out = (Array.isArray(value) ? new Array(value.length) : {}) as Record<
|
|
214
|
+
string,
|
|
215
|
+
unknown
|
|
216
|
+
>;
|
|
217
|
+
// Cached before the walk: a cycle back here pins the copy.
|
|
218
|
+
const result: ElideResult & { state: "ok" } = {
|
|
219
|
+
state: "ok",
|
|
220
|
+
value: out,
|
|
221
|
+
hasHole: false,
|
|
222
|
+
};
|
|
223
|
+
seen.set(value, result);
|
|
224
|
+
let changed = false;
|
|
225
|
+
for (const key of Object.keys(source)) {
|
|
226
|
+
const r = await elideNested(source[key], seen);
|
|
195
227
|
if (r.state === "rejected") return r;
|
|
196
228
|
out[key] = r.value;
|
|
197
|
-
|
|
229
|
+
if (r.value !== source[key]) changed = true;
|
|
230
|
+
result.hasHole ||= r.hasHole;
|
|
198
231
|
}
|
|
199
|
-
|
|
232
|
+
if (!changed) result.value = value;
|
|
233
|
+
return result;
|
|
200
234
|
}
|
|
201
235
|
|
|
202
236
|
return { state: "ok", value, hasHole: false };
|
|
203
237
|
}
|
|
204
238
|
|
|
239
|
+
const CLIENT_REFERENCE = Symbol.for("react.client.reference");
|
|
240
|
+
const TEMPORARY_REFERENCE = Symbol.for("react.temporary.reference");
|
|
241
|
+
const REACT_MEMO = Symbol.for("react.memo");
|
|
242
|
+
const REACT_FORWARD_REF = Symbol.for("react.forward_ref");
|
|
243
|
+
const REACT_LAZY = Symbol.for("react.lazy");
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Whether Flight calls user code to encode an element of this type (its
|
|
247
|
+
* renderElement): a function component, or a memo/forwardRef/lazy wrapper
|
|
248
|
+
* around one. Host (string), Suspense/Fragment (symbol) and client-reference
|
|
249
|
+
* types are encoded with their props as data, so a hole marker in those props
|
|
250
|
+
* reaches no component. A lazy type is resolved the way Flight resolves it:
|
|
251
|
+
* the Flight decode (a cache() hit, a "use cache" result) hands every client
|
|
252
|
+
* component back as a lazy around its client reference, and the capture render
|
|
253
|
+
* already resolved it. One that cannot resolve yet counts as a server
|
|
254
|
+
* component.
|
|
255
|
+
*/
|
|
256
|
+
function rendersOnServer(type: unknown): boolean {
|
|
257
|
+
if (typeof type !== "function" && (typeof type !== "object" || !type)) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
const tag = (type as { $$typeof?: unknown }).$$typeof;
|
|
261
|
+
if (tag === CLIENT_REFERENCE || tag === TEMPORARY_REFERENCE) return false;
|
|
262
|
+
if (typeof type === "function") return true;
|
|
263
|
+
if (tag === REACT_MEMO) {
|
|
264
|
+
return rendersOnServer((type as { type?: unknown }).type);
|
|
265
|
+
}
|
|
266
|
+
if (tag === REACT_LAZY) {
|
|
267
|
+
const resolved = readLazyNode(type);
|
|
268
|
+
return resolved === UNRESOLVED || rendersOnServer(resolved);
|
|
269
|
+
}
|
|
270
|
+
return tag === REACT_FORWARD_REF;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Whether the mask found a thenable in an element's props: the mask's own
|
|
275
|
+
* walk, so elide and the mask share one traversal rule.
|
|
276
|
+
*/
|
|
277
|
+
function elementHoldsThenable(props: unknown): boolean {
|
|
278
|
+
const report: MaskReport = { thenable: false };
|
|
279
|
+
maskNestedContainerThenables(props, undefined, report);
|
|
280
|
+
return report.thenable;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const UNRESOLVED: unique symbol = Symbol("unresolved");
|
|
284
|
+
|
|
285
|
+
function isLazyNode(value: unknown): boolean {
|
|
286
|
+
return (
|
|
287
|
+
typeof value === "object" &&
|
|
288
|
+
value !== null &&
|
|
289
|
+
(value as { $$typeof?: unknown }).$$typeof === REACT_LAZY
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The value behind a lazy node, or UNRESOLVED. Flight moves an element past
|
|
295
|
+
* ~3.2 KB of its row into a row of its own (`$L`), which the decode wraps in a
|
|
296
|
+
* lazy node, so a hole marker inside that element sits behind one. The pinned
|
|
297
|
+
* record is fully decoded when a HIT overlays it, so the read is synchronous;
|
|
298
|
+
* a node that is still pending throws and stays a leaf.
|
|
299
|
+
*/
|
|
300
|
+
function readLazyNode(value: unknown): unknown {
|
|
301
|
+
if (!isLazyNode(value)) return UNRESOLVED;
|
|
302
|
+
const lazy = value as { _payload: unknown; _init: (p: unknown) => unknown };
|
|
303
|
+
try {
|
|
304
|
+
return lazy._init(lazy._payload);
|
|
305
|
+
} catch {
|
|
306
|
+
return UNRESOLVED;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
205
310
|
/**
|
|
206
311
|
* Overlay a recorded (elided) container onto the fresh run's container for a
|
|
207
312
|
* shell HIT. Recorded wins per path; hole markers take the fresh value (the
|
|
208
313
|
* live nested promise); fresh-only object keys pass through. Where the shapes
|
|
209
314
|
* disagree structurally, recorded wins wholesale — it is what the prelude
|
|
210
|
-
* froze, and parity beats freshness inside the shell.
|
|
315
|
+
* froze, and parity beats freshness inside the shell. Copy-on-write: a
|
|
316
|
+
* recorded subtree without markers comes back by identity. A recorded element
|
|
317
|
+
* is rebuilt through cloneElementWithProps only at its marker paths, so its
|
|
318
|
+
* other props stay pinned; props the fresh element adds do not pass through
|
|
319
|
+
* (the prelude rendered the element without them).
|
|
211
320
|
*/
|
|
212
321
|
export function overlayLoaderContainer(
|
|
213
322
|
fresh: unknown,
|
|
@@ -233,27 +342,70 @@ export function overlayLoaderContainer(
|
|
|
233
342
|
|
|
234
343
|
if (Array.isArray(recorded)) {
|
|
235
344
|
const freshArr = Array.isArray(fresh) ? fresh : [];
|
|
236
|
-
|
|
345
|
+
let out: unknown[] | undefined;
|
|
346
|
+
for (let i = 0; i < recorded.length; i++) {
|
|
347
|
+
const next = overlayLoaderContainer(freshArr[i], recorded[i]);
|
|
348
|
+
if (next !== recorded[i]) (out ??= recorded.slice())[i] = next;
|
|
349
|
+
}
|
|
350
|
+
return out ?? recorded;
|
|
237
351
|
}
|
|
238
352
|
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
353
|
+
if (isValidElement<Record<string, unknown>>(recorded)) {
|
|
354
|
+
return overlayElement(fresh, recorded);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const lazyValue = readLazyNode(recorded);
|
|
358
|
+
if (lazyValue !== UNRESOLVED) {
|
|
359
|
+
const next = overlayLoaderContainer(fresh, lazyValue);
|
|
360
|
+
return next === lazyValue ? recorded : next;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (isPlainDataObject(recorded)) {
|
|
364
|
+
// Shape drift (fresh not a plain object): markers fall back to undefined.
|
|
365
|
+
const freshObj = isPlainDataObject(fresh) ? fresh : undefined;
|
|
366
|
+
let out: Record<string, unknown> | undefined;
|
|
249
367
|
for (const key of Object.keys(recorded)) {
|
|
250
|
-
|
|
368
|
+
const next = overlayLoaderContainer(freshObj?.[key], recorded[key]);
|
|
369
|
+
if (next !== recorded[key]) (out ??= { ...recorded })[key] = next;
|
|
251
370
|
}
|
|
252
|
-
|
|
253
|
-
|
|
371
|
+
if (freshObj) {
|
|
372
|
+
for (const key of Object.keys(freshObj)) {
|
|
373
|
+
if (!(key in recorded)) (out ??= { ...recorded })[key] = freshObj[key];
|
|
374
|
+
}
|
|
254
375
|
}
|
|
255
|
-
return out;
|
|
376
|
+
return out ?? recorded;
|
|
256
377
|
}
|
|
257
378
|
|
|
258
379
|
return recorded;
|
|
259
380
|
}
|
|
381
|
+
|
|
382
|
+
function overlayElement(
|
|
383
|
+
fresh: unknown,
|
|
384
|
+
recorded: ReactElement<Record<string, unknown>>,
|
|
385
|
+
): unknown {
|
|
386
|
+
if (isLazyNode(fresh)) {
|
|
387
|
+
// A fresh lazy node is read only when a recorded path needs the fresh
|
|
388
|
+
// value: reading it parses a decoded row or calls a React.lazy factory
|
|
389
|
+
// the HIT render never asks for otherwise.
|
|
390
|
+
if (overlayProps(undefined, recorded) === recorded) return recorded;
|
|
391
|
+
const read = readLazyNode(fresh);
|
|
392
|
+
fresh = read === UNRESOLVED ? undefined : read;
|
|
393
|
+
}
|
|
394
|
+
return overlayProps(
|
|
395
|
+
isValidElement<Record<string, unknown>>(fresh) ? fresh.props : undefined,
|
|
396
|
+
recorded,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function overlayProps(
|
|
401
|
+
freshProps: Record<string, unknown> | undefined,
|
|
402
|
+
recorded: ReactElement<Record<string, unknown>>,
|
|
403
|
+
): unknown {
|
|
404
|
+
const props = recorded.props;
|
|
405
|
+
let changed: Record<string, unknown> | undefined;
|
|
406
|
+
for (const key of Object.keys(props)) {
|
|
407
|
+
const next = overlayLoaderContainer(freshProps?.[key], props[key]);
|
|
408
|
+
if (next !== props[key]) (changed ??= {})[key] = next;
|
|
409
|
+
}
|
|
410
|
+
return changed ? cloneElementWithProps(recorded, changed) : recorded;
|
|
411
|
+
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Capture-side nested
|
|
3
|
-
* shape is the liveness declaration" for
|
|
4
|
-
* (loader-cache.ts) and
|
|
5
|
-
* push wrap in rsc/shell-capture.ts).
|
|
2
|
+
* Capture-side nested thenables. Masking is the mechanism behind "nested-
|
|
3
|
+
* promise shape is the liveness declaration" for the loader lane: bake-lane
|
|
4
|
+
* loader containers (loader-cache.ts) and DSL loader handle pushes (the
|
|
5
|
+
* capture handle store's push wrap in rsc/shell-capture.ts). Handler handle
|
|
6
|
+
* pushes are handler output and bake: the capture waits for their nested
|
|
7
|
+
* promises instead (settleNestedThenables).
|
|
6
8
|
*
|
|
7
9
|
* Deliberately a LEAF module: loader-mask (the other natural home) imports
|
|
8
10
|
* request-context, so any funnel importing the mask from there inherits that
|
|
9
|
-
* graph. This module imports only is-thenable.
|
|
11
|
+
* graph. This module imports only is-thenable and react.
|
|
10
12
|
*/
|
|
11
13
|
|
|
14
|
+
import { cloneElement, isValidElement, type ReactElement } from "react";
|
|
12
15
|
import { isThenable } from "../../handles/is-thenable.js";
|
|
13
16
|
|
|
14
17
|
/**
|
|
@@ -23,18 +26,102 @@ export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
|
|
|
23
26
|
return new Promise<T>(() => {});
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
|
|
29
|
+
/**
|
|
30
|
+
* A data object the container walks descend into: prototype Object.prototype
|
|
31
|
+
* or null, and no symbol `$$typeof`. React elements, lazy nodes and references
|
|
32
|
+
* are plain objects too, told apart by that symbol (Flight switches on it).
|
|
33
|
+
* They are React's, not data: an element's dev-only fields (`_debugStack`,
|
|
34
|
+
* `_debugTask`, `_debugInfo`, `ref`) are non-enumerable, so a key-by-key copy
|
|
35
|
+
* drops them and dev Flight refuses it ("Attempted to render <x> without
|
|
36
|
+
* development properties", issue #942).
|
|
37
|
+
*/
|
|
38
|
+
export function isPlainDataObject(
|
|
39
|
+
value: unknown,
|
|
40
|
+
): value is Record<string, unknown> {
|
|
27
41
|
if (typeof value !== "object" || value === null) return false;
|
|
28
42
|
const proto = Object.getPrototypeOf(value);
|
|
29
|
-
|
|
43
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
44
|
+
return typeof (value as { $$typeof?: unknown }).$$typeof !== "symbol";
|
|
30
45
|
}
|
|
31
46
|
|
|
47
|
+
type KeyValidated = { _store?: { validated?: unknown } };
|
|
48
|
+
|
|
32
49
|
/**
|
|
33
|
-
*
|
|
50
|
+
* cloneElement with only the given props replaced, keeping React's dev key
|
|
51
|
+
* validation. React 19 dev cloneElement resets `_store.validated`, so a
|
|
52
|
+
* keyless child of a static children list (validated at creation) would warn
|
|
53
|
+
* "Each child in a list should have a unique key" once cloned. Pass only the
|
|
54
|
+
* props that change: cloneElement re-parents the element (owner) when its
|
|
55
|
+
* config carries `ref`.
|
|
56
|
+
*/
|
|
57
|
+
export function cloneElementWithProps(
|
|
58
|
+
element: ReactElement,
|
|
59
|
+
props: Record<string, unknown>,
|
|
60
|
+
): ReactElement {
|
|
61
|
+
const clone = cloneElement(element, props);
|
|
62
|
+
const from = (element as KeyValidated)._store;
|
|
63
|
+
const to = (clone as KeyValidated)._store;
|
|
64
|
+
if (from && to) to.validated = from.validated;
|
|
65
|
+
return clone;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolves once `value` and every thenable reachable from it through plain
|
|
70
|
+
* data objects, arrays and React element props has settled, following what
|
|
71
|
+
* each thenable resolves to. A rejection counts as settled: the consumer that
|
|
72
|
+
* encodes the value reports it. Other objects are leaves (the same walk as
|
|
73
|
+
* maskNestedContainerThenables), so a promise inside a Map is not waited for.
|
|
74
|
+
* An element is walked through its props only (`<X data={promise} />` in a
|
|
75
|
+
* handle value is handler output too), never its owner or type, and is never
|
|
76
|
+
* rebuilt: nothing is copied, only awaited.
|
|
77
|
+
*
|
|
78
|
+
* The capture waits on this for HANDLER handle pushes (shell-capture.ts):
|
|
79
|
+
* handler output is baked, so its promises must settle before the doc record
|
|
80
|
+
* encodes the handles every HIT replays.
|
|
81
|
+
*/
|
|
82
|
+
export function settleNestedThenables(value: unknown): Promise<void> {
|
|
83
|
+
const pending: Promise<void>[] = [];
|
|
84
|
+
const seen = new Set<object>();
|
|
85
|
+
const walk = (v: unknown): void => {
|
|
86
|
+
if (isThenable(v)) {
|
|
87
|
+
pending.push(
|
|
88
|
+
Promise.resolve(v).then(settleNestedThenables, () => undefined),
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (isValidElement<Record<string, unknown>>(v)) {
|
|
93
|
+
walk(v.props);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(v) || isPlainDataObject(v)) {
|
|
97
|
+
if (seen.has(v)) return;
|
|
98
|
+
seen.add(v);
|
|
99
|
+
for (const item of Array.isArray(v) ? v : Object.values(v)) walk(item);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
walk(value);
|
|
103
|
+
return pending.length === 0
|
|
104
|
+
? Promise.resolve()
|
|
105
|
+
: Promise.all(pending).then(() => undefined);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Optional single-walk report for maskNestedContainerThenables: `thenable`
|
|
110
|
+
* flips true when the walk masked at least one thenable. The capture push
|
|
111
|
+
* funnel reads it for a loader push: a container with a masked nested
|
|
112
|
+
* promise keeps its loader tag, so the handle encode never waits on a mask.
|
|
113
|
+
* Same single-pass shape as elideLoaderContainer's `hasHole`.
|
|
114
|
+
*/
|
|
115
|
+
export interface MaskReport {
|
|
116
|
+
thenable: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Copy a container with every NESTED thenable replaced by a masked
|
|
34
121
|
* (never-resolving) promise. Applied during shell capture to (a) bake-lane
|
|
35
|
-
* loader containers (loader-cache.ts) and (b)
|
|
36
|
-
* (shell-capture.ts capture store push wrap) — the
|
|
37
|
-
*
|
|
122
|
+
* loader containers (loader-cache.ts) and (b) DSL loader handle pushes
|
|
123
|
+
* (shell-capture.ts capture store push wrap) — the rango-owned funnels where
|
|
124
|
+
* a loader declares per-request data by promise SHAPE.
|
|
38
125
|
*
|
|
39
126
|
* Why: a nested promise that happened to SETTLE before the capture's quiet
|
|
40
127
|
* window closed used to bake its value into the SHARED shell (and, for
|
|
@@ -46,23 +133,16 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
46
133
|
* Masking makes the consuming subtree postpone as a hole no matter when the
|
|
47
134
|
* promise settles: liveness by declaration, not by racing the window.
|
|
48
135
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* declares per-request data, and a shell entry whose HANDLER layer made such
|
|
59
|
-
* a declaration cannot be served handler-free (the hole would never fill).
|
|
60
|
-
* Same single-pass shape as elideLoaderContainer's `hasHole`.
|
|
136
|
+
* Plain data objects, arrays and React element props are traversed: JSX
|
|
137
|
+
* carrying a promise (`<Reviews data={fetchReviews()} />`) declares
|
|
138
|
+
* per-request data the same way a plain container does. Other values, lazy
|
|
139
|
+
* nodes included, are leaves. Copy-on-write: a subtree without a thenable
|
|
140
|
+
* comes back by identity, so an element is rebuilt only when its props hold
|
|
141
|
+
* one, and then through cloneElementWithProps, which keeps its type, key,
|
|
142
|
+
* owner, dev fields and key validation. The INPUT IS NEVER MUTATED — handler-side loader consumption (the
|
|
143
|
+
* consumption-lane rule, semantic-matrix PPR3) shares the raw container and
|
|
144
|
+
* must keep real values. Cycles are preserved as cycles in the copy.
|
|
61
145
|
*/
|
|
62
|
-
export interface MaskReport {
|
|
63
|
-
thenable: boolean;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
146
|
export function maskNestedContainerThenables(
|
|
67
147
|
value: unknown,
|
|
68
148
|
seen: Map<object, unknown> = new Map(),
|
|
@@ -72,27 +152,41 @@ export function maskNestedContainerThenables(
|
|
|
72
152
|
if (report) report.thenable = true;
|
|
73
153
|
return createMaskedLoaderPromise();
|
|
74
154
|
}
|
|
155
|
+
if (typeof value !== "object" || value === null) return value;
|
|
156
|
+
const cached = seen.get(value);
|
|
157
|
+
if (cached !== undefined) return cached;
|
|
75
158
|
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
159
|
+
if (isValidElement<Record<string, unknown>>(value)) {
|
|
160
|
+
// Placeholder: a cycle back to this element through its props keeps the
|
|
161
|
+
// original.
|
|
162
|
+
seen.set(value, value);
|
|
163
|
+
const props = value.props;
|
|
164
|
+
let maskedProps: Record<string, unknown> | undefined;
|
|
165
|
+
for (const key of Object.keys(props)) {
|
|
166
|
+
const masked = maskNestedContainerThenables(props[key], seen, report);
|
|
167
|
+
if (masked !== props[key]) (maskedProps ??= {})[key] = masked;
|
|
83
168
|
}
|
|
84
|
-
return
|
|
169
|
+
if (!maskedProps) return value;
|
|
170
|
+
const clone = cloneElementWithProps(value, maskedProps);
|
|
171
|
+
seen.set(value, clone);
|
|
172
|
+
return clone;
|
|
85
173
|
}
|
|
86
174
|
|
|
87
|
-
if (
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
175
|
+
if (Array.isArray(value) || isPlainDataObject(value)) {
|
|
176
|
+
const source = value as Record<string, unknown>;
|
|
177
|
+
const out = (Array.isArray(value) ? new Array(value.length) : {}) as Record<
|
|
178
|
+
string,
|
|
179
|
+
unknown
|
|
180
|
+
>;
|
|
91
181
|
seen.set(value, out);
|
|
92
|
-
|
|
93
|
-
|
|
182
|
+
let changed = false;
|
|
183
|
+
for (const key of Object.keys(source)) {
|
|
184
|
+
out[key] = maskNestedContainerThenables(source[key], seen, report);
|
|
185
|
+
if (out[key] !== source[key]) changed = true;
|
|
94
186
|
}
|
|
95
|
-
return out;
|
|
187
|
+
if (changed) return out;
|
|
188
|
+
seen.set(value, value);
|
|
189
|
+
return value;
|
|
96
190
|
}
|
|
97
191
|
|
|
98
192
|
return value;
|
|
@@ -45,7 +45,8 @@ import {
|
|
|
45
45
|
} from "./helpers.js";
|
|
46
46
|
import { applyViewTransitionDefault } from "./view-transition-default.js";
|
|
47
47
|
import { getRouterContext } from "../router-context.js";
|
|
48
|
-
import { observeEvent,
|
|
48
|
+
import { observeEvent, observeSegmentHandler } from "../instrument.js";
|
|
49
|
+
import { runInSegmentTagScope } from "../../cache/cache-tag.js";
|
|
49
50
|
import { observeStreamedHandler } from "./streamed-handler-telemetry.js";
|
|
50
51
|
import {
|
|
51
52
|
track,
|
|
@@ -221,7 +222,8 @@ export async function resolveLoadersWithRevalidation<TEnv>(
|
|
|
221
222
|
// whole-container-mask every loader on this path (a never-settling promise
|
|
222
223
|
// for a loader that should have executed).
|
|
223
224
|
// Outside capture the key only activates the _shellLoaderSeed overlay,
|
|
224
|
-
// which
|
|
225
|
+
// which serveShellHit seeds for a document HIT and the PPR partial replay
|
|
226
|
+
// (matchPartialWithPprReplay) seeds once its doc record hits.
|
|
225
227
|
const bakeLane = !entryLoadingMasksLoaders(entry.loading);
|
|
226
228
|
|
|
227
229
|
const loadersToRun = revalidationChecks.filter((c) => c.shouldRun);
|
|
@@ -240,9 +242,10 @@ export async function resolveLoadersWithRevalidation<TEnv>(
|
|
|
240
242
|
loaderEntry,
|
|
241
243
|
ctx,
|
|
242
244
|
ctx.pathname,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
245
|
+
// `bake`, not awaitBeforeFlush: a navigation evaluation never
|
|
246
|
+
// carries awaitBeforeFlush, and an ssr: false loader must pin
|
|
247
|
+
// here exactly as on the document HIT.
|
|
248
|
+
bakeLane || loaderEntry.bake === true ? segmentId : null,
|
|
246
249
|
),
|
|
247
250
|
),
|
|
248
251
|
entry,
|
|
@@ -476,7 +479,9 @@ async function resolveParallelSlotComponent<TEnv>(args: {
|
|
|
476
479
|
handlerRan = true;
|
|
477
480
|
if (hasLoadingFallback) {
|
|
478
481
|
const result =
|
|
479
|
-
typeof handler === "function"
|
|
482
|
+
typeof handler === "function"
|
|
483
|
+
? runInSegmentTagScope(parallelId, handler, context)
|
|
484
|
+
: handler;
|
|
480
485
|
if (result instanceof Promise) {
|
|
481
486
|
warnOnStreamedResponse(result, parallelId);
|
|
482
487
|
const tracked = deps.trackHandler(result, {
|
|
@@ -497,7 +502,9 @@ async function resolveParallelSlotComponent<TEnv>(args: {
|
|
|
497
502
|
}
|
|
498
503
|
} else {
|
|
499
504
|
component =
|
|
500
|
-
typeof handler === "function"
|
|
505
|
+
typeof handler === "function"
|
|
506
|
+
? await runInSegmentTagScope(parallelId, handler, context)
|
|
507
|
+
: handler;
|
|
501
508
|
}
|
|
502
509
|
}
|
|
503
510
|
}
|
|
@@ -842,14 +849,19 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
|
|
|
842
849
|
: routeEntry.handler;
|
|
843
850
|
if (!routeEntry.loading) {
|
|
844
851
|
const result = handleHandlerResult(
|
|
845
|
-
await
|
|
852
|
+
await observeSegmentHandler(
|
|
853
|
+
entry.shortCode,
|
|
854
|
+
entry.id,
|
|
855
|
+
handler,
|
|
856
|
+
context,
|
|
857
|
+
),
|
|
846
858
|
);
|
|
847
859
|
doneHandler();
|
|
848
860
|
return result;
|
|
849
861
|
}
|
|
850
862
|
if (!actionContext) {
|
|
851
863
|
const result = handleHandlerResult(
|
|
852
|
-
|
|
864
|
+
observeSegmentHandler(entry.shortCode, entry.id, handler, context),
|
|
853
865
|
);
|
|
854
866
|
if (result instanceof Promise) {
|
|
855
867
|
warnOnStreamedResponse(result, routeEntry.id);
|
|
@@ -875,7 +887,12 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
|
|
|
875
887
|
entryId: entry.id,
|
|
876
888
|
});
|
|
877
889
|
const actionResult = handleHandlerResult(
|
|
878
|
-
await
|
|
890
|
+
await observeSegmentHandler(
|
|
891
|
+
entry.shortCode,
|
|
892
|
+
entry.id,
|
|
893
|
+
handler,
|
|
894
|
+
context,
|
|
895
|
+
),
|
|
879
896
|
);
|
|
880
897
|
doneHandler();
|
|
881
898
|
return {
|