@rangojs/router 0.26.0 → 0.26.2
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/bin/rango.js +1 -1
- package/dist/types/cache/shell-snapshot.d.ts +6 -4
- package/dist/types/router/dev-reload-handoff.d.ts +26 -0
- package/dist/types/router/loader-resolution.d.ts +4 -1
- package/dist/types/router/types.d.ts +3 -1
- package/dist/types/rsc/shell-capture-constants.d.ts +6 -3
- package/dist/types/rsc/shell-capture.d.ts +9 -6
- package/dist/types/urls/pattern-types.d.ts +7 -6
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/loader/SKILL.md +9 -0
- package/skills/ppr/SKILL.md +30 -22
- package/skills/router-setup/SKILL.md +1 -1
- package/src/cache/shell-snapshot.ts +7 -3
- package/src/router/dev-reload-handoff.ts +73 -0
- package/src/router/loader-resolution.ts +12 -2
- package/src/router/segment-resolution/fresh.ts +2 -0
- package/src/router/types.ts +2 -0
- package/src/router.ts +18 -0
- package/src/rsc/rsc-rendering.ts +44 -12
- package/src/rsc/shell-capture-constants.ts +6 -3
- package/src/rsc/shell-capture.ts +37 -37
- package/src/urls/pattern-types.ts +7 -6
package/dist/bin/rango.js
CHANGED
|
@@ -1644,7 +1644,7 @@ var init_package = __esm({
|
|
|
1644
1644
|
"package.json"() {
|
|
1645
1645
|
package_default = {
|
|
1646
1646
|
name: "@rangojs/router",
|
|
1647
|
-
version: "0.26.
|
|
1647
|
+
version: "0.26.2",
|
|
1648
1648
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
1649
1649
|
keywords: [
|
|
1650
1650
|
"react",
|
|
@@ -199,11 +199,13 @@ export interface ShellLoaderSeedEntry {
|
|
|
199
199
|
* by loader. A key that does not parse pins nothing.
|
|
200
200
|
*
|
|
201
201
|
* Deserializations run in parallel; a record that fails to decode is skipped
|
|
202
|
-
*
|
|
203
|
-
* HIT
|
|
204
|
-
*
|
|
202
|
+
* and reported to `onUndecodable`: its loader runs instead, which a document
|
|
203
|
+
* HIT has already committed a prelude against, so serveShellHit retires the
|
|
204
|
+
* entry (dc-t89). Returns undefined when the snapshot carries no loader
|
|
205
|
+
* records, without touching the Flight codec (kept lazy for cold paths and
|
|
206
|
+
* non-RSC configs).
|
|
205
207
|
*/
|
|
206
|
-
export declare function buildShellLoaderSeed(snapshot: ShellSnapshotRecord[]): Promise<Map<string, ShellLoaderSeedEntry> | undefined>;
|
|
208
|
+
export declare function buildShellLoaderSeed(snapshot: ShellSnapshotRecord[], onUndecodable?: (key: string) => void): Promise<Map<string, ShellLoaderSeedEntry> | undefined>;
|
|
207
209
|
/** The key of a snapshot's one `handles` record (ShellSnapshotHandlesValue). */
|
|
208
210
|
export declare const SHELL_HANDLES_RECORD_KEY = "handles";
|
|
209
211
|
/**
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RangoInternal } from "./router-interfaces.js";
|
|
2
|
+
/** The `import.meta.hot` subset this reads. */
|
|
3
|
+
interface RunnerReloadEvents {
|
|
4
|
+
on(event: "vite:beforeFullReload", listener: () => void): void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Mark this module copy dropped on the runner's next full reload.
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export declare function watchRunnerReloads(events: RunnerReloadEvents | undefined): void;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a runner full reload dropped this module copy.
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export declare function droppedByRunnerReload(): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The router the reload re-created for `routerId`, or undefined when none
|
|
18
|
+
* registered within DEV_RECREATED_ROUTER_WAIT_MS. Importing `sourceFile`
|
|
19
|
+
* evaluates it when nothing else is. While the reload's re-import is still
|
|
20
|
+
* evaluating it, the runner returns the unfinished module (it is in this
|
|
21
|
+
* copy's import call stack, `cachedRequest` in vite's module-runner), so this
|
|
22
|
+
* waits for the fresh registry to receive the router.
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
export declare function routerAfterRunnerReload(routerId: string, sourceFile: string | undefined): Promise<RangoInternal<any, any> | undefined>;
|
|
26
|
+
export {};
|
|
@@ -42,13 +42,16 @@ export type LoaderErrorCallback = (error: unknown, context: {
|
|
|
42
42
|
* This has a simplified signature for internal use - the caller (typically
|
|
43
43
|
* wrapLoaderPromise in router.ts) is responsible for bridging to the full
|
|
44
44
|
* OnErrorCallback with complete request context (request, url, env, etc.).
|
|
45
|
+
* @param useDefaultErrorBoundary - An `ssr: false` loader on a document or
|
|
46
|
+
* capture render: an error with no declared boundary gets
|
|
47
|
+
* `DefaultErrorFallback` and a 500 (it would otherwise reject the tree).
|
|
45
48
|
*/
|
|
46
49
|
export declare function wrapLoaderWithErrorHandling<T>(promise: Promise<T>, entry: EntryData, segmentId: string, pathname: string, findNearestErrorBoundary: (entry: EntryData | null) => ReactNode | ErrorBoundaryHandler | null, createErrorInfo: (error: unknown, segmentId: string, segmentType: ErrorInfo["segmentType"]) => ErrorInfo, onError?: LoaderErrorCallback, notFoundDeps?: {
|
|
47
50
|
findNearestNotFoundBoundary: (entry: EntryData | null) => ReactNode | NotFoundBoundaryHandler | null;
|
|
48
51
|
notFoundComponent?: ReactNode | ((props: {
|
|
49
52
|
pathname: string;
|
|
50
53
|
}) => ReactNode);
|
|
51
|
-
}): Promise<LoaderDataResult<T>>;
|
|
54
|
+
}, useDefaultErrorBoundary?: boolean): Promise<LoaderDataResult<T>>;
|
|
52
55
|
/**
|
|
53
56
|
* Set up the use() method on handler context to access loaders and handles.
|
|
54
57
|
*
|
|
@@ -25,7 +25,9 @@ export interface SegmentResolutionDeps<TEnv = any> {
|
|
|
25
25
|
env?: TEnv;
|
|
26
26
|
isPartial?: boolean;
|
|
27
27
|
requestStartTime?: number;
|
|
28
|
-
}
|
|
28
|
+
},
|
|
29
|
+
/** An `ssr: false` loader on a document or capture render. */
|
|
30
|
+
useDefaultErrorBoundary?: boolean) => Promise<LoaderDataResult<T>>;
|
|
29
31
|
trackHandler: <T>(promise: Promise<T>, errorContext?: {
|
|
30
32
|
segmentId?: string;
|
|
31
33
|
segmentType?: string;
|
|
@@ -153,9 +153,12 @@ export declare const SHELL_CAPTURE_TASK_HARD_CAP_MS = 25000;
|
|
|
153
153
|
* `no-record` (no doc record: the route's cache() refused the write, or the
|
|
154
154
|
* handler output failed), `handles` (a pushed
|
|
155
155
|
* handle value did not finish encoding), `size` (over the store's value
|
|
156
|
-
* limit
|
|
157
|
-
*
|
|
158
|
-
*
|
|
156
|
+
* limit, or `ssr: false` loader pins over `ppr.maxSnapshotBytes`: a
|
|
157
|
+
* navigation-only entry was stored in place of the document shell),
|
|
158
|
+
* `record-expired` (the route cache() record
|
|
159
|
+
* the capture wrote ran out first), `invalidated` (a tag of the shell was
|
|
160
|
+
* invalidated after the capture started), `uncacheable` (the store cannot
|
|
161
|
+
* hold the entry).
|
|
159
162
|
*/
|
|
160
163
|
export type ShellCaptureRefusal = "identity" | "dynamic" | "loader" | "no-record" | "handles" | "size" | "record-expired" | "invalidated" | "uncacheable";
|
|
161
164
|
/**
|
|
@@ -105,9 +105,8 @@ export declare const DEFAULT_SHELL_ENTRY_MAX_BYTES: number;
|
|
|
105
105
|
* while still fitting any sane pinned-ring payload. Applied ONLY in
|
|
106
106
|
* captureAndStoreShell (the single defaulting site — resolvePprConfig passes
|
|
107
107
|
* the option through undefaulted), so every producer and direct caller gets
|
|
108
|
-
* the same policy. The doc record is exempt; over the cap the
|
|
109
|
-
*
|
|
110
|
-
* live store — see PartialPrerenderProps.maxSnapshotBytes).
|
|
108
|
+
* the same policy. The doc record is exempt; over the cap only the
|
|
109
|
+
* navigation half is stored (see PartialPrerenderProps.maxSnapshotBytes).
|
|
111
110
|
*/
|
|
112
111
|
export declare const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number;
|
|
113
112
|
/**
|
|
@@ -165,7 +164,11 @@ export interface ShellCaptureDebugEvent {
|
|
|
165
164
|
preludeBytes?: number;
|
|
166
165
|
/** Serialized snapshot size in UTF-8 bytes. Absent when nothing was recorded. */
|
|
167
166
|
snapshotBytes?: number;
|
|
168
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* True when the loader pins exceeded maxSnapshotBytes: no document shell
|
|
169
|
+
* was stored, only a navigation-only entry (the doc record, or a tombstone
|
|
170
|
+
* for a prerender-served capture). The outcome is `refused`, refusal `size`.
|
|
171
|
+
*/
|
|
169
172
|
snapshotSkipped?: boolean;
|
|
170
173
|
/** A bake-lane loader settled into a shell that uses TTL/SWR-only invalidation. */
|
|
171
174
|
untaggedBake?: true;
|
|
@@ -315,8 +318,8 @@ export interface ShellCaptureDescriptor {
|
|
|
315
318
|
captureTimeout?: number;
|
|
316
319
|
store?: SegmentCacheStore<any>;
|
|
317
320
|
/**
|
|
318
|
-
* Cap (serialized UTF-8 bytes) on the entry's
|
|
319
|
-
*
|
|
321
|
+
* Cap (serialized UTF-8 bytes) on the entry's loader pins; over it only the
|
|
322
|
+
* navigation half is stored and the attempt refuses (reported once per
|
|
320
323
|
* key). Absent = DEFAULT_PPR_MAX_SNAPSHOT_BYTES, applied in
|
|
321
324
|
* captureAndStoreShell — the single defaulting site.
|
|
322
325
|
*/
|
|
@@ -60,12 +60,13 @@ export interface PartialPrerenderProps {
|
|
|
60
60
|
/**
|
|
61
61
|
* Upper bound (serialized UTF-8 bytes) on the loader pins in the capture
|
|
62
62
|
* data snapshot: the bake-lane loader records. The doc record (the handler
|
|
63
|
-
* layer every HIT replays) is exempt.
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* Defaults to 8 MiB. The whole
|
|
68
|
-
*
|
|
63
|
+
* layer every HIT replays) is exempt. A document HIT is served only from an
|
|
64
|
+
* entry that holds a pin for every `ssr: false` loader its HTML baked, so
|
|
65
|
+
* over the cap no document shell is stored: documents render in full
|
|
66
|
+
* (MISS), and client navigations still replay the handler layer and run
|
|
67
|
+
* those loaders fresh. Reported once per key. Defaults to 8 MiB. The whole
|
|
68
|
+
* entry is bounded separately by the store's value limit (Cloudflare KV:
|
|
69
|
+
* 25 MiB); an entry over it is refused.
|
|
69
70
|
*/
|
|
70
71
|
maxSnapshotBytes?: number;
|
|
71
72
|
/**
|
package/dist/vite/index.js
CHANGED
|
@@ -7077,7 +7077,7 @@ function resolveVersionsFrom(table, routerId2) {
|
|
|
7077
7077
|
// package.json
|
|
7078
7078
|
var package_default = {
|
|
7079
7079
|
name: "@rangojs/router",
|
|
7080
|
-
version: "0.26.
|
|
7080
|
+
version: "0.26.2",
|
|
7081
7081
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
7082
7082
|
keywords: [
|
|
7083
7083
|
"react",
|
package/package.json
CHANGED
package/skills/loader/SKILL.md
CHANGED
|
@@ -995,6 +995,15 @@ runs, so a `useLoader` in a layout above every Suspense boundary is safe. On a
|
|
|
995
995
|
during the shell capture refuses the capture (warned once per key): that URL
|
|
996
996
|
gets no shell, and its renders deliver the signal as above.
|
|
997
997
|
|
|
998
|
+
A flagged loader that throws (anything but `notFound()` or `redirect()`) is
|
|
999
|
+
resolved the same way: its route's `errorBoundary()` renders at the owning
|
|
1000
|
+
segment, else `createRouter({ defaultErrorBoundary })`, else the router's
|
|
1001
|
+
default error boundary (the "Internal Server Error" page) with a 500, as a
|
|
1002
|
+
handler that throws does; a declared `errorBoundary()` or
|
|
1003
|
+
`defaultErrorBoundary` answers 200 here. Layouts above it stay. On a `ppr` shell HIT whose
|
|
1004
|
+
run throws, the baked content shows first and the boundary replaces it at
|
|
1005
|
+
hydration, after React reports a hydration mismatch.
|
|
1006
|
+
|
|
998
1007
|
Session/auth gates belong in middleware (they are request-shaped, not
|
|
999
1008
|
data-shaped, and middleware CAN emit a real pre-stream 302). Data-dependent
|
|
1000
1009
|
"this slug moved / does not exist" belongs in the loader.
|
package/skills/ppr/SKILL.md
CHANGED
|
@@ -372,7 +372,8 @@ replay its eligible snapshot. In both cases `matchPartial()`:
|
|
|
372
372
|
- runs live DSL loaders fresh with their normal `loading()` streaming
|
|
373
373
|
behavior, and serves bake-lane loaders from the pins of a shell captured by a
|
|
374
374
|
document request; a snapshot captured by a navigation alone carries no pins,
|
|
375
|
-
|
|
375
|
+
nor does the navigation-only entry a capture over `ppr.maxSnapshotBytes`
|
|
376
|
+
stores, and their replay runs them fresh (see below);
|
|
376
377
|
- keeps the existing prefetch key, source scope, and in-flight lock unchanged.
|
|
377
378
|
|
|
378
379
|
This is deliberately invisible to the browser: the response is the same
|
|
@@ -382,8 +383,9 @@ snapshot is a shell a document request captured, as on a document HIT: a
|
|
|
382
383
|
promise-free `ssr: false` loader is served from its pin without running
|
|
383
384
|
(unless the capture marked it to run: see "On a shell HIT"), and one whose
|
|
384
385
|
return holds promises runs with its baked parts overlaid. A snapshot captured
|
|
385
|
-
by a navigation alone carries no pins,
|
|
386
|
-
|
|
386
|
+
by a navigation alone carries no pins, nor does an entry over
|
|
387
|
+
`ppr.maxSnapshotBytes`, and its replay runs them fresh. Loaders without
|
|
388
|
+
`ssr: false` stay live.
|
|
387
389
|
|
|
388
390
|
A route's own `cache()` scope — including one inherited from an ancestor, the
|
|
389
391
|
common app-wide storefront shape — COMPOSES with replay instead of disabling
|
|
@@ -924,7 +926,10 @@ by a navigation alone carries no pins, and its replay runs these loaders fresh.
|
|
|
924
926
|
fill the holes — and the recorded baked paths are overlaid onto the fresh
|
|
925
927
|
result, so the payload matches the prelude while the nested promises stream
|
|
926
928
|
fresh. A fresh rejection skips the overlay and goes to the loader's error
|
|
927
|
-
boundary
|
|
929
|
+
boundary (the route's `errorBoundary()`, else the router's default one): on
|
|
930
|
+
a document HIT the payload then carries the error where the HTML has the
|
|
931
|
+
baked value, and React renders that part again on the client (one
|
|
932
|
+
hydration mismatch, the one known residual of a HIT). Give it its own cache,
|
|
928
933
|
`loader(Def, { ssr: false }, () => [cache({ ttl })])`, so that run reads
|
|
929
934
|
through the loader cache.
|
|
930
935
|
|
|
@@ -985,9 +990,12 @@ loader running on the replay awaits it. On a document HIT the page hydrates
|
|
|
985
990
|
with the shell's copy and the hole's value takes its place after hydration
|
|
986
991
|
("Handles on a shell HIT" below). An
|
|
987
992
|
`ssr: false` loader is served from the shell only while the entry holds its
|
|
988
|
-
pin: a snapshot captured by a navigation alone carries none, and a
|
|
989
|
-
`ppr.maxSnapshotBytes`
|
|
990
|
-
pushes are that run's, like
|
|
993
|
+
pin: a snapshot captured by a navigation alone carries none, and a capture
|
|
994
|
+
over `ppr.maxSnapshotBytes` stores the navigation half only, without them.
|
|
995
|
+
There a navigation runs the loader fresh, and its pushes are that run's, like
|
|
996
|
+
its data. A document HIT is served only from an entry that holds a pin for
|
|
997
|
+
every `ssr: false` loader its HTML baked, so a document never hydrates one
|
|
998
|
+
loader's run over HTML rendered from the capture's value.
|
|
991
999
|
|
|
992
1000
|
A `Prerender` + `ppr` page follows the same table. Its handler layer comes
|
|
993
1001
|
from the prerendered payload, which never holds a loader push (loaders do not
|
|
@@ -997,8 +1005,9 @@ on its own, and a HIT restores them as above (#1057).
|
|
|
997
1005
|
A dependency the route does not register, which an `ssr: false` loader
|
|
998
1006
|
awaited at capture outside any live loader, is on neither lane: its settled
|
|
999
1007
|
pushes stay as the prelude rendered them, even where a live loader also
|
|
1000
|
-
awaits it, while its data is fresh. (They follow the run instead
|
|
1001
|
-
entry has
|
|
1008
|
+
awaits it, while its data is fresh. (They follow the run instead when the
|
|
1009
|
+
replayed entry has no pin for the loader that awaits it, because that loader
|
|
1010
|
+
then runs.) To keep
|
|
1002
1011
|
its pushes live, declare it as its own `loader()` on the route. A push the
|
|
1003
1012
|
capture cannot record (one whose value, once settled, holds a promise) marks
|
|
1004
1013
|
every loader record of the page to run: those bodies then still run on each
|
|
@@ -1035,12 +1044,11 @@ Four things follow, in this order of scope:
|
|
|
1035
1044
|
inside the `loading()` or `<Suspense>` of a loader that pushes after an
|
|
1036
1045
|
`await` hydrates clean.
|
|
1037
1046
|
|
|
1038
|
-
| On the HIT's request | In the HIT's HTML | The client
|
|
1039
|
-
| ------------------------------------------------------------------------------------------ | ---------------------- |
|
|
1040
|
-
| a handler's push, a pinned `ssr: false` loader's push (`push(promise)` and `.defer()` too) | yes (the shell has it) | hydrates with it
|
|
1041
|
-
| a push by an `ssr: false` loader whose value holds a promise | no | gets it right after hydration
|
|
1042
|
-
| a live-lane loader's push | no | gets it right after hydration
|
|
1043
|
-
| the run of an `ssr: false` loader the shell has no pin for | the capture's push | hydrates with the capture's, then shows the run's |
|
|
1047
|
+
| On the HIT's request | In the HIT's HTML | The client |
|
|
1048
|
+
| ------------------------------------------------------------------------------------------ | ---------------------- | ----------------------------- |
|
|
1049
|
+
| a handler's push, a pinned `ssr: false` loader's push (`push(promise)` and `.defer()` too) | yes (the shell has it) | hydrates with it |
|
|
1050
|
+
| a push by an `ssr: false` loader whose value holds a promise | no | gets it right after hydration |
|
|
1051
|
+
| a live-lane loader's push | no | gets it right after hydration |
|
|
1044
1052
|
|
|
1045
1053
|
So a title an `ssr: false` loader pushes to `Meta`, promise or not, is in
|
|
1046
1054
|
every HIT's `<head>`: a crawler and a link preview see it. It bakes with the
|
|
@@ -1408,13 +1416,13 @@ path(
|
|
|
1408
1416
|
);
|
|
1409
1417
|
```
|
|
1410
1418
|
|
|
1411
|
-
| Field | Default | Notes
|
|
1412
|
-
| ------------------ | ------- |
|
|
1413
|
-
| `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default)
|
|
1414
|
-
| `swr` | — | stale window: serve the stale shell + background recapture
|
|
1415
|
-
| `tags` | — | operational tags UNIONED with the tags the capture collects — see "Invalidation" below
|
|
1416
|
-
| `maxSnapshotBytes` | 8 MiB | cap on the loader pins in the entry's snapshot (the bake-lane loader records). The recorded handler layer is exempt.
|
|
1417
|
-
| `captureTimeout` | 15000ms | ONE capture deadline: the handler layer settling (promises it passes or pushes, async server components, loaders it awaits), bake-lane loaders, and the prerender; a capture that misses it stores nothing rather than a partial shell
|
|
1419
|
+
| Field | Default | Notes |
|
|
1420
|
+
| ------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1421
|
+
| `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
|
|
1422
|
+
| `swr` | — | stale window: serve the stale shell + background recapture |
|
|
1423
|
+
| `tags` | — | operational tags UNIONED with the tags the capture collects — see "Invalidation" below |
|
|
1424
|
+
| `maxSnapshotBytes` | 8 MiB | cap on the loader pins in the entry's snapshot (the bake-lane loader records). The recorded handler layer is exempt. A document HIT is served only from an entry that holds a pin for every `ssr: false` loader its HTML baked, so over the cap no document shell is stored: the entry keeps the handler layer only, documents are a MISS (full SSR, no hydration error, no remount), client navigations still replay the handler layer and run the bake-lane loaders fresh, and the URL backs off like a refused capture. Warned once per key: "the document shell was not stored; raise maxSnapshotBytes or shrink the ssr: false data" |
|
|
1425
|
+
| `captureTimeout` | 15000ms | ONE capture deadline: the handler layer settling (promises it passes or pushes, async server components, loaders it awaits), bake-lane loaders, and the prerender; a capture that misses it stores nothing rather than a partial shell |
|
|
1418
1426
|
|
|
1419
1427
|
A shell never outlives the route `cache()` entry it was captured from: under
|
|
1420
1428
|
a route `cache()`, the shell is fresh no longer than that entry is (at most
|
|
@@ -130,7 +130,7 @@ interface RangoOptions<TEnv> {
|
|
|
130
130
|
id?: string; // Router id; namespaces build output and route maps. Auto-generated.
|
|
131
131
|
|
|
132
132
|
// --- Fallbacks and errors ---
|
|
133
|
-
defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler; // When no errorBoundary() matches
|
|
133
|
+
defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler; // When no errorBoundary() matches; unset: the built-in "Internal Server Error" page
|
|
134
134
|
defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler; // When no notFoundBoundary() matches
|
|
135
135
|
notFound?: ReactNode | ((props: { pathname: string }) => ReactNode); // 404 page
|
|
136
136
|
onError?: OnErrorCallback<TEnv>; // Logging/reporting hook; cannot change the response
|
|
@@ -405,12 +405,15 @@ const PIN_KEY_PREFIX = /^[^D.]*D\d+\./;
|
|
|
405
405
|
* by loader. A key that does not parse pins nothing.
|
|
406
406
|
*
|
|
407
407
|
* Deserializations run in parallel; a record that fails to decode is skipped
|
|
408
|
-
*
|
|
409
|
-
* HIT
|
|
410
|
-
*
|
|
408
|
+
* and reported to `onUndecodable`: its loader runs instead, which a document
|
|
409
|
+
* HIT has already committed a prelude against, so serveShellHit retires the
|
|
410
|
+
* entry (dc-t89). Returns undefined when the snapshot carries no loader
|
|
411
|
+
* records, without touching the Flight codec (kept lazy for cold paths and
|
|
412
|
+
* non-RSC configs).
|
|
411
413
|
*/
|
|
412
414
|
export async function buildShellLoaderSeed(
|
|
413
415
|
snapshot: ShellSnapshotRecord[],
|
|
416
|
+
onUndecodable?: (key: string) => void,
|
|
414
417
|
): Promise<Map<string, ShellLoaderSeedEntry> | undefined> {
|
|
415
418
|
const loaderRecords: ShellSnapshotRecord[] = [];
|
|
416
419
|
for (const rec of snapshot) {
|
|
@@ -441,6 +444,7 @@ export async function buildShellLoaderSeed(
|
|
|
441
444
|
},
|
|
442
445
|
];
|
|
443
446
|
} catch {
|
|
447
|
+
onUndecodable?.(rec.key);
|
|
444
448
|
return null;
|
|
445
449
|
}
|
|
446
450
|
},
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { RangoInternal } from "./router-interfaces.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dev only (#1186). A runner full reload clears its evaluated modules while a
|
|
5
|
+
* request may still be loading them, splitting it across two router copies
|
|
6
|
+
* whose registries disagree. A router of a dropped copy hands its requests to
|
|
7
|
+
* the router the reload re-created. docs/manifests.md, "A request the reload
|
|
8
|
+
* split".
|
|
9
|
+
*/
|
|
10
|
+
let runnerReloaded = false;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Bound on the wait for the re-created router. cloudflare-basic's entry took
|
|
14
|
+
* about 5 s to re-evaluate after the optimizer's `invalidateAll`.
|
|
15
|
+
*/
|
|
16
|
+
const DEV_RECREATED_ROUTER_WAIT_MS = 10_000;
|
|
17
|
+
const DEV_RECREATED_ROUTER_POLL_MS = 20;
|
|
18
|
+
|
|
19
|
+
/** The `import.meta.hot` subset this reads. */
|
|
20
|
+
interface RunnerReloadEvents {
|
|
21
|
+
on(event: "vite:beforeFullReload", listener: () => void): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mark this module copy dropped on the runner's next full reload.
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export function watchRunnerReloads(
|
|
29
|
+
events: RunnerReloadEvents | undefined,
|
|
30
|
+
): void {
|
|
31
|
+
events?.on("vite:beforeFullReload", () => {
|
|
32
|
+
runnerReloaded = true;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (process.env.NODE_ENV !== "production") {
|
|
37
|
+
watchRunnerReloads(import.meta.hot);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a runner full reload dropped this module copy.
|
|
42
|
+
* @internal
|
|
43
|
+
*/
|
|
44
|
+
export function droppedByRunnerReload(): boolean {
|
|
45
|
+
return runnerReloaded;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The router the reload re-created for `routerId`, or undefined when none
|
|
50
|
+
* registered within DEV_RECREATED_ROUTER_WAIT_MS. Importing `sourceFile`
|
|
51
|
+
* evaluates it when nothing else is. While the reload's re-import is still
|
|
52
|
+
* evaluating it, the runner returns the unfinished module (it is in this
|
|
53
|
+
* copy's import call stack, `cachedRequest` in vite's module-runner), so this
|
|
54
|
+
* waits for the fresh registry to receive the router.
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
export async function routerAfterRunnerReload(
|
|
58
|
+
routerId: string,
|
|
59
|
+
sourceFile: string | undefined,
|
|
60
|
+
): Promise<RangoInternal<any, any> | undefined> {
|
|
61
|
+
if (!sourceFile) return undefined;
|
|
62
|
+
await import(/* @vite-ignore */ sourceFile);
|
|
63
|
+
const { RouterRegistry } = await import("./router-registry.js");
|
|
64
|
+
const deadline = Date.now() + DEV_RECREATED_ROUTER_WAIT_MS;
|
|
65
|
+
let router = RouterRegistry.get(routerId);
|
|
66
|
+
while (!router && Date.now() < deadline) {
|
|
67
|
+
await new Promise((resolve) =>
|
|
68
|
+
setTimeout(resolve, DEV_RECREATED_ROUTER_POLL_MS),
|
|
69
|
+
);
|
|
70
|
+
router = RouterRegistry.get(routerId);
|
|
71
|
+
}
|
|
72
|
+
return router;
|
|
73
|
+
}
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from "../server/handle-store.js";
|
|
56
56
|
import { getFetchableLoader } from "../server/fetchable-loader-store.js";
|
|
57
57
|
import { _getRequestContext } from "../server/request-context.js";
|
|
58
|
+
import { DefaultErrorFallback } from "../default-error-boundary.js";
|
|
58
59
|
import { defineRewrittenFrom } from "../server/rewrite-trail.js";
|
|
59
60
|
import {
|
|
60
61
|
firstUnitCandidate,
|
|
@@ -154,6 +155,9 @@ function pushHandleValue(
|
|
|
154
155
|
* This has a simplified signature for internal use - the caller (typically
|
|
155
156
|
* wrapLoaderPromise in router.ts) is responsible for bridging to the full
|
|
156
157
|
* OnErrorCallback with complete request context (request, url, env, etc.).
|
|
158
|
+
* @param useDefaultErrorBoundary - An `ssr: false` loader on a document or
|
|
159
|
+
* capture render: an error with no declared boundary gets
|
|
160
|
+
* `DefaultErrorFallback` and a 500 (it would otherwise reject the tree).
|
|
157
161
|
*/
|
|
158
162
|
export function wrapLoaderWithErrorHandling<T>(
|
|
159
163
|
promise: Promise<T>,
|
|
@@ -177,13 +181,15 @@ export function wrapLoaderWithErrorHandling<T>(
|
|
|
177
181
|
| ReactNode
|
|
178
182
|
| ((props: { pathname: string }) => ReactNode);
|
|
179
183
|
},
|
|
184
|
+
useDefaultErrorBoundary?: boolean,
|
|
180
185
|
): Promise<LoaderDataResult<T>> {
|
|
181
186
|
// Extract the trailing token from segmentId (format: "<shortCode>D<i>.<loaderId>").
|
|
182
187
|
// The token is the loader's $$id (hash#export in prod, pathfrag#export in dev),
|
|
183
188
|
// not a clean display name.
|
|
184
189
|
const loaderName = segmentId.split(".").pop() || "unknown";
|
|
185
190
|
// Captured now: the rejection can settle outside the request's ALS frame.
|
|
186
|
-
const
|
|
191
|
+
const requestContext = _getRequestContext();
|
|
192
|
+
const renderErrors = requestContext?._renderErrors;
|
|
187
193
|
|
|
188
194
|
return Promise.resolve(promise)
|
|
189
195
|
.then(
|
|
@@ -286,7 +292,9 @@ export function wrapLoaderWithErrorHandling<T>(
|
|
|
286
292
|
renderErrors?.push(error);
|
|
287
293
|
|
|
288
294
|
// Find nearest error boundary
|
|
289
|
-
const
|
|
295
|
+
const declared = findNearestErrorBoundary(entry);
|
|
296
|
+
const fallback =
|
|
297
|
+
declared ?? (useDefaultErrorBoundary ? DefaultErrorFallback : null);
|
|
290
298
|
|
|
291
299
|
// Create error info
|
|
292
300
|
const errorInfo = createErrorInfo(error, segmentId, "loader");
|
|
@@ -341,6 +349,8 @@ export function wrapLoaderWithErrorHandling<T>(
|
|
|
341
349
|
segmentId,
|
|
342
350
|
message: errorInfo.message,
|
|
343
351
|
});
|
|
352
|
+
// The default boundary answers 500, as in catchSegmentError.
|
|
353
|
+
if (!declared) requestContext?._setStatus(500);
|
|
344
354
|
|
|
345
355
|
return {
|
|
346
356
|
__loaderResult: true,
|
|
@@ -184,6 +184,7 @@ export async function resolveLoaders<TEnv>(
|
|
|
184
184
|
segmentId,
|
|
185
185
|
ctx.pathname,
|
|
186
186
|
errorContext,
|
|
187
|
+
loaderEntry.awaitBeforeFlush,
|
|
187
188
|
),
|
|
188
189
|
belongsToRoute,
|
|
189
190
|
};
|
|
@@ -235,6 +236,7 @@ export async function resolveLoaders<TEnv>(
|
|
|
235
236
|
segmentId,
|
|
236
237
|
ctx.pathname,
|
|
237
238
|
errorContext,
|
|
239
|
+
loaderEntry.awaitBeforeFlush,
|
|
238
240
|
);
|
|
239
241
|
return { wrapped, segmentId };
|
|
240
242
|
});
|
package/src/router/types.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface SegmentResolutionDeps<TEnv = any> {
|
|
|
49
49
|
isPartial?: boolean;
|
|
50
50
|
requestStartTime?: number;
|
|
51
51
|
},
|
|
52
|
+
/** An `ssr: false` loader on a document or capture render. */
|
|
53
|
+
useDefaultErrorBoundary?: boolean,
|
|
52
54
|
) => Promise<LoaderDataResult<T>>;
|
|
53
55
|
trackHandler: <T>(
|
|
54
56
|
promise: Promise<T>,
|
package/src/router.ts
CHANGED
|
@@ -132,6 +132,10 @@ import {
|
|
|
132
132
|
resolvePrefetchCacheSize,
|
|
133
133
|
resolvePrefetchConcurrency,
|
|
134
134
|
} from "./router/prefetch-limits.js";
|
|
135
|
+
import {
|
|
136
|
+
droppedByRunnerReload,
|
|
137
|
+
routerAfterRunnerReload,
|
|
138
|
+
} from "./router/dev-reload-handoff.js";
|
|
135
139
|
|
|
136
140
|
// Re-export public types and values from extracted modules
|
|
137
141
|
export { RSC_ROUTER_BRAND, RouterRegistry } from "./router/router-registry.js";
|
|
@@ -527,6 +531,7 @@ export function createRouter<TEnv = any>(
|
|
|
527
531
|
isPartial?: boolean;
|
|
528
532
|
requestStartTime?: number;
|
|
529
533
|
},
|
|
534
|
+
useDefaultErrorBoundary?: boolean,
|
|
530
535
|
): Promise<LoaderDataResult<T>> {
|
|
531
536
|
const loaderStart = telemetrySink ? performance.now() : 0;
|
|
532
537
|
const loaderRequestId = telemetrySink
|
|
@@ -596,6 +601,7 @@ export function createRouter<TEnv = any>(
|
|
|
596
601
|
findNearestNotFoundBoundary,
|
|
597
602
|
notFoundComponent: notFound,
|
|
598
603
|
},
|
|
604
|
+
useDefaultErrorBoundary,
|
|
599
605
|
);
|
|
600
606
|
|
|
601
607
|
// Emit loader.end after the promise settles (fire-and-forget)
|
|
@@ -1274,6 +1280,18 @@ export function createRouter<TEnv = any>(
|
|
|
1274
1280
|
});
|
|
1275
1281
|
}
|
|
1276
1282
|
|
|
1283
|
+
// Dev: a runner full reload dropped this router's module copy
|
|
1284
|
+
// (router/dev-reload-handoff.ts, #1186).
|
|
1285
|
+
if (process.env.NODE_ENV !== "production" && droppedByRunnerReload()) {
|
|
1286
|
+
const reloaded = await routerAfterRunnerReload(
|
|
1287
|
+
routerId,
|
|
1288
|
+
__sourceFile,
|
|
1289
|
+
);
|
|
1290
|
+
if (reloaded && reloaded !== router) {
|
|
1291
|
+
return reloaded.fetch(request, input);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1277
1295
|
// Trigger lazy import of per-router manifest data before route matching.
|
|
1278
1296
|
// No-op if data is already loaded or no loader is registered.
|
|
1279
1297
|
await ensureRouterManifest(routerId);
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { buildFullPayload, payloadLocation } from "./full-payload.js";
|
|
|
56
56
|
import {
|
|
57
57
|
capShellWindow,
|
|
58
58
|
lastStoredCaptureSeq,
|
|
59
|
+
markCaptureBackoff,
|
|
59
60
|
scheduleShellCapture,
|
|
60
61
|
resolveShellCaptureDebugSink,
|
|
61
62
|
takeCaptureDebugEventForTiming,
|
|
@@ -1804,13 +1805,14 @@ async function isSettled(value: unknown): Promise<boolean> {
|
|
|
1804
1805
|
}
|
|
1805
1806
|
|
|
1806
1807
|
/**
|
|
1807
|
-
* Evict a shell whose doc record cannot be replayed
|
|
1808
|
-
* delete, so the entry is
|
|
1809
|
-
*
|
|
1810
|
-
* treats as a MISS (shellServePlan,
|
|
1811
|
-
* replay as `no-segment-snapshot`. The
|
|
1812
|
-
* entry. Only for a broken entry: a
|
|
1813
|
-
* (`snapshotFailure: "unavailable"`)
|
|
1808
|
+
* Evict a shell whose doc record cannot be replayed, or one with a loader pin
|
|
1809
|
+
* that failed to decode. The store has no shell delete, so the entry is
|
|
1810
|
+
* overwritten with a tombstone: a navigationOnly entry with no document half
|
|
1811
|
+
* and no snapshot, which document serving treats as a MISS (shellServePlan,
|
|
1812
|
+
* the build shell included) and partial replay as `no-segment-snapshot`. The
|
|
1813
|
+
* recapture overwrites it with a sound entry. Only for a broken entry: a
|
|
1814
|
+
* snapshot read that was merely slow (`snapshotFailure: "unavailable"`)
|
|
1815
|
+
* leaves the entry alone.
|
|
1814
1816
|
*/
|
|
1815
1817
|
async function degradeUnreplayableShell(
|
|
1816
1818
|
descriptor: ShellCaptureDescriptor,
|
|
@@ -1878,6 +1880,21 @@ function serveShellHit(
|
|
|
1878
1880
|
const tailT0 = tailTiming ? performance.now() : 0;
|
|
1879
1881
|
const scheduleRecapture = (): void =>
|
|
1880
1882
|
scheduleShellCapture(ctx, request, env, url, reqCtx, ssrModule, descriptor);
|
|
1883
|
+
/** Report a broken entry, then tombstone and recapture it. Never throws. */
|
|
1884
|
+
const retireEntry = async (reason: string): Promise<void> => {
|
|
1885
|
+
reportCacheError(
|
|
1886
|
+
new Error(`shell entry for "${descriptor.key}": ${reason}`),
|
|
1887
|
+
"cache-read",
|
|
1888
|
+
"[ShellServe] retire",
|
|
1889
|
+
);
|
|
1890
|
+
try {
|
|
1891
|
+
await degradeUnreplayableShell(descriptor, entry, scheduleRecapture);
|
|
1892
|
+
} catch (error) {
|
|
1893
|
+
reportCacheError(error, "cache-write", "[ShellServe] retire");
|
|
1894
|
+
}
|
|
1895
|
+
};
|
|
1896
|
+
/** The first loader pin of the entry that failed to decode (dc-t89). */
|
|
1897
|
+
let undecodablePin: string | undefined;
|
|
1881
1898
|
|
|
1882
1899
|
const createTailContext = (): RequestContext<any> => {
|
|
1883
1900
|
const tailCtx: RequestContext<any> = Object.create(reqCtx);
|
|
@@ -2016,7 +2033,11 @@ function serveShellHit(
|
|
|
2016
2033
|
const seedStart =
|
|
2017
2034
|
INTERNAL_RANGO_DEBUG || tailTiming ? performance.now() : 0;
|
|
2018
2035
|
const loaderSeed =
|
|
2019
|
-
records.length > 0
|
|
2036
|
+
records.length > 0
|
|
2037
|
+
? await buildShellLoaderSeed(records, (key) => {
|
|
2038
|
+
undecodablePin ??= key;
|
|
2039
|
+
})
|
|
2040
|
+
: undefined;
|
|
2020
2041
|
if (tailTiming) {
|
|
2021
2042
|
const seededAt = performance.now();
|
|
2022
2043
|
tailTiming.seedMs = Math.round(seededAt - tailT0);
|
|
@@ -2179,10 +2200,8 @@ function serveShellHit(
|
|
|
2179
2200
|
// snapshot read was only slow stays, and the next request reads it
|
|
2180
2201
|
// again.
|
|
2181
2202
|
if ((await document.snapshotFailure) !== "unavailable") {
|
|
2182
|
-
await
|
|
2183
|
-
|
|
2184
|
-
entry,
|
|
2185
|
-
scheduleRecapture,
|
|
2203
|
+
await retireEntry(
|
|
2204
|
+
"the doc record could not supply the handler layer",
|
|
2186
2205
|
);
|
|
2187
2206
|
}
|
|
2188
2207
|
controller.enqueue(new TextEncoder().encode(shellReloadScript()));
|
|
@@ -2207,6 +2226,19 @@ function serveShellHit(
|
|
|
2207
2226
|
}
|
|
2208
2227
|
publishTailTiming(tailTiming, metricsStore, request, url);
|
|
2209
2228
|
controller.error(error);
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
// A pin that failed to decode left its loader to this request's run:
|
|
2232
|
+
// this response hydrated once over a prelude rendered from the
|
|
2233
|
+
// capture's value (dc-t89). Retire the entry once the response is
|
|
2234
|
+
// closed, and back the key off so a pin that keeps failing costs one
|
|
2235
|
+
// mismatch per window. No reload: that would cost a second page load.
|
|
2236
|
+
if (undecodablePin !== undefined) {
|
|
2237
|
+
const pin = undecodablePin;
|
|
2238
|
+
markCaptureBackoff(descriptor.key);
|
|
2239
|
+
reqCtx.waitUntil(() =>
|
|
2240
|
+
retireEntry(`the loader pin "${pin}" failed to decode`),
|
|
2241
|
+
);
|
|
2210
2242
|
}
|
|
2211
2243
|
},
|
|
2212
2244
|
});
|
|
@@ -213,9 +213,12 @@ export const SHELL_CAPTURE_TASK_HARD_CAP_MS = 25_000;
|
|
|
213
213
|
* `no-record` (no doc record: the route's cache() refused the write, or the
|
|
214
214
|
* handler output failed), `handles` (a pushed
|
|
215
215
|
* handle value did not finish encoding), `size` (over the store's value
|
|
216
|
-
* limit
|
|
217
|
-
*
|
|
218
|
-
*
|
|
216
|
+
* limit, or `ssr: false` loader pins over `ppr.maxSnapshotBytes`: a
|
|
217
|
+
* navigation-only entry was stored in place of the document shell),
|
|
218
|
+
* `record-expired` (the route cache() record
|
|
219
|
+
* the capture wrote ran out first), `invalidated` (a tag of the shell was
|
|
220
|
+
* invalidated after the capture started), `uncacheable` (the store cannot
|
|
221
|
+
* hold the entry).
|
|
219
222
|
*/
|
|
220
223
|
export type ShellCaptureRefusal =
|
|
221
224
|
| "identity"
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -542,9 +542,8 @@ export const DEFAULT_SHELL_ENTRY_MAX_BYTES: number = 25 * 1024 * 1024;
|
|
|
542
542
|
* while still fitting any sane pinned-ring payload. Applied ONLY in
|
|
543
543
|
* captureAndStoreShell (the single defaulting site — resolvePprConfig passes
|
|
544
544
|
* the option through undefaulted), so every producer and direct caller gets
|
|
545
|
-
* the same policy. The doc record is exempt; over the cap the
|
|
546
|
-
*
|
|
547
|
-
* live store — see PartialPrerenderProps.maxSnapshotBytes).
|
|
545
|
+
* the same policy. The doc record is exempt; over the cap only the
|
|
546
|
+
* navigation half is stored (see PartialPrerenderProps.maxSnapshotBytes).
|
|
548
547
|
*/
|
|
549
548
|
export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
|
|
550
549
|
|
|
@@ -553,12 +552,8 @@ const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
|
|
|
553
552
|
|
|
554
553
|
/**
|
|
555
554
|
* Warn once per key that the capture's loader pins exceeded the route's
|
|
556
|
-
* `maxSnapshotBytes` cap
|
|
557
|
-
*
|
|
558
|
-
* pinned loaders lose their captured values, so their cached data can drift
|
|
559
|
-
* from the frozen prelude and hydration repairs it client-side. Once per
|
|
560
|
-
* key: the same page recaptures on every TTL roll and would otherwise
|
|
561
|
-
* re-warn forever.
|
|
555
|
+
* `maxSnapshotBytes` cap, so no document shell was stored (dc-t89). Once per
|
|
556
|
+
* key: the route recaptures every backoff window.
|
|
562
557
|
*/
|
|
563
558
|
function warnSnapshotOverCapOnce(
|
|
564
559
|
key: string,
|
|
@@ -570,14 +565,10 @@ function warnSnapshotOverCapOnce(
|
|
|
570
565
|
key,
|
|
571
566
|
() =>
|
|
572
567
|
`[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
|
|
573
|
-
`loader pins, over the ${capBytes}-byte cap
|
|
574
|
-
"
|
|
575
|
-
"
|
|
576
|
-
"
|
|
577
|
-
"mismatch client-side. Raise the cap via the route's ppr option " +
|
|
578
|
-
"({ maxSnapshotBytes }) if the entry still fits your store's value limit " +
|
|
579
|
-
"(Cloudflare KV: 25 MiB per value), or shrink the cache()'d data the " +
|
|
580
|
-
"loaders read.",
|
|
568
|
+
`ssr: false loader pins, over the ${capBytes}-byte cap: the document ` +
|
|
569
|
+
"shell was not stored; raise maxSnapshotBytes or shrink the ssr: false " +
|
|
570
|
+
"data. Documents of this route are a MISS until then; client " +
|
|
571
|
+
"navigations still replay its handler layer.",
|
|
581
572
|
);
|
|
582
573
|
}
|
|
583
574
|
|
|
@@ -683,7 +674,11 @@ export interface ShellCaptureDebugEvent {
|
|
|
683
674
|
preludeBytes?: number;
|
|
684
675
|
/** Serialized snapshot size in UTF-8 bytes. Absent when nothing was recorded. */
|
|
685
676
|
snapshotBytes?: number;
|
|
686
|
-
/**
|
|
677
|
+
/**
|
|
678
|
+
* True when the loader pins exceeded maxSnapshotBytes: no document shell
|
|
679
|
+
* was stored, only a navigation-only entry (the doc record, or a tombstone
|
|
680
|
+
* for a prerender-served capture). The outcome is `refused`, refusal `size`.
|
|
681
|
+
*/
|
|
687
682
|
snapshotSkipped?: boolean;
|
|
688
683
|
/** A bake-lane loader settled into a shell that uses TTL/SWR-only invalidation. */
|
|
689
684
|
untaggedBake?: true;
|
|
@@ -1053,8 +1048,8 @@ export interface ShellCaptureDescriptor {
|
|
|
1053
1048
|
captureTimeout?: number;
|
|
1054
1049
|
store?: SegmentCacheStore<any>;
|
|
1055
1050
|
/**
|
|
1056
|
-
* Cap (serialized UTF-8 bytes) on the entry's
|
|
1057
|
-
*
|
|
1051
|
+
* Cap (serialized UTF-8 bytes) on the entry's loader pins; over it only the
|
|
1052
|
+
* navigation half is stored and the attempt refuses (reported once per
|
|
1058
1053
|
* key). Absent = DEFAULT_PPR_MAX_SNAPSHOT_BYTES, applied in
|
|
1059
1054
|
* captureAndStoreShell — the single defaulting site.
|
|
1060
1055
|
*/
|
|
@@ -2556,10 +2551,13 @@ async function captureAndStoreShell(
|
|
|
2556
2551
|
runs,
|
|
2557
2552
|
},
|
|
2558
2553
|
});
|
|
2554
|
+
} else {
|
|
2555
|
+
throw new Error(`[rango] no shell pin for "${segmentKey}"`);
|
|
2559
2556
|
}
|
|
2560
|
-
} catch {
|
|
2561
|
-
//
|
|
2562
|
-
//
|
|
2557
|
+
} catch (error) {
|
|
2558
|
+
// Unpinned, the loader would run on every HIT over this prelude
|
|
2559
|
+
// (dc-t89): refuse like an encode error.
|
|
2560
|
+
reqCtx._renderErrors?.push(error);
|
|
2563
2561
|
}
|
|
2564
2562
|
}
|
|
2565
2563
|
}
|
|
@@ -2603,12 +2601,11 @@ async function captureAndStoreShell(
|
|
|
2603
2601
|
snapshot = kept;
|
|
2604
2602
|
}
|
|
2605
2603
|
|
|
2606
|
-
// Snapshot size guard
|
|
2607
|
-
//
|
|
2608
|
-
//
|
|
2609
|
-
//
|
|
2610
|
-
//
|
|
2611
|
-
// bounded separately below, by the store's value limit.
|
|
2604
|
+
// Snapshot size guard: `maxSnapshotBytes` bounds the loader pins, and a
|
|
2605
|
+
// document HIT needs every pin its prelude baked (dc-t89). Over the cap
|
|
2606
|
+
// the entry keeps the navigation half only (the doc record, or nothing:
|
|
2607
|
+
// a tombstone that also shadows a build shell) and the attempt refuses.
|
|
2608
|
+
// The whole entry is bounded separately below, by the store's limit.
|
|
2612
2609
|
if (snapshot.length > 0) {
|
|
2613
2610
|
const docRecord =
|
|
2614
2611
|
docKey !== undefined
|
|
@@ -2629,9 +2626,10 @@ async function captureAndStoreShell(
|
|
|
2629
2626
|
stats.snapshotSkipped = true;
|
|
2630
2627
|
}
|
|
2631
2628
|
}
|
|
2629
|
+
const navigationOnly = capture.navigationOnly || stats.snapshotSkipped;
|
|
2632
2630
|
// Exempt from maxSnapshotBytes: the HIT needs it for its HTML and
|
|
2633
2631
|
// hydration data to match.
|
|
2634
|
-
if (prerenderHandles) {
|
|
2632
|
+
if (prerenderHandles && !navigationOnly) {
|
|
2635
2633
|
snapshot.push({
|
|
2636
2634
|
family: "handles",
|
|
2637
2635
|
key: SHELL_HANDLES_RECORD_KEY,
|
|
@@ -2680,8 +2678,8 @@ async function captureAndStoreShell(
|
|
|
2680
2678
|
// JSON head as an escaped string.
|
|
2681
2679
|
const entryBytes = estimateShellEntryBytes({
|
|
2682
2680
|
// A navigation-only entry stores no document half.
|
|
2683
|
-
preludeBytes:
|
|
2684
|
-
postponed:
|
|
2681
|
+
preludeBytes: navigationOnly ? 0 : result.prelude.length,
|
|
2682
|
+
postponed: navigationOnly ? undefined : result.postponed,
|
|
2685
2683
|
snapshot,
|
|
2686
2684
|
tags: shellTags,
|
|
2687
2685
|
docKey: storedDocKey,
|
|
@@ -2717,7 +2715,7 @@ async function captureAndStoreShell(
|
|
|
2717
2715
|
// consumes only snapshot/docKey), so storing it would ride every KV
|
|
2718
2716
|
// write/read as dead weight — the prerender still ran above as the
|
|
2719
2717
|
// completeness arbiter and sanity gate, its output is dropped here.
|
|
2720
|
-
...(
|
|
2718
|
+
...(navigationOnly
|
|
2721
2719
|
? {}
|
|
2722
2720
|
: {
|
|
2723
2721
|
// bytesToBase64 encodes the view's own region, so a prelude
|
|
@@ -2740,7 +2738,7 @@ async function captureAndStoreShell(
|
|
|
2740
2738
|
// looks the record up by this key (serveShellHit fixedDocKey);
|
|
2741
2739
|
// undefined only for a prerender-served entry.
|
|
2742
2740
|
docKey: storedDocKey,
|
|
2743
|
-
navigationOnly:
|
|
2741
|
+
navigationOnly: navigationOnly || undefined,
|
|
2744
2742
|
createdAt: captureStartedAt,
|
|
2745
2743
|
};
|
|
2746
2744
|
// Capped to the route record the capture used: the policy the
|
|
@@ -2828,8 +2826,9 @@ async function captureAndStoreShell(
|
|
|
2828
2826
|
}
|
|
2829
2827
|
// A shell was captured (ordinary store I/O failure is reported, not retried).
|
|
2830
2828
|
// An acknowledged invalidation rejection returned `refused` above so the
|
|
2831
|
-
// scheduler backs the key off instead of recapturing on every request
|
|
2832
|
-
|
|
2829
|
+
// scheduler backs the key off instead of recapturing on every request, as
|
|
2830
|
+
// does an over-cap entry: no document shell was stored.
|
|
2831
|
+
return stats.snapshotSkipped ? refuse(stats, "size") : "stored";
|
|
2833
2832
|
} finally {
|
|
2834
2833
|
// Stop the hop loop for the pathological never-quiets path (quiesce never
|
|
2835
2834
|
// fired, capture returned via maxWaitMs). On the normal path the loop already
|
|
@@ -2849,7 +2848,8 @@ export {
|
|
|
2849
2848
|
};
|
|
2850
2849
|
|
|
2851
2850
|
// Exported for unit tests that pin the refused-capture backoff policy directly
|
|
2852
|
-
// (dev cap vs production exponential growth, stored-clears, cold-start re-probe)
|
|
2851
|
+
// (dev cap vs production exponential growth, stored-clears, cold-start re-probe),
|
|
2852
|
+
// and markCaptureBackoff for serveShellHit (a pin that failed to decode, dc-t89).
|
|
2853
2853
|
// These are the same module-level functions the schedule path uses; a test that
|
|
2854
2854
|
// drove them through a real capture round-trip could not assert the exact window
|
|
2855
2855
|
// arithmetic without a full cold render.
|
|
@@ -66,12 +66,13 @@ export interface PartialPrerenderProps {
|
|
|
66
66
|
/**
|
|
67
67
|
* Upper bound (serialized UTF-8 bytes) on the loader pins in the capture
|
|
68
68
|
* data snapshot: the bake-lane loader records. The doc record (the handler
|
|
69
|
-
* layer every HIT replays) is exempt.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* Defaults to 8 MiB. The whole
|
|
74
|
-
*
|
|
69
|
+
* layer every HIT replays) is exempt. A document HIT is served only from an
|
|
70
|
+
* entry that holds a pin for every `ssr: false` loader its HTML baked, so
|
|
71
|
+
* over the cap no document shell is stored: documents render in full
|
|
72
|
+
* (MISS), and client navigations still replay the handler layer and run
|
|
73
|
+
* those loaders fresh. Reported once per key. Defaults to 8 MiB. The whole
|
|
74
|
+
* entry is bounded separately by the store's value limit (Cloudflare KV:
|
|
75
|
+
* 25 MiB); an entry over it is refused.
|
|
75
76
|
*/
|
|
76
77
|
maxSnapshotBytes?: number;
|
|
77
78
|
/**
|