@rangojs/router 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -89
- package/dist/testing/vitest.js +252 -0
- package/dist/types/browser/cookie-name.d.ts +6 -0
- package/dist/types/browser/history-state.d.ts +16 -0
- package/dist/types/browser/navigation-transaction.d.ts +6 -0
- package/dist/types/browser/partial-update.d.ts +11 -0
- package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
- package/dist/types/browser/rsc-router.d.ts +1 -1
- package/dist/types/browser/scroll-restoration.d.ts +7 -0
- package/dist/types/cache/cache-exec-scope.d.ts +18 -1
- package/dist/types/cache/cache-key-utils.d.ts +17 -2
- package/dist/types/cache/cache-policy.d.ts +17 -0
- package/dist/types/cache/cache-scope.d.ts +132 -19
- package/dist/types/cache/cache-tag.d.ts +92 -7
- package/dist/types/cache/cf/cf-base64.d.ts +6 -0
- package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
- package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
- package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
- package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
- package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
- package/dist/types/cache/handle-capture.d.ts +41 -3
- package/dist/types/cache/handle-snapshot.d.ts +54 -13
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- package/dist/types/cache/segment-codec.d.ts +10 -3
- package/dist/types/cache/shell-memo.d.ts +178 -0
- package/dist/types/cache/shell-snapshot.d.ts +110 -21
- package/dist/types/cache/tag-invalidation.d.ts +30 -3
- package/dist/types/cache/taint.d.ts +1 -1
- package/dist/types/cache/types.d.ts +220 -44
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
- package/dist/types/client.d.ts +4 -4
- package/dist/types/client.rsc.d.ts +4 -5
- package/dist/types/components/DefaultDocument.d.ts +5 -2
- package/dist/types/context-var.d.ts +6 -5
- package/dist/types/escape-script.d.ts +1 -1
- package/dist/types/handles/MetaTags.d.ts +5 -4
- package/dist/types/handles/Scripts.d.ts +8 -7
- package/dist/types/handles/meta.d.ts +2 -2
- package/dist/types/handles/script.d.ts +17 -16
- package/dist/types/href-client.d.ts +1 -1
- package/dist/types/html.d.ts +12 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.rsc.d.ts +3 -2
- package/dist/types/loader-store.d.ts +7 -4
- package/dist/types/prerender/build-shell-capture.d.ts +11 -4
- package/dist/types/reverse.d.ts +36 -4
- package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
- package/dist/types/route-definition/helpers-types.d.ts +33 -9
- package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
- package/dist/types/route-types.d.ts +1 -1
- package/dist/types/router/debug-manifest.d.ts +3 -2
- package/dist/types/router/error-handling.d.ts +20 -0
- package/dist/types/router/handler-context.d.ts +6 -0
- package/dist/types/router/instrument.d.ts +8 -0
- package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
- package/dist/types/router/middleware-types.d.ts +9 -3
- package/dist/types/router/router-context.d.ts +3 -1
- package/dist/types/router/router-interfaces.d.ts +24 -2
- package/dist/types/router/router-options.d.ts +8 -5
- package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
- package/dist/types/router/segment-resolution/loader-cache.d.ts +66 -1
- package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +23 -12
- package/dist/types/router/segment-resolution/mask-nested.d.ts +67 -26
- package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
- package/dist/types/router/segment-wrappers.d.ts +1 -1
- package/dist/types/rsc/full-payload.d.ts +14 -0
- package/dist/types/rsc/handler.d.ts +1 -1
- package/dist/types/rsc/response-route-handler.d.ts +1 -1
- package/dist/types/rsc/rsc-rendering.d.ts +1 -2
- package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +61 -6
- package/dist/types/rsc/shell-capture.d.ts +151 -19
- package/dist/types/rsc/shell-serve.d.ts +140 -17
- package/dist/types/rsc/ssr-setup.d.ts +6 -6
- package/dist/types/rsc/types.d.ts +17 -0
- package/dist/types/server/context.d.ts +139 -15
- package/dist/types/server/cookie-store.d.ts +21 -0
- package/dist/types/server/handle-store.d.ts +26 -2
- package/dist/types/server/request-context.d.ts +182 -53
- package/dist/types/ssr/index.d.ts +24 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/static-handler.d.ts +4 -1
- package/dist/types/testing/flight.entry.d.ts +7 -0
- package/dist/types/testing/generated-routes.d.ts +2 -2
- package/dist/types/testing/internal/context.d.ts +1 -1
- package/dist/types/testing/render-route.d.ts +32 -6
- package/dist/types/testing/run-loader.d.ts +18 -3
- package/dist/types/testing/serve-shell-request.d.ts +132 -0
- package/dist/types/testing/shell-status.d.ts +23 -10
- package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
- package/dist/types/testing/vitest.d.ts +35 -1
- package/dist/types/theme/ThemeScript.d.ts +3 -3
- package/dist/types/types/cache-types.d.ts +14 -10
- package/dist/types/types/handler-context.d.ts +28 -1
- package/dist/types/types/loader-types.d.ts +8 -6
- package/dist/types/urls/pattern-types.d.ts +22 -18
- package/dist/types/urls/response-types.d.ts +3 -12
- package/dist/types/use-loader.d.ts +12 -3
- package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
- package/dist/types/vite/rango.d.ts +1 -1
- package/dist/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +1420 -1348
- package/package.json +2 -2
- package/skills/api-client/SKILL.md +22 -10
- package/skills/breadcrumbs/SKILL.md +75 -22
- package/skills/bundle-analysis/SKILL.md +22 -22
- package/skills/cache-guide/SKILL.md +306 -124
- package/skills/caching/SKILL.md +530 -98
- package/skills/catalog.json +16 -16
- package/skills/client-urls/SKILL.md +65 -38
- package/skills/cloudflare/SKILL.md +87 -20
- package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
- package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
- package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
- package/skills/comparison/SKILL.md +5 -0
- package/skills/comparison/references/framework-comparison.md +75 -57
- package/skills/composability/SKILL.md +38 -29
- package/skills/css/SKILL.md +10 -13
- package/skills/debug-manifest/SKILL.md +74 -31
- package/skills/defer-hydration/SKILL.md +52 -31
- package/skills/deployment-caching/SKILL.md +11 -3
- package/skills/document-cache/SKILL.md +98 -28
- package/skills/fonts/SKILL.md +16 -8
- package/skills/handler-use/SKILL.md +55 -17
- package/skills/hooks/SKILL.md +28 -20
- package/skills/hooks/data.md +23 -3
- package/skills/hooks/handle-and-actions.md +34 -17
- package/skills/hooks/navigation.md +39 -10
- package/skills/hooks/outlets.md +13 -6
- package/skills/hooks/state.md +39 -15
- package/skills/hooks/urls.md +7 -5
- package/skills/host-router/SKILL.md +64 -12
- package/skills/i18n/SKILL.md +60 -34
- package/skills/intercept/SKILL.md +161 -26
- package/skills/layout/SKILL.md +106 -50
- package/skills/links/SKILL.md +51 -19
- package/skills/loader/SKILL.md +386 -128
- package/skills/middleware/SKILL.md +129 -26
- package/skills/migrate-nextjs/SKILL.md +156 -70
- package/skills/migrate-nextjs/backend-host-swap.md +5 -2
- package/skills/migrate-react-router/SKILL.md +51 -39
- package/skills/migrate-react-router/component-migration.md +28 -7
- package/skills/migrate-react-router/data-and-actions.md +21 -6
- package/skills/migrate-react-router/route-mapping.md +19 -10
- package/skills/mime-routes/SKILL.md +12 -7
- package/skills/observability/SKILL.md +92 -14
- package/skills/parallel/SKILL.md +88 -47
- package/skills/ppr/SKILL.md +866 -379
- package/skills/prerender/SKILL.md +210 -108
- package/skills/rango/SKILL.md +115 -65
- package/skills/react-compiler/SKILL.md +6 -5
- package/skills/response-routes/SKILL.md +133 -25
- package/skills/route/SKILL.md +170 -60
- package/skills/router-setup/SKILL.md +420 -240
- package/skills/scripts/SKILL.md +39 -21
- package/skills/server-actions/SKILL.md +231 -59
- package/skills/shell-manifest/SKILL.md +28 -14
- package/skills/streams-and-websockets/SKILL.md +32 -14
- package/skills/tailwind/SKILL.md +37 -33
- package/skills/testing/SKILL.md +73 -33
- package/skills/testing/cache-prerender.md +60 -33
- package/skills/testing/client-components.md +59 -6
- package/skills/testing/e2e-parity.md +12 -2
- package/skills/testing/flight.md +19 -11
- package/skills/testing/handles.md +8 -5
- package/skills/testing/loader.md +26 -6
- package/skills/testing/middleware.md +8 -3
- package/skills/testing/predicates.md +120 -0
- package/skills/testing/render-handler.md +70 -22
- package/skills/testing/response-routes.md +11 -4
- package/skills/testing/reverse-and-types.md +20 -14
- package/skills/testing/serve-shell-request.md +188 -0
- package/skills/testing/server-actions.md +12 -3
- package/skills/testing/server-tree.md +9 -0
- package/skills/testing/setup.md +9 -5
- package/skills/theme/SKILL.md +155 -30
- package/skills/typesafety/SKILL.md +20 -9
- package/skills/typesafety/env-and-bindings.md +137 -50
- package/skills/typesafety/generated-files-and-cli.md +26 -14
- package/skills/typesafety/params-and-search.md +44 -19
- package/skills/typesafety/route-types.md +50 -14
- package/skills/use-cache/SKILL.md +224 -83
- package/skills/vercel/SKILL.md +35 -13
- package/skills/view-transitions/SKILL.md +42 -21
- package/src/__augment-tests__/augmented.check.ts +26 -1
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/history-state.ts +23 -0
- package/src/browser/index.ts +2 -2
- package/src/browser/navigation-bridge.ts +3 -4
- package/src/browser/navigation-transaction.ts +22 -2
- package/src/browser/partial-update.ts +35 -25
- package/src/browser/react/ScrollRestoration.tsx +6 -6
- package/src/browser/rsc-router.tsx +6 -1
- package/src/browser/scroll-restoration.ts +53 -6
- package/src/cache/cache-exec-scope.ts +37 -4
- package/src/cache/cache-key-utils.ts +23 -8
- package/src/cache/cache-policy.ts +22 -0
- package/src/cache/cache-runtime.ts +264 -68
- package/src/cache/cache-scope.ts +419 -43
- package/src/cache/cache-tag.ts +375 -11
- package/src/cache/cf/cf-base64.ts +36 -4
- package/src/cache/cf/cf-cache-constants.ts +12 -0
- package/src/cache/cf/cf-cache-store.ts +990 -348
- package/src/cache/cf/cf-cache-types.ts +32 -4
- package/src/cache/cf/cf-shell-frame.ts +258 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
- package/src/cache/document-cache.ts +15 -0
- package/src/cache/handle-capture.ts +90 -10
- package/src/cache/handle-snapshot.ts +133 -28
- package/src/cache/index.ts +2 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/segment-codec.ts +26 -7
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +217 -27
- package/src/cache/tag-invalidation.ts +108 -22
- package/src/cache/taint.ts +1 -1
- package/src/cache/types.ts +225 -44
- package/src/cache/vercel/vercel-cache-store.ts +545 -61
- package/src/client.rsc.tsx +10 -9
- package/src/client.tsx +5 -4
- package/src/components/DefaultDocument.tsx +7 -2
- package/src/context-var.ts +6 -5
- package/src/escape-script.ts +1 -1
- package/src/handles/MetaTags.tsx +13 -11
- package/src/handles/Scripts.tsx +11 -10
- package/src/handles/meta.ts +2 -2
- package/src/handles/script.ts +17 -16
- package/src/href-client.ts +1 -1
- package/src/html.ts +12 -0
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +1 -0
- package/src/loader-store.ts +7 -4
- package/src/prerender/build-shell-capture.ts +73 -22
- package/src/reverse.ts +44 -4
- package/src/route-definition/dsl-helpers.ts +121 -33
- package/src/route-definition/helpers-types.ts +33 -9
- package/src/route-definition/resolve-handler-use.ts +121 -6
- package/src/route-types.ts +0 -3
- package/src/router/debug-manifest.ts +10 -2
- package/src/router/error-handling.ts +51 -15
- package/src/router/handler-context.ts +71 -28
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +141 -43
- package/src/router/loader-resolution.ts +115 -39
- package/src/router/match-api.ts +40 -26
- package/src/router/match-handlers.ts +5 -0
- package/src/router/match-middleware/background-revalidation.ts +161 -96
- package/src/router/match-middleware/cache-lookup.ts +110 -10
- package/src/router/match-middleware/cache-store.ts +48 -101
- package/src/router/middleware-types.ts +9 -6
- package/src/router/middleware.ts +75 -13
- package/src/router/prerender-match.ts +59 -19
- package/src/router/route-snapshot.ts +11 -1
- package/src/router/router-context.ts +2 -0
- package/src/router/router-interfaces.ts +25 -2
- package/src/router/router-options.ts +8 -5
- package/src/router/segment-resolution/fresh.ts +62 -21
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +407 -76
- package/src/router/segment-resolution/loader-mask.ts +12 -22
- package/src/router/segment-resolution/loader-snapshot.ts +208 -56
- package/src/router/segment-resolution/mask-nested.ts +138 -44
- package/src/router/segment-resolution/revalidation.ts +61 -16
- package/src/router/segment-wrappers.ts +4 -0
- package/src/rsc/full-payload.ts +25 -1
- package/src/rsc/handler.ts +14 -2
- package/src/rsc/progressive-enhancement.ts +3 -2
- package/src/rsc/render-pipeline.ts +3 -0
- package/src/rsc/response-cache-serve.ts +9 -6
- package/src/rsc/response-route-handler.ts +11 -3
- package/src/rsc/rsc-rendering.ts +608 -225
- package/src/rsc/shell-build-manifest.ts +27 -9
- package/src/rsc/shell-capture-constants.ts +73 -6
- package/src/rsc/shell-capture.ts +1068 -296
- package/src/rsc/shell-serve.ts +316 -34
- package/src/rsc/ssr-setup.ts +14 -8
- package/src/rsc/types.ts +18 -0
- package/src/server/context.ts +300 -32
- package/src/server/cookie-store.ts +87 -61
- package/src/server/handle-store.ts +232 -7
- package/src/server/request-context.ts +297 -67
- package/src/ssr/index.tsx +136 -44
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/static-handler.ts +4 -1
- package/src/testing/flight-runtime.d.ts +21 -0
- package/src/testing/flight.entry.ts +14 -0
- package/src/testing/generated-routes.ts +2 -2
- package/src/testing/internal/context.ts +1 -1
- package/src/testing/internal/flight-client-globals.ts +7 -5
- package/src/testing/render-route.tsx +85 -24
- package/src/testing/run-loader.ts +68 -6
- package/src/testing/serve-shell-request.ts +378 -0
- package/src/testing/shell-status.ts +34 -12
- package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
- package/src/testing/vitest.ts +62 -1
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/theme/ThemeScript.tsx +3 -3
- package/src/types/cache-types.ts +14 -10
- package/src/types/handler-context.ts +28 -1
- package/src/types/loader-types.ts +8 -6
- package/src/urls/pattern-types.ts +22 -18
- package/src/urls/response-types.ts +3 -23
- package/src/use-loader.tsx +12 -3
- package/src/vite/discovery/shell-prerender-phase.ts +9 -0
- package/src/vite/plugins/use-cache-transform.ts +118 -74
- package/src/vite/rango.ts +7 -3
- package/src/vite/router-discovery.ts +14 -4
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import React from "react";
|
|
20
|
-
import { isLoaderDataResult } from "../types.js";
|
|
21
|
-
import {
|
|
20
|
+
import { isLoaderDataResult, type MatchResult } from "../types.js";
|
|
21
|
+
import { bytesToBase64 } from "../cache/cf/cf-base64.js";
|
|
22
|
+
import { SHELL_BAKE_TAG_OWNER, getSegmentTags } from "../cache/cache-tag.js";
|
|
22
23
|
import { reportCacheError } from "../cache/cache-error.js";
|
|
23
24
|
import { runBackground } from "../cache/background-task.js";
|
|
24
25
|
import {
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
enqueueSerializedCapture,
|
|
29
30
|
} from "./capture-queue.js";
|
|
30
31
|
import {
|
|
32
|
+
PPR_LANE_HINT,
|
|
31
33
|
SHELL_CAPTURE_MAX_WAIT_MS,
|
|
32
34
|
SHELL_CAPTURE_TASK_HARD_CAP_MS,
|
|
33
35
|
} from "./shell-capture-constants.js";
|
|
@@ -40,10 +42,17 @@ import {
|
|
|
40
42
|
wireRenderBarrier,
|
|
41
43
|
UNTRACKED_BACKGROUND_TASK,
|
|
42
44
|
type RequestContext,
|
|
45
|
+
type RouteRecordWindow,
|
|
43
46
|
} from "../server/request-context.js";
|
|
47
|
+
import {
|
|
48
|
+
DEFAULT_FUNCTION_TTL,
|
|
49
|
+
resolveSwrWindow,
|
|
50
|
+
resolveTtl,
|
|
51
|
+
} from "../cache/cache-policy.js";
|
|
44
52
|
import { createHandleStore, type HandleStore } from "../server/handle-store.js";
|
|
45
53
|
import {
|
|
46
54
|
maskNestedContainerThenables,
|
|
55
|
+
settleNestedThenables,
|
|
47
56
|
type MaskReport,
|
|
48
57
|
} from "../router/segment-resolution/mask-nested.js";
|
|
49
58
|
import {
|
|
@@ -52,6 +61,7 @@ import {
|
|
|
52
61
|
} from "../server/context.js";
|
|
53
62
|
import { isThenable } from "../handles/is-thenable.js";
|
|
54
63
|
import type {
|
|
64
|
+
CachedEntryData,
|
|
55
65
|
ShellCacheEntry,
|
|
56
66
|
SegmentCacheStore,
|
|
57
67
|
ShellSnapshotRecord,
|
|
@@ -63,11 +73,16 @@ import {
|
|
|
63
73
|
import {
|
|
64
74
|
RecordingShellStore,
|
|
65
75
|
SnapshotOnlySegmentStore,
|
|
76
|
+
countSnapshotFamilies,
|
|
77
|
+
estimateShellEntryBytes,
|
|
66
78
|
getRecordingStore,
|
|
79
|
+
hasDocRecord,
|
|
80
|
+
pruneShellSnapshot,
|
|
81
|
+
type ShellSnapshotReaders,
|
|
67
82
|
} from "../cache/shell-snapshot.js";
|
|
68
83
|
import type { HandlerContext } from "./handler-context.js";
|
|
69
84
|
import type { SSRModule } from "./types.js";
|
|
70
|
-
import { buildFullPayload } from "./full-payload.js";
|
|
85
|
+
import { buildFullPayload, payloadInitialTheme } from "./full-payload.js";
|
|
71
86
|
import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
|
|
72
87
|
import { renderRscFlightStage } from "./render-pipeline.js";
|
|
73
88
|
import { stripInternalParams } from "../router/handler-context.js";
|
|
@@ -257,10 +272,11 @@ function releaseCaptureGuard(key: string, token: CaptureGuardToken): void {
|
|
|
257
272
|
* mode's ceiling (60s in production, {@link REFUSED_CAPTURE_DEV_MAX_MS} in dev).
|
|
258
273
|
*
|
|
259
274
|
* Why exponential and not a flat 60s: a flat long window conflates two very
|
|
260
|
-
* different failures. A STRUCTURALLY ineligible route (
|
|
261
|
-
* reader) fails forever and wants the long 60s cap. But a
|
|
262
|
-
* can also fail the in-place retry under a truly cold
|
|
263
|
-
* or a cold worker under parallel load) — and it
|
|
275
|
+
* different failures. A STRUCTURALLY ineligible route (a boundary-less live
|
|
276
|
+
* loader read, a cookie reader) fails forever and wants the long 60s cap. But a
|
|
277
|
+
* cold-but-ELIGIBLE route can also fail the in-place retry under a truly cold
|
|
278
|
+
* graph (dev module transform, or a cold worker under parallel load) — and it
|
|
279
|
+
* must recover FAST, on the next
|
|
264
280
|
* request or two, not be frozen for 60s (that would re-break the very cold-start DX
|
|
265
281
|
* the retry fixes; it bit the cloudflare dev e2e). Escalating from 1s means the
|
|
266
282
|
* eligible route re-probes almost immediately (warm now → HIT and clear), while the
|
|
@@ -357,8 +373,9 @@ function clearCaptureBackoff(key: string): void {
|
|
|
357
373
|
const warnedNullCaptures = new Set<string>();
|
|
358
374
|
|
|
359
375
|
/**
|
|
360
|
-
* Warn once per key that a capture produced no usable shell
|
|
361
|
-
* in-place retry (runShellCapture attempt 2)
|
|
376
|
+
* Warn once per key that a capture produced no usable shell: after the
|
|
377
|
+
* in-place retry (runShellCapture attempt 2), or after one attempt that ran
|
|
378
|
+
* out of its deadline (no retry: noShellCauses). Naming both causes with the
|
|
362
379
|
* distinguishing signal — does the route ever flip to HIT — is the whole point:
|
|
363
380
|
* the pre-retry version blamed "a loader route without loading()" unconditionally
|
|
364
381
|
* and misled users whose route DID have loading() and was merely cold. Because the
|
|
@@ -366,55 +383,164 @@ const warnedNullCaptures = new Set<string>();
|
|
|
366
383
|
* usually healed, so a firing warning leans toward the structural cause — but we
|
|
367
384
|
* still name both so a cold-start straggler is not misdiagnosed.
|
|
368
385
|
*
|
|
369
|
-
* The pointer is
|
|
370
|
-
*
|
|
371
|
-
* rather than a relative path that dead-ends for consumers.
|
|
386
|
+
* The pointer is the shared PPR_LANE_HINT: the /ppr skill ships in the npm
|
|
387
|
+
* tarball, so the path resolves for consumers (a05c8251 convention).
|
|
372
388
|
*/
|
|
373
|
-
function warnNullCaptureOnce(key: string): void {
|
|
389
|
+
function warnNullCaptureOnce(key: string, retried: boolean): void {
|
|
390
|
+
const pendingStacks = noShellPendingStacks.get(key);
|
|
391
|
+
noShellPendingStacks.delete(key);
|
|
392
|
+
const cause = noShellCauses.get(key);
|
|
393
|
+
noShellCauses.delete(key);
|
|
374
394
|
if (warnedNullCaptures.has(key)) return;
|
|
375
395
|
warnedNullCaptures.add(key);
|
|
376
396
|
console.warn(
|
|
377
|
-
`[rango] Shell capture for "${key}" produced no usable shell
|
|
378
|
-
|
|
379
|
-
"
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
397
|
+
`[rango] Shell capture for "${key}" produced no usable shell` +
|
|
398
|
+
(retried ? " after an in-place retry" : "") +
|
|
399
|
+
"; nothing was stored, so this request stays on MISS. " +
|
|
400
|
+
(cause
|
|
401
|
+
? `Cause: ${cause}. Every capture waits for the handler layer to finish and ` +
|
|
402
|
+
'bakes it; make the slow part cheaper (cache() or "use cache"), raise ' +
|
|
403
|
+
"ppr.captureTimeout, or move it into a live loader (no ssr: false) read under " +
|
|
404
|
+
"loading() or an inline <Suspense>.\n"
|
|
405
|
+
: "Causes, told apart by whether the route ever flips to HIT:\n" +
|
|
406
|
+
" 1. Cold-start warmup (dev module transform, or a cold worker): the capture raced " +
|
|
407
|
+
"an unfinished shell render. This SELF-HEALS — the route flips to HIT once a later " +
|
|
408
|
+
"request warms the modules.\n" +
|
|
409
|
+
" 2. Something suspends above <body> with no Suspense boundary and never settles " +
|
|
410
|
+
"within the capture window: a live loader (no ssr: false) read without loading() or " +
|
|
411
|
+
"an inline <Suspense>, or a loader(Def, { ssr: false }) or top-level push (e.g. Meta) " +
|
|
412
|
+
"slower than ppr.captureTimeout. The boundary belongs to the entry or component " +
|
|
413
|
+
"that owns the data.\n") +
|
|
414
|
+
(pendingStacks && pendingStacks.length > 0
|
|
415
|
+
? "Components still pending when the capture froze the shell (one of them " +
|
|
416
|
+
"suspended above <body>):\n" +
|
|
417
|
+
pendingStacks.map(formatPendingStack).join("\n") +
|
|
418
|
+
"\n"
|
|
419
|
+
: "") +
|
|
420
|
+
PPR_LANE_HINT,
|
|
394
421
|
);
|
|
395
422
|
}
|
|
396
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Why the latest no-shell attempt per key ran out of ppr.captureTimeout (the
|
|
426
|
+
* match, the handler output, or the prerender after them). Set only for a
|
|
427
|
+
* deadline outcome, so runShellCapture also reads it to skip the in-place
|
|
428
|
+
* retry. Printed (and cleared) by warnNullCaptureOnce.
|
|
429
|
+
*/
|
|
430
|
+
const noShellCauses = new Map<string, string>();
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Dev only: component stacks of the tasks still pending at the latest
|
|
434
|
+
* no-shell attempt's abort, per key, printed (and cleared) by
|
|
435
|
+
* warnNullCaptureOnce. React reports them through prerender's onError
|
|
436
|
+
* (captureShellHTML's onAbortedTask).
|
|
437
|
+
*/
|
|
438
|
+
const noShellPendingStacks = new Map<string, string[]>();
|
|
439
|
+
|
|
440
|
+
/** Stacks kept per attempt: the root pin is almost always among the first. */
|
|
441
|
+
const MAX_PENDING_STACKS = 3;
|
|
442
|
+
|
|
443
|
+
/** The first lines of one component stack, indented under the warning. */
|
|
444
|
+
function formatPendingStack(stack: string): string {
|
|
445
|
+
return stack
|
|
446
|
+
.split("\n")
|
|
447
|
+
.map((line) => line.trim())
|
|
448
|
+
.filter(Boolean)
|
|
449
|
+
.slice(0, 8)
|
|
450
|
+
.map((line) => ` ${line}`)
|
|
451
|
+
.join("\n");
|
|
452
|
+
}
|
|
453
|
+
|
|
397
454
|
/** Keys already warned about a deterministic capture refusal (once per key). */
|
|
398
455
|
const warnedRefusedCaptures = new Set<string>();
|
|
399
456
|
|
|
400
457
|
/**
|
|
401
458
|
* Warn once per key that the capture was REFUSED for a deterministic reason
|
|
402
|
-
* (identity-guard trip
|
|
403
|
-
* warnNullCaptureOnce: these are not cold-start
|
|
404
|
-
* and the message carries the concrete cause
|
|
459
|
+
* (identity-guard trip, a rejected or signal-settled bake-lane loader, a store
|
|
460
|
+
* refusal). Distinct from warnNullCaptureOnce: these are not cold-start
|
|
461
|
+
* shapes, the retry is skipped, and the message carries the concrete cause
|
|
462
|
+
* instead of a differential. Lane-dependent causes pass PPR_LANE_HINT as the
|
|
463
|
+
* trailing pointer; the rest keep the plain skill pointer.
|
|
405
464
|
*/
|
|
406
|
-
function warnCaptureRefusedOnce(
|
|
465
|
+
function warnCaptureRefusedOnce(
|
|
466
|
+
key: string,
|
|
467
|
+
reason: string,
|
|
468
|
+
pointer: string = "See the /ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md).",
|
|
469
|
+
): void {
|
|
407
470
|
if (warnedRefusedCaptures.has(key)) return;
|
|
408
471
|
warnedRefusedCaptures.add(key);
|
|
409
472
|
console.warn(
|
|
410
473
|
`[rango] Shell capture for "${key}" was refused: ${reason}\n` +
|
|
411
474
|
"The route stays on MISS (axis 1) — the page keeps working, only the shell " +
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
475
|
+
`cache is off. ${pointer}`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Refuse the capture when a capture guard tripped: a request-scoped read
|
|
481
|
+
* (cookies(), headers(), a { cache: false } variable) or ctx.dynamic() made
|
|
482
|
+
* anywhere the capture waited for — a handler, a promise it passes or pushes,
|
|
483
|
+
* an async server component, a bake-lane loader. The read throws where it
|
|
484
|
+
* happens, but the code can catch that throw, so the flag the guard sets on
|
|
485
|
+
* the capture context is what decides (server/context.ts
|
|
486
|
+
* assertNotInsideShellCapture). Returns true when it refused (and warned).
|
|
487
|
+
*/
|
|
488
|
+
function refuseOnCaptureGuard(
|
|
489
|
+
key: string,
|
|
490
|
+
derivedCtx: RequestContext<any>,
|
|
491
|
+
): boolean {
|
|
492
|
+
if (derivedCtx._dynamic) {
|
|
493
|
+
// A build-time capture reports dynamic() as its own outcome
|
|
494
|
+
// (build-shell-capture.ts): the URL keeps runtime capture.
|
|
495
|
+
if (derivedCtx.build) return true;
|
|
496
|
+
warnCaptureRefusedOnce(
|
|
497
|
+
key,
|
|
498
|
+
"ctx.dynamic() was called while capturing the shared shell (in a handler, or " +
|
|
499
|
+
"in a promise or async component the capture waited for). A dynamic render " +
|
|
500
|
+
"has no shell; the route keeps serving axis 1 for requests that opt out.",
|
|
501
|
+
);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
const trip = derivedCtx._shellCaptureGuardTripped;
|
|
505
|
+
if (!trip) return false;
|
|
506
|
+
// Name the recorded source instead of hardcoding a lane: a trip can come
|
|
507
|
+
// from a bake-lane SEGMENT loader or from handler/render code, including a
|
|
508
|
+
// loader a handler awaits (issue #672). The guard recorded the read and its
|
|
509
|
+
// fix (server/context.ts tripShellCaptureGuard): cookies()/headers(), a
|
|
510
|
+
// { cache: false } variable, and the theme reads (#971).
|
|
511
|
+
const loaderId = derivedCtx._shellCaptureGuardTrippedLoaderId;
|
|
512
|
+
const origin = loaderId
|
|
513
|
+
? `the loader "${loaderId}"`
|
|
514
|
+
: "handler/render code (no loader body was executing)";
|
|
515
|
+
warnCaptureRefusedOnce(
|
|
516
|
+
key,
|
|
517
|
+
`${origin} read ${trip.surface} during capture; request-scoped data must not ` +
|
|
518
|
+
`bake into the shared shell. ${trip.fix}`,
|
|
519
|
+
PPR_LANE_HINT,
|
|
415
520
|
);
|
|
521
|
+
return true;
|
|
416
522
|
}
|
|
417
523
|
|
|
524
|
+
/** The no-shell cause for handler output that missed the capture deadline. */
|
|
525
|
+
const HANDLER_OUTPUT_TIMEOUT_REASON: string =
|
|
526
|
+
"the handler output (a promise it passes or pushes, or an async server " +
|
|
527
|
+
"component) did not settle within ppr.captureTimeout";
|
|
528
|
+
|
|
529
|
+
/** The refusal for a capture that stored no doc record (a HIT needs it). */
|
|
530
|
+
const NO_DOC_RECORD_REASON: string =
|
|
531
|
+
"the capture produced no doc segment record, so a HIT could not replay the " +
|
|
532
|
+
"handler layer. Either the route's cache() refused the write (cache(false), or " +
|
|
533
|
+
"a condition() that returned false for the capture), or rendering the handler " +
|
|
534
|
+
"output failed (see the cache-write error reported for this key).";
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Default limit (bytes) on a whole stored shell entry — prelude, postponed
|
|
538
|
+
* state, and snapshot — when the store declares none
|
|
539
|
+
* (SegmentCacheStore.maxShellEntryBytes). Cloudflare KV's 25 MiB value limit:
|
|
540
|
+
* the tightest common store tier a shell entry lands in.
|
|
541
|
+
*/
|
|
542
|
+
export const DEFAULT_SHELL_ENTRY_MAX_BYTES: number = 25 * 1024 * 1024;
|
|
543
|
+
|
|
418
544
|
/**
|
|
419
545
|
* Default cap (serialized UTF-8 bytes) on the capture data snapshot riding
|
|
420
546
|
* inside a shell entry, when the route's `ppr` option does not set
|
|
@@ -424,8 +550,9 @@ function warnCaptureRefusedOnce(key: string, reason: string): void {
|
|
|
424
550
|
* while still fitting any sane pinned-ring payload. Applied ONLY in
|
|
425
551
|
* captureAndStoreShell (the single defaulting site — resolvePprConfig passes
|
|
426
552
|
* the option through undefaulted), so every producer and direct caller gets
|
|
427
|
-
* the same policy.
|
|
428
|
-
*
|
|
553
|
+
* the same policy. The doc record is exempt; over the cap the other records
|
|
554
|
+
* are dropped (shell still stored and replayed; the pins' loaders read the
|
|
555
|
+
* live store — see PartialPrerenderProps.maxSnapshotBytes).
|
|
429
556
|
*/
|
|
430
557
|
export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
|
|
431
558
|
|
|
@@ -436,12 +563,13 @@ const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
|
|
|
436
563
|
const warnedOverCapSnapshots = new Set<string>();
|
|
437
564
|
|
|
438
565
|
/**
|
|
439
|
-
* Warn once per key that the capture
|
|
440
|
-
* `maxSnapshotBytes` cap and
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
* repairs it client-side
|
|
444
|
-
* page recaptures on every TTL roll and would otherwise
|
|
566
|
+
* Warn once per key that the capture's loader pins exceeded the route's
|
|
567
|
+
* `maxSnapshotBytes` cap and were dropped. The shell entry is still stored
|
|
568
|
+
* with its doc record and every HIT replays the handler layer; only the
|
|
569
|
+
* pinned loaders lose their captured values, so their cached data can drift
|
|
570
|
+
* from the frozen prelude and hydration repairs it client-side. Once per
|
|
571
|
+
* key: the same page recaptures on every TTL roll and would otherwise
|
|
572
|
+
* re-warn forever.
|
|
445
573
|
*/
|
|
446
574
|
function warnSnapshotOverCapOnce(
|
|
447
575
|
key: string,
|
|
@@ -451,14 +579,15 @@ function warnSnapshotOverCapOnce(
|
|
|
451
579
|
if (warnedOverCapSnapshots.has(key)) return;
|
|
452
580
|
warnedOverCapSnapshots.add(key);
|
|
453
581
|
console.warn(
|
|
454
|
-
`[rango] Shell capture for "${key}" recorded
|
|
455
|
-
`
|
|
456
|
-
"the shell was stored
|
|
457
|
-
"
|
|
458
|
-
"the shell's TTL, hydration repairs the
|
|
459
|
-
"cap via the route's ppr option
|
|
460
|
-
"still fits your store's value limit
|
|
461
|
-
"or shrink the cache()'d data the
|
|
582
|
+
`[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
|
|
583
|
+
`loader pins, over the ${capBytes}-byte cap — the pins were dropped and ` +
|
|
584
|
+
"the shell was stored with its doc record only. HITs still replay the " +
|
|
585
|
+
"handler layer, but ssr: false loaders now read the live store: if " +
|
|
586
|
+
"their data drifts before the shell's TTL, hydration repairs the " +
|
|
587
|
+
"mismatch client-side. Raise the cap via the route's ppr option " +
|
|
588
|
+
"({ maxSnapshotBytes }) if the entry still fits your store's value limit " +
|
|
589
|
+
"(Cloudflare KV: 25 MiB per value), or shrink the cache()'d data the " +
|
|
590
|
+
"loaders read.",
|
|
462
591
|
);
|
|
463
592
|
}
|
|
464
593
|
|
|
@@ -479,8 +608,8 @@ const warnedBakeCosts = new Set<string>();
|
|
|
479
608
|
|
|
480
609
|
/**
|
|
481
610
|
* Dev-only, once per key: name the slowest bake source and the three remedies
|
|
482
|
-
* in preference order.
|
|
483
|
-
*
|
|
611
|
+
* in preference order. Handler output and bake-lane values are shell material
|
|
612
|
+
* by design, so this is a cost report with an exit, not a deprecation.
|
|
484
613
|
*/
|
|
485
614
|
function warnBakeCostOnce(key: string, source: string, ms: number): void {
|
|
486
615
|
if (warnedBakeCosts.has(key)) return;
|
|
@@ -489,13 +618,12 @@ function warnBakeCostOnce(key: string, source: string, ms: number): void {
|
|
|
489
618
|
`[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
|
|
490
619
|
"settle before the shell could freeze. This cost recurs on every capture " +
|
|
491
620
|
"of the route and occupies the per-isolate capture queue; the served " +
|
|
492
|
-
"response never shows it.
|
|
493
|
-
"
|
|
494
|
-
"
|
|
495
|
-
"
|
|
496
|
-
"
|
|
497
|
-
|
|
498
|
-
"/ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md).",
|
|
621
|
+
"response never shows it. To keep it baked but cheap, wrap the work in " +
|
|
622
|
+
'cache() or "use cache"; to make it per-request, load it in a live ' +
|
|
623
|
+
"loader (no ssr: false) read under loading() or an inline <Suspense>; for " +
|
|
624
|
+
"a loader(Def, { ssr: false }), return the slow part as a nested promise " +
|
|
625
|
+
"or drop ssr: false. " +
|
|
626
|
+
PPR_LANE_HINT,
|
|
499
627
|
);
|
|
500
628
|
}
|
|
501
629
|
|
|
@@ -511,10 +639,13 @@ export interface ShellCaptureDebugEvent {
|
|
|
511
639
|
key: string;
|
|
512
640
|
/**
|
|
513
641
|
* What happened:
|
|
514
|
-
* - stored / redirect / no-shell / refused: one capture
|
|
515
|
-
* (see CaptureAttemptOutcome for the semantics of each)
|
|
642
|
+
* - stored / redirect / no-shell / refused / expired: one capture
|
|
643
|
+
* ATTEMPT's outcome (see CaptureAttemptOutcome for the semantics of each)
|
|
516
644
|
* - error: the capture task failed with a genuine error (also routed through
|
|
517
645
|
* reportCacheError; the key is backed off)
|
|
646
|
+
* - skip-stored: a capture for the key stored after the scheduling request
|
|
647
|
+
* read the store and missed (ShellCaptureDescriptor.storedSeqAtRead), so
|
|
648
|
+
* the shell that request missed exists now
|
|
518
649
|
* - skip-in-flight: scheduleShellCapture found a capture already running for
|
|
519
650
|
* the key (stampede guard) and scheduled nothing
|
|
520
651
|
* - skip-backoff: the key is inside its refused-capture backoff window and
|
|
@@ -537,8 +668,10 @@ export interface ShellCaptureDebugEvent {
|
|
|
537
668
|
| "redirect"
|
|
538
669
|
| "no-shell"
|
|
539
670
|
| "refused"
|
|
671
|
+
| "expired"
|
|
540
672
|
| "error"
|
|
541
673
|
| "skip-in-flight"
|
|
674
|
+
| "skip-stored"
|
|
542
675
|
| "skip-backoff"
|
|
543
676
|
| "skip-capacity"
|
|
544
677
|
| "skip-inert-store"
|
|
@@ -590,6 +723,14 @@ export interface ShellCaptureDebugEvent {
|
|
|
590
723
|
* it attributable. Absent when nothing held the gate.
|
|
591
724
|
*/
|
|
592
725
|
bakeWaitMs?: number;
|
|
726
|
+
/**
|
|
727
|
+
* How long the capture waited for its doc record: the handler layer's
|
|
728
|
+
* promises, async server components and handle pushes settling, then the
|
|
729
|
+
* record's encode (settleCaptureRecord). Part of the one capture deadline.
|
|
730
|
+
*/
|
|
731
|
+
recordSettleMs?: number;
|
|
732
|
+
/** Bytes of the stored entry: prelude, postponed state, and snapshot. */
|
|
733
|
+
entryBytes?: number;
|
|
593
734
|
}
|
|
594
735
|
|
|
595
736
|
/**
|
|
@@ -648,6 +789,12 @@ export function describeShellCaptureEvent(
|
|
|
648
789
|
if (event.bakeWaitMs !== undefined) {
|
|
649
790
|
parts.push(`bake=${event.bakeWaitMs}ms`);
|
|
650
791
|
}
|
|
792
|
+
if (event.recordSettleMs !== undefined) {
|
|
793
|
+
parts.push(`record=${event.recordSettleMs}ms`);
|
|
794
|
+
}
|
|
795
|
+
if (event.entryBytes !== undefined) {
|
|
796
|
+
parts.push(`entry=${event.entryBytes}b`);
|
|
797
|
+
}
|
|
651
798
|
return parts.join(" ");
|
|
652
799
|
}
|
|
653
800
|
|
|
@@ -683,6 +830,7 @@ const TIMING_RECORDED_OUTCOMES = new Set<ShellCaptureDebugEvent["outcome"]>([
|
|
|
683
830
|
"redirect",
|
|
684
831
|
"no-shell",
|
|
685
832
|
"refused",
|
|
833
|
+
"expired",
|
|
686
834
|
"error",
|
|
687
835
|
]);
|
|
688
836
|
|
|
@@ -976,6 +1124,33 @@ export interface ShellCaptureDescriptor {
|
|
|
976
1124
|
debugSink?: (event: ShellCaptureDebugEvent) => void;
|
|
977
1125
|
/** Store the snapshot for navigation replay, never the captured HTML prelude. */
|
|
978
1126
|
navigationOnly?: true;
|
|
1127
|
+
/**
|
|
1128
|
+
* lastStoredCaptureSeq(key) when a document request read the store and
|
|
1129
|
+
* missed. The request schedules its capture only after rendering the MISS,
|
|
1130
|
+
* so a capture another request started can store in between; a later
|
|
1131
|
+
* sequence number then skips the redundant one (`skip-stored`), which would
|
|
1132
|
+
* re-render the page and drop the isolate's memo of the shell just stored.
|
|
1133
|
+
*/
|
|
1134
|
+
storedSeqAtRead?: number;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Per key, the sequence number of its latest stored capture (bounded). */
|
|
1138
|
+
const storedCaptureSeqs = new Map<string, number>();
|
|
1139
|
+
const STORED_CAPTURE_SEQS_MAX = 1_000;
|
|
1140
|
+
let storedCaptureSeq = 0;
|
|
1141
|
+
|
|
1142
|
+
/** The latest stored capture's sequence number for `key` in this isolate, or 0. */
|
|
1143
|
+
export function lastStoredCaptureSeq(key: string): number {
|
|
1144
|
+
return storedCaptureSeqs.get(key) ?? 0;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function noteCaptureStored(key: string): void {
|
|
1148
|
+
storedCaptureSeqs.delete(key);
|
|
1149
|
+
if (storedCaptureSeqs.size >= STORED_CAPTURE_SEQS_MAX) {
|
|
1150
|
+
const oldest = storedCaptureSeqs.keys().next().value;
|
|
1151
|
+
if (oldest !== undefined) storedCaptureSeqs.delete(oldest);
|
|
1152
|
+
}
|
|
1153
|
+
storedCaptureSeqs.set(key, ++storedCaptureSeq);
|
|
979
1154
|
}
|
|
980
1155
|
|
|
981
1156
|
/**
|
|
@@ -1013,6 +1188,13 @@ export function scheduleShellCapture(
|
|
|
1013
1188
|
// new token, and the stale task's token-guarded release can no longer
|
|
1014
1189
|
// touch it.
|
|
1015
1190
|
}
|
|
1191
|
+
if (
|
|
1192
|
+
descriptor.storedSeqAtRead !== undefined &&
|
|
1193
|
+
lastStoredCaptureSeq(key) > descriptor.storedSeqAtRead
|
|
1194
|
+
) {
|
|
1195
|
+
publishCaptureDebugEvent(descriptor, { key, outcome: "skip-stored" });
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1016
1198
|
// Refused/failed within the window → skip the doomed re-render (one probe per
|
|
1017
1199
|
// key per window per isolate). Expired entries self-evict inside the check.
|
|
1018
1200
|
if (isCaptureBackedOff(key)) {
|
|
@@ -1078,9 +1260,12 @@ export function scheduleShellCapture(
|
|
|
1078
1260
|
// Update the negative cache off the terminal outcome. A stored shell clears
|
|
1079
1261
|
// any prior backoff; a `no-shell` (after the in-place retry) backs the key
|
|
1080
1262
|
// off so the next requests don't re-probe it. A `redirect` has no shell but
|
|
1081
|
-
// is not a doomed render
|
|
1082
|
-
|
|
1083
|
-
|
|
1263
|
+
// is not a doomed render, and an `expired` retry lost a second record to
|
|
1264
|
+
// the same race — leave the backoff untouched.
|
|
1265
|
+
if (outcome === "stored") {
|
|
1266
|
+
clearCaptureBackoff(key);
|
|
1267
|
+
noteCaptureStored(key);
|
|
1268
|
+
} else if (outcome === "no-shell") {
|
|
1084
1269
|
markCaptureBackoff(key);
|
|
1085
1270
|
publishCaptureDebugEvent(descriptor, {
|
|
1086
1271
|
key,
|
|
@@ -1193,6 +1378,91 @@ export function scheduleShellCapture(
|
|
|
1193
1378
|
runBackground(reqCtx, serializedTask);
|
|
1194
1379
|
}
|
|
1195
1380
|
|
|
1381
|
+
/**
|
|
1382
|
+
* The ttl/swr a shell is stored with: the route's ppr policy, capped by the
|
|
1383
|
+
* route cache() records the capture replayed or wrote
|
|
1384
|
+
* (RequestContext._routeRecordWindow). A HIT replays the capture's handler
|
|
1385
|
+
* output, while a client navigation reads the route's cache() tier first, so
|
|
1386
|
+
* a shell that outlived its record would show a document request other
|
|
1387
|
+
* handler output than a navigation of the same URL. Freshness and total
|
|
1388
|
+
* lifetime are capped separately, like the record's own SWR: the shell is
|
|
1389
|
+
* fresh while the record is (at most `ttl`), and servable while the record
|
|
1390
|
+
* is (at most `ttl + swr`), so a record already inside its swr window yields
|
|
1391
|
+
* a shell that is stale from the start and serves while it recaptures.
|
|
1392
|
+
*
|
|
1393
|
+
* Whole seconds, rounded up (the SegmentCacheStore.putShell contract), so the
|
|
1394
|
+
* shell may outlast the record by under a second. Null when the record's
|
|
1395
|
+
* total lifetime has run out: the shell would be dead on arrival.
|
|
1396
|
+
*/
|
|
1397
|
+
export function capShellWindow(
|
|
1398
|
+
ttl: number,
|
|
1399
|
+
swr: number,
|
|
1400
|
+
record: Pick<RouteRecordWindow, "freshUntil" | "staleUntil">,
|
|
1401
|
+
now: number,
|
|
1402
|
+
): { ttl: number; swr: number } | null {
|
|
1403
|
+
const total = Math.min(ttl + swr, (record.staleUntil - now) / 1000);
|
|
1404
|
+
if (!(total > 0)) return null;
|
|
1405
|
+
// At most `total`: freshUntil <= staleUntil and swr >= 0.
|
|
1406
|
+
const fresh = Math.max(0, Math.min(ttl, (record.freshUntil - now) / 1000));
|
|
1407
|
+
const wholeTtl = Math.ceil(fresh);
|
|
1408
|
+
return { ttl: wholeTtl, swr: Math.ceil(total) - wholeTtl };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Routes already warned that their cache() entry expires before a capture. */
|
|
1412
|
+
const warnedExpiredRecordRoutes = new Set<string>();
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Warn once per route (dev and production) that no shell was stored because
|
|
1416
|
+
* the route cache() entry ran out before the capture could store one
|
|
1417
|
+
* (capShellWindow returned null) where a retry cannot help: the capture
|
|
1418
|
+
* wrote the entry itself (`written`), or the in-place retry's entry ran out
|
|
1419
|
+
* too, or no budget was left to retry. The key backs off like a refused
|
|
1420
|
+
* capture.
|
|
1421
|
+
*/
|
|
1422
|
+
function warnRecordRanOutOnce(
|
|
1423
|
+
route: string,
|
|
1424
|
+
record: RouteRecordWindow,
|
|
1425
|
+
detail: string,
|
|
1426
|
+
): void {
|
|
1427
|
+
if (warnedExpiredRecordRoutes.has(route)) return;
|
|
1428
|
+
warnedExpiredRecordRoutes.add(route);
|
|
1429
|
+
console.warn(
|
|
1430
|
+
`[rango] Route "${route}": its cache() entry (ttl ${record.ttl}, swr ` +
|
|
1431
|
+
`${record.swr}) ran out before the shell capture could store a shell ` +
|
|
1432
|
+
`(${detail}), so no shell is stored and the route renders without ` +
|
|
1433
|
+
"one. A shell never outlives the route cache() entry it replays: give " +
|
|
1434
|
+
"that cache() a ttl or swr longer than the capture takes, queue wait " +
|
|
1435
|
+
"included.",
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* The record an `expired` attempt ran out on, per key, for the warning
|
|
1441
|
+
* runShellCapture prints when that outcome is terminal. Set and consumed
|
|
1442
|
+
* within one capture task.
|
|
1443
|
+
*/
|
|
1444
|
+
const expiredRecords = new Map<
|
|
1445
|
+
string,
|
|
1446
|
+
{ route: string; record: RouteRecordWindow; captureMs: number }
|
|
1447
|
+
>();
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* A terminal `expired`: warn once per route with the record it ran out on,
|
|
1451
|
+
* and return `no-shell` so the caller backs the key off.
|
|
1452
|
+
*/
|
|
1453
|
+
function expiredTerminal(key: string, reason: string): "no-shell" {
|
|
1454
|
+
const expired = expiredRecords.get(key);
|
|
1455
|
+
expiredRecords.delete(key);
|
|
1456
|
+
if (expired) {
|
|
1457
|
+
warnRecordRanOutOnce(
|
|
1458
|
+
expired.route,
|
|
1459
|
+
expired.record,
|
|
1460
|
+
`${reason}; the last attempt took ${expired.captureMs} ms`,
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
return "no-shell";
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1196
1466
|
/**
|
|
1197
1467
|
* The outcome of one capture attempt.
|
|
1198
1468
|
* - `stored`: a usable shell was captured (and a putShell was attempted; a store
|
|
@@ -1200,10 +1470,21 @@ export function scheduleShellCapture(
|
|
|
1200
1470
|
* the capture itself worked).
|
|
1201
1471
|
* - `redirect`: the matched route redirects, so there is no shell to capture.
|
|
1202
1472
|
* - `no-shell`: captureShellHTML returned null because the prelude was unusable
|
|
1203
|
-
* or its private capture abort landed before the shell completed
|
|
1204
|
-
*
|
|
1473
|
+
* or its private capture abort landed before the shell completed, or the
|
|
1474
|
+
* capture ran out of ppr.captureTimeout. Retried once in place, when no
|
|
1475
|
+
* deadline ran out (noShellCauses).
|
|
1476
|
+
* - `expired`: the route cache() record the capture read (not wrote) ran out
|
|
1477
|
+
* before the store (capShellWindow null): it was near its end (its age,
|
|
1478
|
+
* queue wait). Retried once in place, where a fresh match reads or renders
|
|
1479
|
+
* a newer record; a second `expired`, or no hard-cap budget left for the
|
|
1480
|
+
* retry, is terminal `no-shell` (backoff, warned once per route).
|
|
1205
1481
|
*/
|
|
1206
|
-
type CaptureAttemptOutcome =
|
|
1482
|
+
type CaptureAttemptOutcome =
|
|
1483
|
+
| "stored"
|
|
1484
|
+
| "redirect"
|
|
1485
|
+
| "no-shell"
|
|
1486
|
+
| "refused"
|
|
1487
|
+
| "expired";
|
|
1207
1488
|
|
|
1208
1489
|
function createNavigationCaptureRequest(request: Request, url: URL): Request {
|
|
1209
1490
|
const headers = new Headers(request.headers);
|
|
@@ -1238,6 +1519,8 @@ type CaptureAttemptStats = Pick<
|
|
|
1238
1519
|
| "untaggedBake"
|
|
1239
1520
|
| "storeWrite"
|
|
1240
1521
|
| "bakeWaitMs"
|
|
1522
|
+
| "recordSettleMs"
|
|
1523
|
+
| "entryBytes"
|
|
1241
1524
|
>;
|
|
1242
1525
|
|
|
1243
1526
|
/**
|
|
@@ -1251,8 +1534,9 @@ type CaptureAttemptStats = Pick<
|
|
|
1251
1534
|
* when we froze the shell; the attempt itself warmed the module graph, so a second
|
|
1252
1535
|
* attempt a short beat later usually completes the shell in the SAME background
|
|
1253
1536
|
* task. That kills the old multi-request warmup where the caller had to re-issue
|
|
1254
|
-
* several HTTP requests before a capture stuck. We retry ONLY on `no-shell
|
|
1255
|
-
*
|
|
1537
|
+
* several HTTP requests before a capture stuck. We retry ONLY on `no-shell`
|
|
1538
|
+
* that did not run out of ppr.captureTimeout (noShellCauses); a genuine render
|
|
1539
|
+
* error is NOT retried — it propagates to scheduleShellCapture's
|
|
1256
1540
|
* reportCacheError. See docs/design/ppr-shell-resume.md.
|
|
1257
1541
|
*
|
|
1258
1542
|
* `retryDelayMs` is a parameter (defaulting to the module const) so unit tests can
|
|
@@ -1306,49 +1590,257 @@ async function runShellCapture(
|
|
|
1306
1590
|
return outcome;
|
|
1307
1591
|
};
|
|
1308
1592
|
|
|
1593
|
+
const taskStartedAt = performance.now();
|
|
1309
1594
|
const first = await timedAttempt(1);
|
|
1310
1595
|
// "refused" is deterministic (identity guard / rejected bake-lane loader —
|
|
1311
1596
|
// its own warning already fired): no retry, and the caller backs the key off
|
|
1312
1597
|
// exactly like a structural no-shell.
|
|
1313
1598
|
if (first === "refused") return "no-shell";
|
|
1314
|
-
|
|
1315
|
-
if (first
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1599
|
+
let second: CaptureAttemptOutcome;
|
|
1600
|
+
if (first === "expired") {
|
|
1601
|
+
// The record attempt 1 read ran out mid-capture: a fresh match reads or
|
|
1602
|
+
// renders a newer one. The whole task races SHELL_CAPTURE_TASK_HARD_CAP_MS,
|
|
1603
|
+
// so a retry that could not finish inside it (barrier + captureTimeout)
|
|
1604
|
+
// is not started.
|
|
1605
|
+
const retryBudgetMs =
|
|
1606
|
+
SHELL_CAPTURE_TASK_HARD_CAP_MS - (performance.now() - taskStartedAt);
|
|
1607
|
+
const retryNeedsMs =
|
|
1608
|
+
SHELL_CAPTURE_WRITE_BARRIER_MS +
|
|
1609
|
+
(descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
|
|
1610
|
+
if (retryBudgetMs < retryNeedsMs) {
|
|
1611
|
+
return expiredTerminal(descriptor.key, "no time was left to retry");
|
|
1612
|
+
}
|
|
1613
|
+
log(
|
|
1614
|
+
`[ShellCache] capture attempt 1/2 for ${descriptor.key} outlived the route cache() record it read — retrying`,
|
|
1615
|
+
);
|
|
1616
|
+
second = await timedAttempt(2);
|
|
1617
|
+
if (second === "expired") {
|
|
1618
|
+
return expiredTerminal(descriptor.key, "twice in a row");
|
|
1619
|
+
}
|
|
1620
|
+
expiredRecords.delete(descriptor.key);
|
|
1621
|
+
} else {
|
|
1622
|
+
// "stored" (success) or "redirect" (no shell exists): nothing to retry.
|
|
1623
|
+
if (first !== "no-shell") return first;
|
|
1624
|
+
// Attempt 1 ran out of ppr.captureTimeout (a cause is recorded): its
|
|
1625
|
+
// handlers may still be running (a match is not cancellable), and a retry
|
|
1626
|
+
// would start the same work beside them. A cold-module abort, the retry's
|
|
1627
|
+
// reason, ends well inside the deadline.
|
|
1628
|
+
if (noShellCauses.has(descriptor.key)) {
|
|
1629
|
+
warnNullCaptureOnce(descriptor.key, false);
|
|
1630
|
+
return "no-shell";
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// Attempt 1 produced no usable shell. Retry ONCE in place — the first
|
|
1634
|
+
// attempt warmed the dev transform graph / cold worker, so attempt 2
|
|
1635
|
+
// typically completes the shell without another HTTP request. The concise
|
|
1636
|
+
// line is gated on the middleware's debug flag (threaded via the
|
|
1637
|
+
// descriptor) so it replaces the old full DOMException dump with one
|
|
1638
|
+
// readable breadcrumb.
|
|
1639
|
+
log(
|
|
1640
|
+
`[ShellCache] capture attempt 1/2 for ${descriptor.key} aborted before shell completed (cold modules?) — retrying`,
|
|
1641
|
+
);
|
|
1642
|
+
await delay(retryDelayMs);
|
|
1643
|
+
second = await timedAttempt(2);
|
|
1644
|
+
}
|
|
1327
1645
|
if (second === "refused") return "no-shell";
|
|
1328
1646
|
if (second !== "no-shell") return second;
|
|
1329
1647
|
|
|
1330
1648
|
// Both attempts came back with no usable shell. Cold-start would have healed by
|
|
1331
|
-
// now, so the eternal-MISS structural shape (a loader
|
|
1332
|
-
//
|
|
1649
|
+
// now, so the eternal-MISS structural shape (a boundary-less live-loader read;
|
|
1650
|
+
// lane rule: see resolveLoaderData, loader-cache.ts) is the likely cause —
|
|
1651
|
+
// warn once per key. Ordering matters: because the retry
|
|
1333
1652
|
// absorbs cold-start, cold-start routes almost never reach this warning. The
|
|
1334
1653
|
// caller (scheduleShellCapture) reads this `no-shell` return to back the key off.
|
|
1335
1654
|
log(
|
|
1336
1655
|
`[ShellCache] capture attempt 2/2 for ${descriptor.key} aborted — giving up until next request`,
|
|
1337
1656
|
);
|
|
1338
|
-
warnNullCaptureOnce(descriptor.key);
|
|
1657
|
+
warnNullCaptureOnce(descriptor.key, true);
|
|
1339
1658
|
return "no-shell";
|
|
1340
1659
|
}
|
|
1341
1660
|
|
|
1342
|
-
/**
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
)
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1661
|
+
/**
|
|
1662
|
+
* Who reads the snapshot this capture stores besides its doc record
|
|
1663
|
+
* (docs/design/shell-entry-layout.md §2): partial replay alone for a
|
|
1664
|
+
* navigation-only entry ("segments"), the loaders a document HIT re-runs
|
|
1665
|
+
* otherwise ("loaders"). Undefined when the capture recorded no doc record,
|
|
1666
|
+
* which only a prerender-served capture stores (its HIT tail takes the handler
|
|
1667
|
+
* layer from the prerender store and reads nothing else from the snapshot
|
|
1668
|
+
* beyond the loader records, so nothing is pruned).
|
|
1669
|
+
*/
|
|
1670
|
+
function snapshotReaders(
|
|
1671
|
+
capture: ShellCaptureDescriptor,
|
|
1672
|
+
snapshot: readonly ShellSnapshotRecord[],
|
|
1673
|
+
docKey: string | undefined,
|
|
1674
|
+
): ShellSnapshotReaders | undefined {
|
|
1675
|
+
if (!hasDocRecord(snapshot, docKey)) return undefined;
|
|
1676
|
+
return capture.navigationOnly ? "segments" : "loaders";
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* What the record-first step produced ({@link settleCaptureRecord}):
|
|
1681
|
+
* - `record`: the doc record settled; `match` carries its fragments in place
|
|
1682
|
+
* of the handler layer's elements, so the capture's Flight payload is the
|
|
1683
|
+
* bytes every HIT replays.
|
|
1684
|
+
* - `prerender`: the prerender store supplied the handler layer (a
|
|
1685
|
+
* Prerender route); its HIT tail replays the same build-time segments, so
|
|
1686
|
+
* the capture renders the match as is.
|
|
1687
|
+
* - `timeout`: handler output did not settle within the capture budget.
|
|
1688
|
+
* - `refused`: a deterministic refusal (a capture guard tripped, the route
|
|
1689
|
+
* opted out, or the handler output failed); the warning already fired.
|
|
1690
|
+
*/
|
|
1691
|
+
export type CaptureRecordOutcome =
|
|
1692
|
+
| { kind: "record" | "prerender"; match: MatchResult }
|
|
1693
|
+
| { kind: "timeout"; reason: string }
|
|
1694
|
+
| { kind: "refused" };
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Record-first capture: the doc record is both the settle signal and the
|
|
1698
|
+
* parity source.
|
|
1699
|
+
*
|
|
1700
|
+
* Writing the record Flight-serializes every non-loader segment
|
|
1701
|
+
* (serializeSegments), which runs each handler's async server components and
|
|
1702
|
+
* waits for every promise the handler output carries — the capture's "is the
|
|
1703
|
+
* handler layer done" signal. Live-lane loader data is not in the record
|
|
1704
|
+
* (loader segments are excluded and their masked promises never settle), so
|
|
1705
|
+
* the wait never depends on a hole.
|
|
1706
|
+
*
|
|
1707
|
+
* The capture then renders its OWN Flight payload from the record's
|
|
1708
|
+
* fragments (fragmentSegments, the function a HIT tail uses) instead of from
|
|
1709
|
+
* the elements the match produced. Server components therefore render once
|
|
1710
|
+
* per capture, inside the record's encode: before this, the capture's Flight
|
|
1711
|
+
* render and the record's encode each ran them, so an uncached async
|
|
1712
|
+
* component showed one value in the prelude and another on every HIT.
|
|
1713
|
+
*
|
|
1714
|
+
* Order, all bounded by the one capture deadline (`ppr.captureTimeout`):
|
|
1715
|
+
* top-level handle promises, the handler pushes' nested promises, and the
|
|
1716
|
+
* bake-lane loader containers settle first (the record encodes the handles
|
|
1717
|
+
* a HIT restores, and its handle encode has its own 5s timeout that must
|
|
1718
|
+
* never be the one that fires); then the capture's onResponse callbacks fire
|
|
1719
|
+
* with a synthetic 200 (the implicit doc scope's cacheRoute, an explicit
|
|
1720
|
+
* scope's own write), and the deferred writes settle.
|
|
1721
|
+
*/
|
|
1722
|
+
export async function settleCaptureRecord(
|
|
1723
|
+
match: MatchResult,
|
|
1724
|
+
derivation: CaptureContextDerivation,
|
|
1725
|
+
capture: Pick<ShellCaptureDescriptor, "key">,
|
|
1726
|
+
deadline: number,
|
|
1727
|
+
): Promise<CaptureRecordOutcome> {
|
|
1728
|
+
const { derivedCtx, freshHandleStore } = derivation;
|
|
1729
|
+
const recording = getRecordingStore(derivedCtx._cacheStore);
|
|
1730
|
+
|
|
1731
|
+
// cacheRoute waits for handleStore.settled, which needs the seal.
|
|
1732
|
+
freshHandleStore.seal();
|
|
1733
|
+
const loaderRecords = derivedCtx._shellCaptureLoaderRecords;
|
|
1734
|
+
const settled = await withinDeadline(
|
|
1735
|
+
Promise.all([
|
|
1736
|
+
freshHandleStore.getData().then(resolveDeferredHandleValues),
|
|
1737
|
+
derivation.handlerPushesSettled(),
|
|
1738
|
+
loaderRecords && loaderRecords.size > 0
|
|
1739
|
+
? Promise.allSettled([...loaderRecords.values()])
|
|
1740
|
+
: undefined,
|
|
1741
|
+
]).catch(() => undefined),
|
|
1742
|
+
deadline,
|
|
1743
|
+
);
|
|
1744
|
+
// Past the deadline the record cannot settle in time: stop before its
|
|
1745
|
+
// write starts (no encode for a capture that already failed).
|
|
1746
|
+
if (!settled.done) {
|
|
1747
|
+
if (refuseOnCaptureGuard(capture.key, derivedCtx))
|
|
1748
|
+
return { kind: "refused" };
|
|
1749
|
+
return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const callbacks = derivedCtx._onResponseCallbacks?.splice(0) ?? [];
|
|
1753
|
+
if (callbacks.length > 0) {
|
|
1754
|
+
const synthetic = new Response(null, { status: 200 });
|
|
1755
|
+
for (const cb of callbacks) {
|
|
1756
|
+
try {
|
|
1757
|
+
cb(synthetic);
|
|
1758
|
+
} catch {
|
|
1759
|
+
// A capture-time cache write that throws is degradation, not failure.
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
const drained = recording
|
|
1764
|
+
? await recording.settleWrites(Math.max(0, deadline - Date.now()))
|
|
1765
|
+
: true;
|
|
1766
|
+
|
|
1767
|
+
// Capture guards first: a tripped guard usually also fails the record,
|
|
1768
|
+
// and its own message names the cause.
|
|
1769
|
+
if (refuseOnCaptureGuard(capture.key, derivedCtx)) return { kind: "refused" };
|
|
1770
|
+
|
|
1771
|
+
const docKey = derivedCtx._shellImplicitCache?.docKey;
|
|
1772
|
+
const record =
|
|
1773
|
+
recording && docKey !== undefined
|
|
1774
|
+
? (recording.getRecord("segment", docKey) as CachedEntryData | undefined)
|
|
1775
|
+
: undefined;
|
|
1776
|
+
if (
|
|
1777
|
+
!record ||
|
|
1778
|
+
!Array.isArray(record.segments) ||
|
|
1779
|
+
record.segments.length === 0
|
|
1780
|
+
) {
|
|
1781
|
+
// Post-match serve-source truth (cache-lookup.ts tryPrerenderLookup).
|
|
1782
|
+
if (derivedCtx._pprReplayPostMatchReason === "prerender-store") {
|
|
1783
|
+
return { kind: "prerender", match };
|
|
1784
|
+
}
|
|
1785
|
+
if (!drained) {
|
|
1786
|
+
return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
|
|
1787
|
+
}
|
|
1788
|
+
warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
|
|
1789
|
+
return { kind: "refused" };
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// A handle encode that timed out stores "" (handle-snapshot.ts
|
|
1793
|
+
// encodeHandleValue). With every handler push settled above, that only
|
|
1794
|
+
// happens for a promise the walk does not reach (inside a Map, Set, or class
|
|
1795
|
+
// instance), which never settles in time on a retry either.
|
|
1796
|
+
if (!record.handles && recordSegmentsHaveHandles(record, freshHandleStore)) {
|
|
1797
|
+
warnCaptureRefusedOnce(
|
|
1798
|
+
capture.key,
|
|
1799
|
+
"a handle value the handler pushed did not finish encoding into the doc " +
|
|
1800
|
+
"record. A promise inside a Map, Set, or class instance is not awaited " +
|
|
1801
|
+
"before the encode; push plain objects and arrays.",
|
|
1802
|
+
);
|
|
1803
|
+
return { kind: "refused" };
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const { fragmentSegments } = await import("../cache/segment-codec.js");
|
|
1807
|
+
const fragments = new Map(
|
|
1808
|
+
(await fragmentSegments(record.segments)).map((s) => [s.id, s]),
|
|
1809
|
+
);
|
|
1810
|
+
return {
|
|
1811
|
+
kind: "record",
|
|
1812
|
+
match: {
|
|
1813
|
+
...match,
|
|
1814
|
+
segments: match.segments.map((segment) => {
|
|
1815
|
+
const fragment =
|
|
1816
|
+
segment.type === "loader" ? undefined : fragments.get(segment.id);
|
|
1817
|
+
return fragment
|
|
1818
|
+
? {
|
|
1819
|
+
...segment,
|
|
1820
|
+
component: fragment.component,
|
|
1821
|
+
layout: fragment.layout,
|
|
1822
|
+
loading: fragment.loading,
|
|
1823
|
+
}
|
|
1824
|
+
: segment;
|
|
1825
|
+
}),
|
|
1826
|
+
},
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/** Whether the handle store holds handler pushes for the record's segments. */
|
|
1831
|
+
function recordSegmentsHaveHandles(
|
|
1832
|
+
record: CachedEntryData,
|
|
1833
|
+
handleStore: HandleStore,
|
|
1834
|
+
): boolean {
|
|
1835
|
+
for (const segment of record.segments) {
|
|
1836
|
+
const id = segment?.metadata?.id;
|
|
1837
|
+
if (id === undefined) continue;
|
|
1838
|
+
const data = handleStore.getDataForSegment(id, true);
|
|
1839
|
+
for (const name in data) {
|
|
1840
|
+
if (data[name]!.length > 0) return true;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
return false;
|
|
1352
1844
|
}
|
|
1353
1845
|
|
|
1354
1846
|
/**
|
|
@@ -1368,6 +1860,7 @@ function handlerLayerIsLive(
|
|
|
1368
1860
|
* set a shell entry should be invalidatable by (loader tags belong to holes).
|
|
1369
1861
|
* - _transitionWhen: a fresh [] so the capture's transition gating is its own.
|
|
1370
1862
|
* - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
|
|
1863
|
+
* - _dynamic / dynamic(): an own latch the capture reads.
|
|
1371
1864
|
* - _metricsStore: undefined so the capture never appends to the foreground's
|
|
1372
1865
|
* (already-finalized) metrics.
|
|
1373
1866
|
* - _renderBarrier family: an own barrier wired to the fresh handle store
|
|
@@ -1380,6 +1873,8 @@ function handlerLayerIsLive(
|
|
|
1380
1873
|
* capture guard is load-bearing). Middleware is NOT re-run: it already ran for the
|
|
1381
1874
|
* triggering request, and the derived context inherits its post-middleware state
|
|
1382
1875
|
* (guarding is serve-time; the shell is never served without the full chain).
|
|
1876
|
+
* After the match, {@link settleCaptureRecord} writes the doc record and the
|
|
1877
|
+
* capture renders from it.
|
|
1383
1878
|
*
|
|
1384
1879
|
* A FRESH context (and match/render) per attempt is what makes the retry sound:
|
|
1385
1880
|
* the second attempt is a clean capture, not a resumption of the first.
|
|
@@ -1407,23 +1902,67 @@ async function attemptCapture(
|
|
|
1407
1902
|
await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
|
|
1408
1903
|
stats.barrierWaitMs = Math.round(performance.now() - barrierStart);
|
|
1409
1904
|
|
|
1410
|
-
const
|
|
1905
|
+
const derivation = deriveShellCaptureContext(
|
|
1411
1906
|
reqCtx,
|
|
1412
1907
|
descriptor,
|
|
1413
1908
|
descriptor.navigationOnly ? { request, url } : undefined,
|
|
1414
1909
|
);
|
|
1910
|
+
const { derivedCtx, freshHandleStore } = derivation;
|
|
1415
1911
|
const captureStartedAt = Date.now();
|
|
1416
1912
|
|
|
1417
1913
|
return runWithRequestContext(derivedCtx, async () => {
|
|
1418
|
-
|
|
1914
|
+
// One deadline for the whole capture (see
|
|
1915
|
+
// PartialPrerenderProps.captureTimeout): the match (handlers, and the
|
|
1916
|
+
// loaders they await), then the record, then the prerender with what is
|
|
1917
|
+
// left.
|
|
1918
|
+
const deadline =
|
|
1919
|
+
Date.now() + (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
|
|
1920
|
+
const matched = await withinDeadline(
|
|
1921
|
+
ctx.router.match(request, { env }),
|
|
1922
|
+
deadline,
|
|
1923
|
+
);
|
|
1924
|
+
if (!matched.done) {
|
|
1925
|
+
noShellCauses.set(
|
|
1926
|
+
descriptor.key,
|
|
1927
|
+
"the handlers (or a loader a handler awaits) did not return within " +
|
|
1928
|
+
"ppr.captureTimeout",
|
|
1929
|
+
);
|
|
1930
|
+
return "no-shell";
|
|
1931
|
+
}
|
|
1932
|
+
const match = matched.value;
|
|
1419
1933
|
// A route that redirects has no shell to capture — bail (no store write, no
|
|
1420
1934
|
// retry: a redirect is deterministic).
|
|
1421
1935
|
if (match.redirect) return "redirect";
|
|
1422
1936
|
|
|
1423
1937
|
setRequestContextParams(match.params, match.routeName);
|
|
1424
1938
|
|
|
1425
|
-
const
|
|
1939
|
+
const settleStart = performance.now();
|
|
1940
|
+
const settled = await settleCaptureRecord(
|
|
1426
1941
|
match,
|
|
1942
|
+
derivation,
|
|
1943
|
+
descriptor,
|
|
1944
|
+
deadline,
|
|
1945
|
+
);
|
|
1946
|
+
stats.recordSettleMs = Math.round(performance.now() - settleStart);
|
|
1947
|
+
if (
|
|
1948
|
+
process.env.NODE_ENV !== "production" &&
|
|
1949
|
+
settled.kind === "record" &&
|
|
1950
|
+
stats.recordSettleMs >= SHELL_CAPTURE_BAKE_WARN_MS
|
|
1951
|
+
) {
|
|
1952
|
+
warnBakeCostOnce(
|
|
1953
|
+
descriptor.key,
|
|
1954
|
+
"the handler output (promises it passes or pushes, async server components)",
|
|
1955
|
+
stats.recordSettleMs,
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
if (settled.kind === "refused") return "refused";
|
|
1959
|
+
if (settled.kind === "timeout") {
|
|
1960
|
+
noShellCauses.set(descriptor.key, settled.reason);
|
|
1961
|
+
return "no-shell";
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
const payload = buildFullPayload(
|
|
1965
|
+
settled.match,
|
|
1427
1966
|
ctx,
|
|
1428
1967
|
url,
|
|
1429
1968
|
derivedCtx,
|
|
@@ -1447,16 +1986,56 @@ async function attemptCapture(
|
|
|
1447
1986
|
// content (and tags propagated by async cache()/"use cache" reads) lands after
|
|
1448
1987
|
// this synchronous construction point, so snapshotting here dropped it — the
|
|
1449
1988
|
// shell-tag snapshot must sit behind the quiesce gate (issue #676).
|
|
1450
|
-
|
|
1989
|
+
const outcome = await captureAndStoreShell(
|
|
1451
1990
|
ssrModule,
|
|
1452
1991
|
flightStage.stream,
|
|
1453
1992
|
freshHandleStore,
|
|
1454
1993
|
derivedCtx,
|
|
1455
|
-
|
|
1994
|
+
{
|
|
1995
|
+
...descriptor,
|
|
1996
|
+
captureTimeout: Math.max(1, deadline - Date.now()),
|
|
1997
|
+
},
|
|
1456
1998
|
stats,
|
|
1457
1999
|
captureStartedAt,
|
|
1458
2000
|
);
|
|
2001
|
+
if (outcome === "no-shell" && Date.now() >= deadline) {
|
|
2002
|
+
noShellCauses.set(
|
|
2003
|
+
descriptor.key,
|
|
2004
|
+
"the shell render did not finish within what ppr.captureTimeout left " +
|
|
2005
|
+
`after the handler output settled (${stats.recordSettleMs}ms)`,
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
return outcome;
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/**
|
|
2013
|
+
* Race `work` against an absolute deadline, keeping its value. The losing
|
|
2014
|
+
* work is not cancelled (a router match has no abort signal); callers that
|
|
2015
|
+
* lose do not start it again (runShellCapture skips its in-place retry).
|
|
2016
|
+
*/
|
|
2017
|
+
export async function withinDeadline<T>(
|
|
2018
|
+
work: Promise<T>,
|
|
2019
|
+
deadline: number,
|
|
2020
|
+
): Promise<{ done: true; value: T } | { done: false }> {
|
|
2021
|
+
// The losing branch keeps running; a late rejection must not surface.
|
|
2022
|
+
work.catch(() => {});
|
|
2023
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
2024
|
+
const expired = new Promise<{ done: false }>((resolve) => {
|
|
2025
|
+
timer = setTimeout(
|
|
2026
|
+
() => resolve({ done: false }),
|
|
2027
|
+
Math.max(0, deadline - Date.now()),
|
|
2028
|
+
);
|
|
2029
|
+
(timer as { unref?: () => void }).unref?.();
|
|
1459
2030
|
});
|
|
2031
|
+
try {
|
|
2032
|
+
return await Promise.race([
|
|
2033
|
+
work.then((value) => ({ done: true as const, value })),
|
|
2034
|
+
expired,
|
|
2035
|
+
]);
|
|
2036
|
+
} finally {
|
|
2037
|
+
if (timer) clearTimeout(timer);
|
|
2038
|
+
}
|
|
1460
2039
|
}
|
|
1461
2040
|
|
|
1462
2041
|
/**
|
|
@@ -1464,13 +2043,20 @@ async function attemptCapture(
|
|
|
1464
2043
|
* shared by BOTH shell producers: the runtime background capture
|
|
1465
2044
|
* (attemptCapture, producer A) and the build-time prerender shell capture
|
|
1466
2045
|
* (prerender/build-shell-capture.ts, producer B — issue #699). One
|
|
1467
|
-
* implementation so the capture semantics — the
|
|
1468
|
-
*
|
|
1469
|
-
* scope — cannot drift between producers.
|
|
2046
|
+
* implementation so the capture semantics — the handle push funnel (handler
|
|
2047
|
+
* pushes settled, loader pushes masked), the capture guards, snapshot
|
|
2048
|
+
* recording, the implicit doc-cache scope — cannot drift between producers.
|
|
1470
2049
|
*/
|
|
1471
2050
|
export interface CaptureContextDerivation {
|
|
1472
2051
|
derivedCtx: RequestContext;
|
|
1473
2052
|
freshHandleStore: HandleStore;
|
|
2053
|
+
/**
|
|
2054
|
+
* Resolves once every handler handle push has settled at any depth of
|
|
2055
|
+
* plain objects and arrays (top-level promise, then the promises nested
|
|
2056
|
+
* in what it resolved to). The record-first step awaits it before the doc
|
|
2057
|
+
* record encodes its handles, so that encode never races a pending value.
|
|
2058
|
+
*/
|
|
2059
|
+
handlerPushesSettled: () => Promise<void>;
|
|
1474
2060
|
}
|
|
1475
2061
|
|
|
1476
2062
|
/**
|
|
@@ -1488,104 +2074,110 @@ export function deriveShellCaptureContext(
|
|
|
1488
2074
|
): CaptureContextDerivation {
|
|
1489
2075
|
const freshHandleStore = createHandleStore();
|
|
1490
2076
|
freshHandleStore.onError = reqCtx._handleStore.onError;
|
|
1491
|
-
//
|
|
1492
|
-
// (
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
//
|
|
1507
|
-
//
|
|
1508
|
-
// (
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1514
|
-
//
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
2077
|
+
// The capture handle store's push is the single funnel every push wrapper
|
|
2078
|
+
// (setupLoaderAccess, createUseFunction, prerender) goes through; the store
|
|
2079
|
+
// exists only for this capture attempt, so the foreground store is
|
|
2080
|
+
// untouched. Two lanes:
|
|
2081
|
+
// - HANDLER pushes (outside a DSL loader scope: handler bodies, handler-
|
|
2082
|
+
// invoked ctx.use(loader) bodies, defers) are handler output, baked like
|
|
2083
|
+
// everything else the handler layer produces. They pass through
|
|
2084
|
+
// unmasked; settleHandlerPush tracks each one so the record-first step
|
|
2085
|
+
// (settleCaptureRecord) can wait for its promises, top-level and nested,
|
|
2086
|
+
// before the doc record encodes the handles a HIT replays. A route
|
|
2087
|
+
// cache() record's restore (handle-snapshot.ts restoreHandles) lands
|
|
2088
|
+
// here too: a value it restores through pushReplayed keeps that loader
|
|
2089
|
+
// as owner, so a HIT that runs the loader replaces it, not appends.
|
|
2090
|
+
// - DSL-LOADER pushes. Only bake-lane { ssr: false } loaders and the
|
|
2091
|
+
// loaders they await via ctx.use execute at capture (live loaders are
|
|
2092
|
+
// masked), their pushes are in the captured HTML (<head> title/meta,
|
|
2093
|
+
// useHandle echoes), and a HIT does not run a promise-free bake-lane
|
|
2094
|
+
// loader (loader-cache.ts), so the stored record carries them: each
|
|
2095
|
+
// settled, thenable-free push from a loader body keeps that loader as
|
|
2096
|
+
// `owner` (HandleStore.push), the record stores it
|
|
2097
|
+
// (CachedEntryData.handleOwners), and the HIT restores it through
|
|
2098
|
+
// pushReplayed; a loader that does run on the HIT (a promise-carrying
|
|
2099
|
+
// bake-lane loader, a live loader awaiting it) replaces its restored
|
|
2100
|
+
// copies in place. Nested thenables stay masked (the promise shape is
|
|
2101
|
+
// the liveness declaration, mask-nested.ts). A deferred (thenable) push,
|
|
2102
|
+
// one with masked nested promises, or one outside a loader body (a
|
|
2103
|
+
// loader-cache replay names its loader: pushReplayed below) stays
|
|
2104
|
+
// tagged (HandleStore.push loaderPush) so the
|
|
2105
|
+
// handle encode cannot stall on a never-resolving mask; only the
|
|
2106
|
+
// captureHandles cache-write call site excludes tagged pushes, and the
|
|
2107
|
+
// loader records then carry `runs: 1` so a HIT runs those bodies for
|
|
2108
|
+
// them.
|
|
2109
|
+
const handlerPushSettles: Promise<void>[] = [];
|
|
2110
|
+
// A replay (a loader-cache HIT, loader-cache.ts replayLoaderHandles, or a
|
|
2111
|
+
// route cache() record's owned values, restoreHandles) re-pushes a loader's
|
|
2112
|
+
// recorded values through pushReplayed, which calls push() outside any
|
|
2113
|
+
// loader body: name that loader for the funnel so the record keeps them
|
|
2114
|
+
// under it too.
|
|
2115
|
+
let replayOwner: string | undefined;
|
|
2116
|
+
const rawPushReplayed = freshHandleStore.pushReplayed.bind(freshHandleStore);
|
|
2117
|
+
freshHandleStore.pushReplayed = (handleName, segmentId, value, loaderId) => {
|
|
2118
|
+
replayOwner = loaderId;
|
|
2119
|
+
try {
|
|
2120
|
+
rawPushReplayed(handleName, segmentId, value, loaderId);
|
|
2121
|
+
} finally {
|
|
2122
|
+
replayOwner = undefined;
|
|
2123
|
+
}
|
|
1531
2124
|
};
|
|
1532
|
-
const loaderScopedPushValues = new WeakSet<object>();
|
|
1533
2125
|
const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
|
|
1534
2126
|
freshHandleStore.push = (
|
|
1535
2127
|
handleName: string,
|
|
1536
2128
|
segmentId: string,
|
|
1537
2129
|
value: unknown,
|
|
1538
2130
|
) => {
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
2131
|
+
if (!isInsideLoaderScope()) {
|
|
2132
|
+
handlerPushSettles.push(settleNestedThenables(value));
|
|
2133
|
+
rawCapturePush(handleName, segmentId, value, false, replayOwner);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
1542
2136
|
let maskedNestedThenable = false;
|
|
1543
|
-
const
|
|
2137
|
+
const mask = (v: unknown): unknown => {
|
|
1544
2138
|
const report: MaskReport = { thenable: false };
|
|
1545
2139
|
const masked = maskNestedContainerThenables(v, undefined, report);
|
|
1546
|
-
if (report.thenable)
|
|
1547
|
-
maskedNestedThenable = true;
|
|
1548
|
-
if (!pushedInLoaderScope) handleLiveness.holes = true;
|
|
1549
|
-
}
|
|
2140
|
+
if (report.thenable) maskedNestedThenable = true;
|
|
1550
2141
|
return masked;
|
|
1551
2142
|
};
|
|
1552
2143
|
let masked: unknown;
|
|
2144
|
+
let loaderPush = true;
|
|
2145
|
+
let owner: string | undefined;
|
|
1553
2146
|
if (isThenable(value)) {
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
value.then(settle, settle);
|
|
1558
|
-
}
|
|
1559
|
-
masked = value.then(maskWithLiveness);
|
|
1560
|
-
// Deferred (thenable) loader pushes always keep the exclusion — the
|
|
1561
|
-
// bake-lane carve-out below is for settled values only.
|
|
1562
|
-
if (pushedInLoaderScope) {
|
|
1563
|
-
loaderScopedPushValues.add(masked as object);
|
|
1564
|
-
}
|
|
2147
|
+
// Deferred (thenable) loader pushes always keep the tag — the
|
|
2148
|
+
// carve-out below is for settled values only.
|
|
2149
|
+
masked = value.then(mask);
|
|
1565
2150
|
} else {
|
|
1566
|
-
masked =
|
|
1567
|
-
if (
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
//
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
const baked =
|
|
1577
|
-
bodyLoaderId !== undefined &&
|
|
1578
|
-
!maskedNestedThenable &&
|
|
1579
|
-
derivedCtx._awaitBeforeFlushLoaderIds?.has(bodyLoaderId) === true;
|
|
1580
|
-
if (!baked) {
|
|
1581
|
-
loaderScopedPushValues.add(masked);
|
|
1582
|
-
}
|
|
2151
|
+
masked = mask(value);
|
|
2152
|
+
if (!maskedNestedThenable) {
|
|
2153
|
+
// A settled, thenable-free push from a loader body is shell
|
|
2154
|
+
// material (see the funnel comment above): only bake-lane loaders
|
|
2155
|
+
// and the loaders they await run at capture, and a HIT does not run
|
|
2156
|
+
// a promise-free bake-lane loader (loader-cache.ts), so the record
|
|
2157
|
+
// keeps the push under the loader that made it.
|
|
2158
|
+
const bodyLoaderId = getCurrentLoaderBodyId() ?? replayOwner;
|
|
2159
|
+
loaderPush = bodyLoaderId === undefined;
|
|
2160
|
+
if (!loaderPush) owner = bodyLoaderId;
|
|
1583
2161
|
}
|
|
1584
2162
|
}
|
|
1585
|
-
|
|
2163
|
+
// A push the record cannot keep reaches a HIT only from a live run of
|
|
2164
|
+
// its loader, so the loader records ask for one (runs: 1).
|
|
2165
|
+
if (loaderPush) derivedCtx._shellCaptureUnrecordedLoaderPush = true;
|
|
2166
|
+
rawCapturePush(handleName, segmentId, masked, loaderPush, owner);
|
|
1586
2167
|
};
|
|
1587
2168
|
|
|
1588
|
-
|
|
2169
|
+
// The capture reuses the record keys its request resolved (CacheScope
|
|
2170
|
+
// resolveKeyFrom), read through the prototype: the map must exist on the
|
|
2171
|
+
// request before the derivation, or the capture would start its own and
|
|
2172
|
+
// run key() under the capture guard.
|
|
2173
|
+
reqCtx._resolvedCacheKeys ??= new Map();
|
|
2174
|
+
const derivedCtx: RequestContext = Object.assign(Object.create(reqCtx), {
|
|
2175
|
+
_handleStore: freshHandleStore,
|
|
2176
|
+
// The capture notes the route records it reads or writes itself; the
|
|
2177
|
+
// foreground's (a partial request's `partial:` record, say) are not the
|
|
2178
|
+
// ones this shell is captured from.
|
|
2179
|
+
_routeRecordWindow: undefined,
|
|
2180
|
+
});
|
|
1589
2181
|
if (identity) {
|
|
1590
2182
|
derivedCtx.request = identity.request;
|
|
1591
2183
|
derivedCtx.url = identity.url;
|
|
@@ -1593,7 +2185,6 @@ export function deriveShellCaptureContext(
|
|
|
1593
2185
|
derivedCtx.pathname = identity.url.pathname;
|
|
1594
2186
|
derivedCtx.searchParams = identity.url.searchParams;
|
|
1595
2187
|
}
|
|
1596
|
-
derivedCtx._handleStore = freshHandleStore;
|
|
1597
2188
|
// Own render barrier, closure-bound to the derived ctx and the fresh store
|
|
1598
2189
|
// (issue #684, plan 009). Without this every _renderBarrier* read fell
|
|
1599
2190
|
// through the prototype to the foreground's ALREADY-RESOLVED barrier: a
|
|
@@ -1603,7 +2194,6 @@ export function deriveShellCaptureContext(
|
|
|
1603
2194
|
// also resets _treeHasStreaming (recomputed for the capture's tree) and the
|
|
1604
2195
|
// deadlock-guard fields as own properties.
|
|
1605
2196
|
wireRenderBarrier(derivedCtx, freshHandleStore);
|
|
1606
|
-
derivedCtx._shellCaptureLoaderHandleValues = loaderScopedPushValues;
|
|
1607
2197
|
derivedCtx._requestTags = new Set<string>();
|
|
1608
2198
|
// Own explicit-store registry: cache-store resolutions during the capture
|
|
1609
2199
|
// (the implicit scope's SnapshotOnlySegmentStore, any per-capture explicit
|
|
@@ -1614,7 +2204,20 @@ export function deriveShellCaptureContext(
|
|
|
1614
2204
|
// context; module-singleton stores stay registered by normal renders.
|
|
1615
2205
|
derivedCtx._explicitTaggedStores = new Set();
|
|
1616
2206
|
derivedCtx._transitionWhen = [];
|
|
2207
|
+
// Own list: the capture's render errors refuse the capture only, and the
|
|
2208
|
+
// foreground's never reach it.
|
|
2209
|
+
derivedCtx._renderErrors = [];
|
|
1617
2210
|
derivedCtx._shellCaptureRun = true;
|
|
2211
|
+
// Own dynamic() latch. The inherited method writes the FOREGROUND context
|
|
2212
|
+
// (its closure), which already served, and nothing read it for a runtime
|
|
2213
|
+
// capture: handler code that opts out after an await (a handler promise the
|
|
2214
|
+
// capture now waits for) must refuse THIS capture instead
|
|
2215
|
+
// (settleCaptureRecord and captureAndStoreShell read _dynamic). The ppr
|
|
2216
|
+
// header latch is left alone: a capture's response is never sent.
|
|
2217
|
+
derivedCtx._dynamic = false;
|
|
2218
|
+
derivedCtx.dynamic = (): void => {
|
|
2219
|
+
derivedCtx._dynamic = true;
|
|
2220
|
+
};
|
|
1618
2221
|
// Own-property reset: an inherited flag would make serializeSegments store
|
|
1619
2222
|
// a double-encoded fragment. No current arming site reaches a capture
|
|
1620
2223
|
// render (kept as defense-in-depth for a future one).
|
|
@@ -1650,7 +2253,6 @@ export function deriveShellCaptureContext(
|
|
|
1650
2253
|
// forwarding to the parent so the write persists and the worker stays alive),
|
|
1651
2254
|
// then captureAndStoreShell awaits them before draining. Reads that HIT are
|
|
1652
2255
|
// recorded synchronously during the render and need none of this.
|
|
1653
|
-
derivedCtx._shellCaptureHandleLiveness = handleLiveness;
|
|
1654
2256
|
if (reqCtx._cacheStore) {
|
|
1655
2257
|
const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
|
|
1656
2258
|
derivedCtx._cacheStore = recordingStore;
|
|
@@ -1659,12 +2261,13 @@ export function deriveShellCaptureContext(
|
|
|
1659
2261
|
recordingStore.trackWrite(p);
|
|
1660
2262
|
reqCtx.waitUntil(() => p);
|
|
1661
2263
|
};
|
|
1662
|
-
//
|
|
2264
|
+
// The doc record (capture side): the implicit doc-cache scope makes the
|
|
1663
2265
|
// capture's match write ALL matched non-loader segments as one doc-keyed
|
|
1664
2266
|
// segment record — into the snapshot only (SnapshotOnlySegmentStore), so
|
|
1665
2267
|
// the record dies with the shell entry and the next capture's lookup
|
|
1666
|
-
// still misses (handlers re-run on recapture).
|
|
1667
|
-
// cache scope
|
|
2268
|
+
// still misses (handlers re-run on recapture). A route deriving its own
|
|
2269
|
+
// cache() scope keeps it, and recordShellCaptureDocRecord (cache-store.ts)
|
|
2270
|
+
// writes the same record for it.
|
|
1668
2271
|
derivedCtx._shellImplicitCache = {
|
|
1669
2272
|
ttl: descriptor.ttl,
|
|
1670
2273
|
swr: descriptor.swr,
|
|
@@ -1673,7 +2276,12 @@ export function deriveShellCaptureContext(
|
|
|
1673
2276
|
};
|
|
1674
2277
|
}
|
|
1675
2278
|
|
|
1676
|
-
return {
|
|
2279
|
+
return {
|
|
2280
|
+
derivedCtx,
|
|
2281
|
+
freshHandleStore,
|
|
2282
|
+
handlerPushesSettled: () =>
|
|
2283
|
+
Promise.all(handlerPushSettles).then(() => undefined),
|
|
2284
|
+
};
|
|
1677
2285
|
}
|
|
1678
2286
|
|
|
1679
2287
|
/**
|
|
@@ -1714,21 +2322,24 @@ async function captureAndStoreShell(
|
|
|
1714
2322
|
// generator has not yielded, SsrRoot suspends at the root (consumeAsyncGenerator
|
|
1715
2323
|
// sits above every boundary), the prelude comes back trivial, and
|
|
1716
2324
|
// captureShellHTML's sanity gate returns null: the designed fail-safe no-op, not
|
|
1717
|
-
// an error. This mirrors the
|
|
1718
|
-
// excludes loaders. See
|
|
2325
|
+
// an error. This mirrors the matchForPrerender (router/prerender-match.ts)
|
|
2326
|
+
// seal+settled regime, which also excludes loaders. See
|
|
2327
|
+
// docs/design/ppr-shell-resume.md ("Loaders and handles").
|
|
1719
2328
|
handleStore.seal();
|
|
1720
2329
|
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
//
|
|
1725
|
-
//
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
// awaits the top-level thenables).
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
//
|
|
2330
|
+
// TOP-LEVEL pushed handle promises are BAKED into the shell —
|
|
2331
|
+
// resolvedHandleStream awaits them before the payload's handles row emits. A
|
|
2332
|
+
// pushed promise with real latency would lose the byte-quiet race (the
|
|
2333
|
+
// pending handles row emits no bytes, the gate freezes, the row is dropped,
|
|
2334
|
+
// SsrRoot suspends at the root), so the gate is HELD open until the same
|
|
2335
|
+
// await completes: handlesBaked mirrors resolvedHandleStream's resolution
|
|
2336
|
+
// (getData waits the tracked-handler barrier; resolveDeferredHandleValues
|
|
2337
|
+
// awaits the top-level thenables). On the runtime path settleCaptureRecord
|
|
2338
|
+
// already waited for these (and for handler pushes' nested promises), so the
|
|
2339
|
+
// hold is normally released at once. A DSL loader push's nested promises are
|
|
2340
|
+
// masked and never hold the gate — they stay holes. Bounded by maxWaitMs like
|
|
2341
|
+
// every quiesce input (a defer hanging on a masked loader still ends in the
|
|
2342
|
+
// sanity-gate refusal).
|
|
1732
2343
|
const handlesBaked = handleStore.getData().then(resolveDeferredHandleValues);
|
|
1733
2344
|
// Bake-lane loader containers hold the gate the same way (loader-container-
|
|
1734
2345
|
// bake): a boundary-less container with real latency (a 100ms layout loader)
|
|
@@ -1774,36 +2385,32 @@ async function captureAndStoreShell(
|
|
|
1774
2385
|
() => {},
|
|
1775
2386
|
);
|
|
1776
2387
|
|
|
1777
|
-
// Deterministic
|
|
1778
|
-
// guard error either rejects the prerender itself
|
|
1779
|
-
// lands in the catch) or is swallowed into
|
|
1780
|
-
// completes — caught after the try).
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
"as a capture-time copy, mirroring cache() semantics (the consumption-lane rule).",
|
|
1804
|
-
);
|
|
1805
|
-
return "refused";
|
|
1806
|
-
};
|
|
2388
|
+
// Deterministic capture-guard refusal (refuseOnCaptureGuard), checked at
|
|
2389
|
+
// BOTH exits below: the guard error either rejects the prerender itself
|
|
2390
|
+
// (boundary-less segment — lands in the catch) or is swallowed into
|
|
2391
|
+
// per-loader error UI (the render completes — caught after the try).
|
|
2392
|
+
const refuseOnGuardTrip = (): "refused" | undefined =>
|
|
2393
|
+
refuseOnCaptureGuard(capture.key, reqCtx) ? "refused" : undefined;
|
|
2394
|
+
|
|
2395
|
+
// Dev diagnostics for a no-shell attempt (warnNullCaptureOnce): the stacks of
|
|
2396
|
+
// the tasks React reports as still pending at the capture's abort. React's
|
|
2397
|
+
// componentStack is computed on read, so it is read only while there is room.
|
|
2398
|
+
let pendingStacks: string[] | undefined;
|
|
2399
|
+
let onAbortedTask:
|
|
2400
|
+
| ((errorInfo: { componentStack?: string } | undefined) => void)
|
|
2401
|
+
| undefined;
|
|
2402
|
+
if (process.env.NODE_ENV !== "production") {
|
|
2403
|
+
noShellPendingStacks.delete(capture.key);
|
|
2404
|
+
const stacks: string[] = [];
|
|
2405
|
+
pendingStacks = stacks;
|
|
2406
|
+
onAbortedTask = (errorInfo) => {
|
|
2407
|
+
if (stacks.length >= MAX_PENDING_STACKS) return;
|
|
2408
|
+
const componentStack = errorInfo?.componentStack;
|
|
2409
|
+
if (componentStack && !stacks.includes(componentStack)) {
|
|
2410
|
+
stacks.push(componentStack);
|
|
2411
|
+
}
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
1807
2414
|
|
|
1808
2415
|
try {
|
|
1809
2416
|
// captureShellHTML CONSUMES the (gated) stream — it is not also SSR'd.
|
|
@@ -1819,6 +2426,10 @@ async function captureAndStoreShell(
|
|
|
1819
2426
|
// static-part search reads bake what the key names.
|
|
1820
2427
|
search: capture.searchSeed,
|
|
1821
2428
|
origin: capture.originSeed,
|
|
2429
|
+
onError: (error) => {
|
|
2430
|
+
reqCtx._renderErrors?.push(error);
|
|
2431
|
+
},
|
|
2432
|
+
onAbortedTask,
|
|
1822
2433
|
}),
|
|
1823
2434
|
);
|
|
1824
2435
|
} catch (error) {
|
|
@@ -1835,9 +2446,9 @@ async function captureAndStoreShell(
|
|
|
1835
2446
|
// null = sanity gate refused (trivial/empty prelude, no <body>). Store nothing
|
|
1836
2447
|
// and report `no-shell` so the caller (runShellCapture) can retry once and, if
|
|
1837
2448
|
// that also fails, warn once per key. On a cold render this is the shell not
|
|
1838
|
-
// yet finished;
|
|
1839
|
-
// the structural eternal-MISS shape
|
|
1840
|
-
//
|
|
2449
|
+
// yet finished; a boundary-less live-loader read (lane rule: see
|
|
2450
|
+
// resolveLoaderData, loader-cache.ts) is the structural eternal-MISS shape.
|
|
2451
|
+
// The caller's warning names both.
|
|
1841
2452
|
// Guard check first — BEFORE the trivial-prelude retry path. A guard trip
|
|
1842
2453
|
// is deterministic (retrying re-trips it), and when the tripping loader's
|
|
1843
2454
|
// error UI still completed a shell, storing it would bake the failure into
|
|
@@ -1846,6 +2457,7 @@ async function captureAndStoreShell(
|
|
|
1846
2457
|
if (refused) return refused;
|
|
1847
2458
|
|
|
1848
2459
|
if (result === null) {
|
|
2460
|
+
if (pendingStacks) noShellPendingStacks.set(capture.key, pendingStacks);
|
|
1849
2461
|
return "no-shell";
|
|
1850
2462
|
}
|
|
1851
2463
|
if (stats) stats.preludeBytes = result.prelude.length;
|
|
@@ -1932,13 +2544,16 @@ async function captureAndStoreShell(
|
|
|
1932
2544
|
let serializeContainer:
|
|
1933
2545
|
| typeof import("../cache/segment-codec.js").serializeResult
|
|
1934
2546
|
| undefined;
|
|
2547
|
+
// Capture-wide, read once: every record of this snapshot agrees.
|
|
2548
|
+
const runs: 0 | 1 = reqCtx._shellCaptureUnrecordedLoaderPush ? 1 : 0;
|
|
1935
2549
|
for (const [segmentKey, containerPromise] of loaderRecords) {
|
|
1936
2550
|
const elided = await elideLoaderContainer(containerPromise);
|
|
1937
2551
|
if (elided.state === "rejected") {
|
|
1938
2552
|
warnCaptureRefusedOnce(
|
|
1939
2553
|
capture.key,
|
|
1940
2554
|
`the loader for segment "${segmentKey}" rejected during capture; its error UI must not bake into the shared shell. ` +
|
|
1941
|
-
"Fix the loader, or
|
|
2555
|
+
"Fix the loader, or drop its ssr: false to move it to the live lane.",
|
|
2556
|
+
PPR_LANE_HINT,
|
|
1942
2557
|
);
|
|
1943
2558
|
return "refused";
|
|
1944
2559
|
}
|
|
@@ -1954,7 +2569,8 @@ async function captureAndStoreShell(
|
|
|
1954
2569
|
warnCaptureRefusedOnce(
|
|
1955
2570
|
capture.key,
|
|
1956
2571
|
`the loader for segment "${segmentKey}" settled with redirect()/notFound() during capture; a request-specific signal must not bake into the shared shell. ` +
|
|
1957
|
-
"
|
|
2572
|
+
"Drop its ssr: false to move it to the live lane, or move the decision into middleware.",
|
|
2573
|
+
PPR_LANE_HINT,
|
|
1958
2574
|
);
|
|
1959
2575
|
return "refused";
|
|
1960
2576
|
}
|
|
@@ -1966,7 +2582,14 @@ async function captureAndStoreShell(
|
|
|
1966
2582
|
// must round-trip; serializeResult preserves it through Flight.
|
|
1967
2583
|
serializeContainer ??= (await import("../cache/segment-codec.js"))
|
|
1968
2584
|
.serializeResult;
|
|
1969
|
-
|
|
2585
|
+
// A value Flight cannot encode (an async component that throws, a
|
|
2586
|
+
// rejected promise the elide walk does not reach, a function, a
|
|
2587
|
+
// class instance) completes with an error row instead of rejecting
|
|
2588
|
+
// (issue #927). Collected with the render's errors, so the check
|
|
2589
|
+
// after this drain refuses the capture.
|
|
2590
|
+
const serialized = await serializeContainer(elided.value, (error) => {
|
|
2591
|
+
reqCtx._renderErrors?.push(error);
|
|
2592
|
+
});
|
|
1970
2593
|
if (serialized !== null) {
|
|
1971
2594
|
(snapshot ??= []).push({
|
|
1972
2595
|
family: "loader",
|
|
@@ -1975,52 +2598,151 @@ async function captureAndStoreShell(
|
|
|
1975
2598
|
// without rescanning whether the pin can resolve immediately
|
|
1976
2599
|
// (holes: 0) or must wait for the fresh run's live promises
|
|
1977
2600
|
// (holes: 1). See ShellSnapshotLoaderValue.
|
|
1978
|
-
value: {
|
|
2601
|
+
value: {
|
|
2602
|
+
value: serialized,
|
|
2603
|
+
holes: elided.hasHole ? 1 : 0,
|
|
2604
|
+
runs,
|
|
2605
|
+
},
|
|
1979
2606
|
});
|
|
1980
2607
|
}
|
|
1981
2608
|
} catch {
|
|
1982
|
-
//
|
|
1983
|
-
//
|
|
2609
|
+
// Codec import failed: leave it unpinned (it drifts on a HIT, the
|
|
2610
|
+
// pre-snapshot behavior) rather than failing the capture.
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
// A shell component that threw inside a Suspense boundary does not reject
|
|
2616
|
+
// the capture: Flight and Fizz report it through onError and the prelude
|
|
2617
|
+
// carries the errored boundary, which every HIT would serve (issue #915).
|
|
2618
|
+
// The drain's container encode adds its Flight errors here too (#927).
|
|
2619
|
+
// After the loader drain so a rejected bake-lane loader keeps its specific
|
|
2620
|
+
// refusal. Thrown like a fatal shell error: no retry, reportCacheError and
|
|
2621
|
+
// backoff in scheduleShellCapture.
|
|
2622
|
+
const renderErrors = reqCtx._renderErrors;
|
|
2623
|
+
if (renderErrors && renderErrors.length > 0) throw renderErrors[0];
|
|
2624
|
+
|
|
2625
|
+
// Record only what a HIT reads (issue #941): the doc record, plus what
|
|
2626
|
+
// the bake-lane loaders a HIT runs read. Before the size guards, so they measure
|
|
2627
|
+
// what is stored.
|
|
2628
|
+
const docKey = reqCtx._shellImplicitCache?.docKey;
|
|
2629
|
+
let prunedRecords: string | undefined;
|
|
2630
|
+
if (recording && snapshot) {
|
|
2631
|
+
const readers = snapshotReaders(capture, snapshot, docKey);
|
|
2632
|
+
if (readers && docKey !== undefined) {
|
|
2633
|
+
const { kept, pruned } = pruneShellSnapshot(
|
|
2634
|
+
snapshot,
|
|
2635
|
+
readers,
|
|
2636
|
+
recording.loaderKeys,
|
|
2637
|
+
docKey,
|
|
2638
|
+
);
|
|
2639
|
+
if (pruned.length > 0) {
|
|
2640
|
+
prunedRecords = countSnapshotFamilies(pruned);
|
|
2641
|
+
snapshot = kept.length > 0 ? kept : undefined;
|
|
1984
2642
|
}
|
|
1985
2643
|
}
|
|
1986
2644
|
}
|
|
1987
2645
|
|
|
1988
2646
|
// Snapshot size guard (issue #651): the snapshot duplicates every pinned
|
|
1989
|
-
// cache value inside the shell entry
|
|
1990
|
-
//
|
|
1991
|
-
//
|
|
1992
|
-
//
|
|
1993
|
-
//
|
|
1994
|
-
//
|
|
1995
|
-
//
|
|
1996
|
-
// client-side, the pre-snapshot behavior), which beats losing the whole
|
|
1997
|
-
// entry to a store-side write rejection. Reported once per key.
|
|
2647
|
+
// cache value inside the shell entry. `maxSnapshotBytes` bounds the PINS
|
|
2648
|
+
// (loader-family records and the item/response records loaders read);
|
|
2649
|
+
// over it they are dropped and the entry keeps its doc record, so every
|
|
2650
|
+
// HIT still replays the handler layer (the pins' loaders then read the
|
|
2651
|
+
// live store: documented drift, repaired client-side). The doc record is
|
|
2652
|
+
// exempt: without it a HIT could not serve at all. The whole entry is
|
|
2653
|
+
// bounded separately below, by the store's value limit.
|
|
1998
2654
|
if (snapshot && snapshot.length > 0) {
|
|
1999
|
-
const
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2655
|
+
const docRecord =
|
|
2656
|
+
docKey !== undefined
|
|
2657
|
+
? snapshot.find((r) => r.family === "segment" && r.key === docKey)
|
|
2658
|
+
: undefined;
|
|
2659
|
+
const pins = docRecord
|
|
2660
|
+
? snapshot.filter((r) => r !== docRecord)
|
|
2661
|
+
: snapshot;
|
|
2662
|
+
const pinBytes =
|
|
2663
|
+
pins.length > 0
|
|
2664
|
+
? SNAPSHOT_BYTE_ENCODER.encode(JSON.stringify(pins)).length
|
|
2665
|
+
: 0;
|
|
2666
|
+
if (stats) stats.snapshotBytes = pinBytes;
|
|
2003
2667
|
const cap = capture.maxSnapshotBytes ?? DEFAULT_PPR_MAX_SNAPSHOT_BYTES;
|
|
2004
|
-
if (
|
|
2005
|
-
warnSnapshotOverCapOnce(capture.key,
|
|
2006
|
-
snapshot = undefined;
|
|
2668
|
+
if (pinBytes > cap) {
|
|
2669
|
+
warnSnapshotOverCapOnce(capture.key, pinBytes, cap);
|
|
2670
|
+
snapshot = docRecord ? [docRecord] : undefined;
|
|
2007
2671
|
if (stats) stats.snapshotSkipped = true;
|
|
2008
2672
|
}
|
|
2009
2673
|
}
|
|
2010
2674
|
|
|
2011
|
-
//
|
|
2012
|
-
//
|
|
2013
|
-
//
|
|
2014
|
-
//
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2675
|
+
// A capture that ran handlers must store its doc record: an entry without
|
|
2676
|
+
// it could only be served by running handlers after the commit. Only a
|
|
2677
|
+
// prerender-served capture (the prerender store supplies the handler
|
|
2678
|
+
// layer on every HIT) or a navigation-only entry may lack it.
|
|
2679
|
+
const storedDocKey = hasDocRecord(snapshot, docKey) ? docKey : undefined;
|
|
2680
|
+
if (
|
|
2681
|
+
storedDocKey === undefined &&
|
|
2682
|
+
!capture.navigationOnly &&
|
|
2683
|
+
reqCtx._pprReplayPostMatchReason !== "prerender-store"
|
|
2684
|
+
) {
|
|
2685
|
+
warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
|
|
2686
|
+
return "refused";
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// Shell tags: what the shell renders from, and nothing else. The handler
|
|
2690
|
+
// layer is the doc record, whose tags are what its content recorded (the
|
|
2691
|
+
// handlers, the server components its serialization rendered, the loaders
|
|
2692
|
+
// they consumed, the handle values; cache-scope.ts collectRecordTags), or
|
|
2693
|
+
// what a replayed route cache() record carried (recordSegmentTags). So a
|
|
2694
|
+
// capture that rendered fresh and one that replayed the route's record
|
|
2695
|
+
// store the same tags. Bake-lane loaders add theirs (SHELL_BAKE_TAG_OWNER):
|
|
2696
|
+
// their data is in the shell, and no handler reads it. A live-lane loader
|
|
2697
|
+
// never runs at capture, and one whose cache() config tags are recorded
|
|
2698
|
+
// anyway is a hole's, so request-level tags are not the source. Read at
|
|
2699
|
+
// the write barrier: the record settled and the deferred writes were
|
|
2700
|
+
// awaited, so a tag recorded after an await (#676) is in. A capture
|
|
2701
|
+
// without a doc record (the prerender store served its match) keeps the
|
|
2702
|
+
// request-level set. Union with the route's static ppr.tags.
|
|
2703
|
+
const docRecordValue = storedDocKey
|
|
2704
|
+
? (snapshot?.find((r) => r.family === "segment" && r.key === storedDocKey)
|
|
2705
|
+
?.value as CachedEntryData | undefined)
|
|
2706
|
+
: undefined;
|
|
2707
|
+
const collected = docRecordValue
|
|
2708
|
+
? [
|
|
2709
|
+
...(docRecordValue.tags ?? []),
|
|
2710
|
+
...getSegmentTags(reqCtx, SHELL_BAKE_TAG_OWNER),
|
|
2711
|
+
]
|
|
2712
|
+
: [...reqCtx._requestTags];
|
|
2021
2713
|
const union = new Set<string>([...(capture.tags ?? []), ...collected]);
|
|
2022
2714
|
const shellTags = union.size > 0 ? [...union] : undefined;
|
|
2023
2715
|
|
|
2716
|
+
// Whole-entry guard: the prelude, postponed state, snapshot, and tags
|
|
2717
|
+
// travel in one stored value. Over the store's limit (Cloudflare KV:
|
|
2718
|
+
// 25 MiB) the write would fail inside waitUntil and every MISS would
|
|
2719
|
+
// recapture, so refuse here (the key backs off) instead. Measured as the
|
|
2720
|
+
// stores write it (estimateShellEntryBytes): the postponed state rides a
|
|
2721
|
+
// JSON head as an escaped string.
|
|
2722
|
+
const entryBytes = estimateShellEntryBytes({
|
|
2723
|
+
// A navigation-only entry stores no document half.
|
|
2724
|
+
preludeBytes: capture.navigationOnly ? 0 : result.prelude.length,
|
|
2725
|
+
postponed: capture.navigationOnly ? undefined : result.postponed,
|
|
2726
|
+
snapshot,
|
|
2727
|
+
tags: shellTags,
|
|
2728
|
+
docKey: storedDocKey,
|
|
2729
|
+
prunedRecords: snapshot ? prunedRecords : undefined,
|
|
2730
|
+
});
|
|
2731
|
+
if (stats) stats.entryBytes = entryBytes;
|
|
2732
|
+
const entryLimit =
|
|
2733
|
+
(capture.store ?? reqCtx._cacheStore)?.maxShellEntryBytes ??
|
|
2734
|
+
DEFAULT_SHELL_ENTRY_MAX_BYTES;
|
|
2735
|
+
if (entryBytes > entryLimit) {
|
|
2736
|
+
warnCaptureRefusedOnce(
|
|
2737
|
+
capture.key,
|
|
2738
|
+
`the shell entry is ${entryBytes} bytes (prelude, postponed state, ` +
|
|
2739
|
+
`snapshot, and head), over the store's ${entryLimit}-byte value limit. ` +
|
|
2740
|
+
"Shrink the page the shell bakes, or move large regions under a live " +
|
|
2741
|
+
"loader's boundary.",
|
|
2742
|
+
);
|
|
2743
|
+
return "refused";
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2024
2746
|
// Missing tags are valid: the shell follows TTL/SWR-only invalidation. Expose
|
|
2025
2747
|
// that choice only to operators who enabled structured capture diagnostics.
|
|
2026
2748
|
if (
|
|
@@ -2044,45 +2766,73 @@ async function captureAndStoreShell(
|
|
|
2044
2766
|
...(capture.navigationOnly
|
|
2045
2767
|
? {}
|
|
2046
2768
|
: {
|
|
2047
|
-
//
|
|
2048
|
-
//
|
|
2049
|
-
|
|
2050
|
-
// reads the whole ArrayBuffer it is handed.
|
|
2051
|
-
prelude: bufferToBase64(
|
|
2052
|
-
result.prelude.slice().buffer as ArrayBuffer,
|
|
2053
|
-
),
|
|
2769
|
+
// bytesToBase64 encodes the view's own region, so a prelude
|
|
2770
|
+
// that is a subarray of a larger buffer encodes correctly.
|
|
2771
|
+
prelude: bytesToBase64(result.prelude),
|
|
2054
2772
|
postponed: result.postponed,
|
|
2055
2773
|
}),
|
|
2056
2774
|
reactVersion: React.version,
|
|
2057
2775
|
buildVersion: capture.buildVersion,
|
|
2058
2776
|
// The theme this capture's payload was built with (buildFullPayload
|
|
2059
|
-
// reads
|
|
2060
|
-
//
|
|
2061
|
-
// ShellCacheEntry.initialTheme.
|
|
2062
|
-
initialTheme: reqCtx
|
|
2777
|
+
// reads payloadInitialTheme off the derived context: the no-cookie
|
|
2778
|
+
// default, #971). The serve tail replays it so the resume tree
|
|
2779
|
+
// matches the frozen prelude — see ShellCacheEntry.initialTheme.
|
|
2780
|
+
initialTheme: payloadInitialTheme(reqCtx),
|
|
2063
2781
|
snapshot,
|
|
2782
|
+
prunedRecords: snapshot ? prunedRecords : undefined,
|
|
2064
2783
|
// The canonical doc segment record's key, published by the doc
|
|
2065
|
-
// scope's cacheRoute during this capture's match
|
|
2066
|
-
//
|
|
2067
|
-
//
|
|
2068
|
-
docKey:
|
|
2784
|
+
// scope's cacheRoute during this capture's match. Every HIT tail
|
|
2785
|
+
// looks the record up by this key (serveShellHit fixedDocKey);
|
|
2786
|
+
// undefined only for a prerender-served entry.
|
|
2787
|
+
docKey: storedDocKey,
|
|
2069
2788
|
navigationOnly: capture.navigationOnly,
|
|
2070
|
-
// Handler-layer liveness folded at the barrier: nested thenables in
|
|
2071
|
-
// handler-scoped pushes, handler pushes still pending (liveness
|
|
2072
|
-
// unknowable), or a handler-invoked loader execution — any of them
|
|
2073
|
-
// refuses the FAST PATH, not the capture. See
|
|
2074
|
-
// _shellCaptureHandleLiveness.
|
|
2075
|
-
handlerLiveHoles: handlerLayerIsLive(
|
|
2076
|
-
reqCtx._shellCaptureHandleLiveness,
|
|
2077
|
-
),
|
|
2078
|
-
transitionWhen: reqCtx._transitionWhen?.length ? true : undefined,
|
|
2079
2789
|
createdAt: captureStartedAt,
|
|
2080
2790
|
};
|
|
2791
|
+
// Capped to the route record the capture used: the policy the
|
|
2792
|
+
// store would apply (resolveTtl/resolveSwrWindow, as putShell does),
|
|
2793
|
+
// made explicit.
|
|
2794
|
+
const record = reqCtx._routeRecordWindow;
|
|
2795
|
+
let window: { ttl?: number; swr?: number } = {
|
|
2796
|
+
ttl: capture.ttl,
|
|
2797
|
+
swr: capture.swr,
|
|
2798
|
+
};
|
|
2799
|
+
if (record) {
|
|
2800
|
+
const capped = capShellWindow(
|
|
2801
|
+
resolveTtl(capture.ttl, store.defaults, DEFAULT_FUNCTION_TTL),
|
|
2802
|
+
resolveSwrWindow(capture.swr, store.defaults),
|
|
2803
|
+
record,
|
|
2804
|
+
Date.now(),
|
|
2805
|
+
);
|
|
2806
|
+
if (!capped) {
|
|
2807
|
+
// Dead on arrival, not stored. A record this attempt wrote ran
|
|
2808
|
+
// out between its write and the store, which a retry repeats:
|
|
2809
|
+
// warn, refuse, back off. A record it read was near its end (its
|
|
2810
|
+
// age, queue wait): `expired` retries on a fresh match.
|
|
2811
|
+
const route = reqCtx._routeName ?? capture.key;
|
|
2812
|
+
if (record.written) {
|
|
2813
|
+
const sinceWriteMs =
|
|
2814
|
+
Date.now() - (record.freshUntil - record.ttl * 1000);
|
|
2815
|
+
warnRecordRanOutOnce(
|
|
2816
|
+
route,
|
|
2817
|
+
record,
|
|
2818
|
+
`${sinceWriteMs} ms after this capture wrote it`,
|
|
2819
|
+
);
|
|
2820
|
+
return "refused";
|
|
2821
|
+
}
|
|
2822
|
+
expiredRecords.set(capture.key, {
|
|
2823
|
+
route,
|
|
2824
|
+
record,
|
|
2825
|
+
captureMs: Date.now() - captureStartedAt,
|
|
2826
|
+
});
|
|
2827
|
+
return "expired";
|
|
2828
|
+
}
|
|
2829
|
+
window = capped;
|
|
2830
|
+
}
|
|
2081
2831
|
const storeWrite = await store.putShell(
|
|
2082
2832
|
capture.key,
|
|
2083
2833
|
entry,
|
|
2084
|
-
|
|
2085
|
-
|
|
2834
|
+
window.ttl,
|
|
2835
|
+
window.swr,
|
|
2086
2836
|
shellTags,
|
|
2087
2837
|
);
|
|
2088
2838
|
if (stats && storeWrite) stats.storeWrite = storeWrite;
|
|
@@ -2155,3 +2905,25 @@ export {
|
|
|
2155
2905
|
REFUSED_CAPTURE_MAX_MS,
|
|
2156
2906
|
REFUSED_CAPTURE_DEV_MAX_MS,
|
|
2157
2907
|
};
|
|
2908
|
+
|
|
2909
|
+
/**
|
|
2910
|
+
* @internal Reset this module's per-isolate state: the capture stampede
|
|
2911
|
+
* guard, the refused-capture backoff, the recorded no-shell causes (which
|
|
2912
|
+
* also skip a timed-out capture's in-place retry), the once-per-key warnings
|
|
2913
|
+
* and the buffered debug events. Tests only (testing/serve-shell-request.ts
|
|
2914
|
+
* resetShellTestState), between requests, never while a capture runs.
|
|
2915
|
+
*/
|
|
2916
|
+
export function resetShellCaptureStateForTests(): void {
|
|
2917
|
+
inFlightCaptures.clear();
|
|
2918
|
+
refusedCaptures.clear();
|
|
2919
|
+
warnedNullCaptures.clear();
|
|
2920
|
+
noShellCauses.clear();
|
|
2921
|
+
noShellPendingStacks.clear();
|
|
2922
|
+
warnedRefusedCaptures.clear();
|
|
2923
|
+
warnedOverCapSnapshots.clear();
|
|
2924
|
+
warnedBakeCosts.clear();
|
|
2925
|
+
warnedExpiredRecordRoutes.clear();
|
|
2926
|
+
expiredRecords.clear();
|
|
2927
|
+
storedCaptureSeqs.clear();
|
|
2928
|
+
lastCaptureEventsForTiming.clear();
|
|
2929
|
+
}
|