@rangojs/router 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/rango.js +1 -1
- package/dist/types/cache/cf/cf-cache-store.d.ts +0 -2
- package/dist/types/cache/cf/cf-cache-types.d.ts +0 -16
- package/dist/types/cache/handle-snapshot.d.ts +2 -2
- package/dist/types/cache/shell-snapshot.d.ts +6 -4
- package/dist/types/cache/types.d.ts +8 -6
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +1 -15
- package/dist/types/handles/deferred-resolution.d.ts +3 -2
- package/dist/types/handles/meta.d.ts +5 -0
- package/dist/types/prerender/build-shell-capture.d.ts +1 -2
- package/dist/types/prerender/writable-store.d.ts +2 -3
- package/dist/types/router/dev-reload-handoff.d.ts +26 -0
- package/dist/types/router/router-interfaces.d.ts +0 -5
- package/dist/types/router/router-options.d.ts +0 -20
- package/dist/types/router/segment-resolution/mask-nested.d.ts +5 -3
- package/dist/types/router-versions.d.ts +8 -6
- package/dist/types/rsc/shell-capture-constants.d.ts +10 -6
- package/dist/types/rsc/shell-capture.d.ts +25 -8
- package/dist/types/rsc/types.d.ts +0 -8
- package/dist/types/server/build-version-table.d.ts +10 -13
- package/dist/types/server/context.d.ts +26 -2
- package/dist/types/server/handle-store.d.ts +17 -6
- package/dist/types/server/request-context.d.ts +4 -3
- package/dist/types/testing/build-versions.d.ts +0 -3
- package/dist/types/testing/internal/seed-vars.d.ts +4 -4
- package/dist/types/testing/serve-shell-request.d.ts +1 -1
- package/dist/types/types/loader-types.d.ts +6 -1
- package/dist/types/urls/pattern-types.d.ts +7 -6
- package/dist/vite/index.js +3 -8
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +4 -8
- package/skills/caching/SKILL.md +3 -5
- package/skills/comparison/references/framework-comparison.md +1 -1
- package/skills/hooks/handle-and-actions.md +6 -6
- package/skills/host-router/SKILL.md +1 -1
- package/skills/loader/SKILL.md +10 -9
- package/skills/ppr/SKILL.md +83 -60
- package/skills/prerender/SKILL.md +3 -3
- package/skills/router-setup/SKILL.md +0 -2
- package/skills/testing/render-handler.md +17 -17
- package/skills/testing/serve-shell-request.md +1 -1
- package/skills/testing/server-actions.md +16 -16
- package/skills/vercel/SKILL.md +1 -1
- package/src/cache/cf/cf-cache-store.ts +4 -7
- package/src/cache/cf/cf-cache-types.ts +0 -17
- package/src/cache/handle-snapshot.ts +2 -2
- package/src/cache/shell-snapshot.ts +7 -3
- package/src/cache/types.ts +8 -6
- package/src/cache/vercel/vercel-cache-store.ts +2 -18
- package/src/handles/deferred-resolution.ts +3 -2
- package/src/handles/meta.ts +5 -0
- package/src/prerender/build-shell-capture.ts +1 -2
- package/src/prerender/writable-store.ts +2 -3
- package/src/route-definition/dsl-helpers.ts +14 -14
- package/src/router/dev-reload-handoff.ts +73 -0
- package/src/router/manifest.ts +9 -2
- package/src/router/match-middleware/intercept-resolution.ts +2 -2
- package/src/router/router-interfaces.ts +0 -6
- package/src/router/router-options.ts +0 -21
- package/src/router/segment-resolution/loader-cache.ts +4 -3
- package/src/router/segment-resolution/mask-nested.ts +5 -3
- package/src/router-versions.ts +7 -7
- package/src/router.ts +19 -8
- package/src/rsc/handler.ts +3 -7
- package/src/rsc/rsc-rendering.ts +44 -12
- package/src/rsc/shell-capture-constants.ts +10 -6
- package/src/rsc/shell-capture.ts +163 -88
- package/src/rsc/types.ts +0 -9
- package/src/server/build-version-table.ts +12 -24
- package/src/server/context.ts +64 -2
- package/src/server/handle-store.ts +77 -10
- package/src/server/request-context.ts +4 -3
- package/src/testing/build-versions.ts +0 -3
- package/src/testing/dispatch.ts +1 -3
- package/src/testing/internal/seed-vars.ts +4 -4
- package/src/testing/serve-shell-request.ts +2 -5
- package/src/types/loader-types.ts +6 -1
- package/src/urls/pattern-types.ts +7 -6
- package/src/vite/discovery/shell-prerender-phase.ts +1 -5
package/src/router.ts
CHANGED
|
@@ -132,6 +132,10 @@ import {
|
|
|
132
132
|
resolvePrefetchCacheSize,
|
|
133
133
|
resolvePrefetchConcurrency,
|
|
134
134
|
} from "./router/prefetch-limits.js";
|
|
135
|
+
import {
|
|
136
|
+
droppedByRunnerReload,
|
|
137
|
+
routerAfterRunnerReload,
|
|
138
|
+
} from "./router/dev-reload-handoff.js";
|
|
135
139
|
|
|
136
140
|
// Re-export public types and values from extracted modules
|
|
137
141
|
export { RSC_ROUTER_BRAND, RouterRegistry } from "./router/router-registry.js";
|
|
@@ -170,7 +174,6 @@ export function createRouter<TEnv = any>(
|
|
|
170
174
|
$$routeNames: staticRouteNames,
|
|
171
175
|
$$sourceFile: injectedSourceFile,
|
|
172
176
|
nonce,
|
|
173
|
-
version,
|
|
174
177
|
prefetchCacheTTL: prefetchCacheTTLOption,
|
|
175
178
|
prefetchCacheSize: prefetchCacheSizeOption,
|
|
176
179
|
prefetchConcurrency: prefetchConcurrencyOption,
|
|
@@ -771,7 +774,7 @@ export function createRouter<TEnv = any>(
|
|
|
771
774
|
// handler's overlay read makes (resolvePrerenderVersion), so the keys agree.
|
|
772
775
|
const prerenderTrigger = createPrerenderTrigger<TEnv, {}>({
|
|
773
776
|
routerId,
|
|
774
|
-
resolveVersion: () => resolvePrerenderVersion(routerId
|
|
777
|
+
resolveVersion: () => resolvePrerenderVersion(routerId),
|
|
775
778
|
isDev: isDevEnvironment,
|
|
776
779
|
isViteDevServer,
|
|
777
780
|
ensureManifest: () => ensureRouterManifest(routerId),
|
|
@@ -803,7 +806,7 @@ export function createRouter<TEnv = any>(
|
|
|
803
806
|
{},
|
|
804
807
|
{
|
|
805
808
|
...prerenderDeps,
|
|
806
|
-
versions: resolveRouterVersions(routerId
|
|
809
|
+
versions: resolveRouterVersions(routerId),
|
|
807
810
|
},
|
|
808
811
|
undefined,
|
|
809
812
|
isPassthrough,
|
|
@@ -812,7 +815,7 @@ export function createRouter<TEnv = any>(
|
|
|
812
815
|
true,
|
|
813
816
|
),
|
|
814
817
|
// A warm goes through router.fetch's handler: the one createRouter's own
|
|
815
|
-
// cache
|
|
818
|
+
// cache and nonce configure. An entry that passes them to
|
|
816
819
|
// createRSCHandler itself is not what a warm runs.
|
|
817
820
|
resolveCacheConfig: (env, ctx) =>
|
|
818
821
|
typeof cache === "function" ? cache(env, ctx) : cache,
|
|
@@ -1222,9 +1225,6 @@ export function createRouter<TEnv = any>(
|
|
|
1222
1225
|
// Expose nonce provider for fetch
|
|
1223
1226
|
nonce,
|
|
1224
1227
|
|
|
1225
|
-
// Expose version for fetch
|
|
1226
|
-
version,
|
|
1227
|
-
|
|
1228
1228
|
// Expose urlpatterns for runtime manifest generation
|
|
1229
1229
|
get urlpatterns() {
|
|
1230
1230
|
return storedUrlPatterns ?? undefined;
|
|
@@ -1278,6 +1278,18 @@ export function createRouter<TEnv = any>(
|
|
|
1278
1278
|
});
|
|
1279
1279
|
}
|
|
1280
1280
|
|
|
1281
|
+
// Dev: a runner full reload dropped this router's module copy
|
|
1282
|
+
// (router/dev-reload-handoff.ts, #1186).
|
|
1283
|
+
if (process.env.NODE_ENV !== "production" && droppedByRunnerReload()) {
|
|
1284
|
+
const reloaded = await routerAfterRunnerReload(
|
|
1285
|
+
routerId,
|
|
1286
|
+
__sourceFile,
|
|
1287
|
+
);
|
|
1288
|
+
if (reloaded && reloaded !== router) {
|
|
1289
|
+
return reloaded.fetch(request, input);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1281
1293
|
// Trigger lazy import of per-router manifest data before route matching.
|
|
1282
1294
|
// No-op if data is already loaded or no loader is registered.
|
|
1283
1295
|
await ensureRouterManifest(routerId);
|
|
@@ -1292,7 +1304,6 @@ export function createRouter<TEnv = any>(
|
|
|
1292
1304
|
router: router as any,
|
|
1293
1305
|
cache,
|
|
1294
1306
|
nonce,
|
|
1295
|
-
version,
|
|
1296
1307
|
}) as (
|
|
1297
1308
|
request: Request,
|
|
1298
1309
|
input: RouterRequestInput<TEnv>,
|
package/src/rsc/handler.ts
CHANGED
|
@@ -165,12 +165,8 @@ export function createRSCHandler<
|
|
|
165
165
|
TRoutes extends Record<string, string> = Record<string, string>,
|
|
166
166
|
>(options: CreateRSCHandlerOptions<TEnv, TRoutes>) {
|
|
167
167
|
const { router, nonce: nonceProvider } = options;
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
const versions = resolveRouterVersions(
|
|
171
|
-
router.id,
|
|
172
|
-
options.version ?? router.version,
|
|
173
|
-
);
|
|
168
|
+
// The router's own pair from the build.
|
|
169
|
+
const versions = resolveRouterVersions(router.id);
|
|
174
170
|
// Everything the handler itself versions is tied to the HTML a tab holds:
|
|
175
171
|
// payload metadata (what the browser echoes as `_rsc_v`), the reload check,
|
|
176
172
|
// shell stamping and gating, the rango state value. The data version is only
|
|
@@ -548,7 +544,7 @@ export function createRSCHandler<
|
|
|
548
544
|
resolvedPrerender = {
|
|
549
545
|
config: prerenderConfig,
|
|
550
546
|
routerId: router.id,
|
|
551
|
-
version: resolvePrerenderVersion(router.id
|
|
547
|
+
version: resolvePrerenderVersion(router.id),
|
|
552
548
|
};
|
|
553
549
|
}
|
|
554
550
|
}
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { buildFullPayload, payloadLocation } from "./full-payload.js";
|
|
|
56
56
|
import {
|
|
57
57
|
capShellWindow,
|
|
58
58
|
lastStoredCaptureSeq,
|
|
59
|
+
markCaptureBackoff,
|
|
59
60
|
scheduleShellCapture,
|
|
60
61
|
resolveShellCaptureDebugSink,
|
|
61
62
|
takeCaptureDebugEventForTiming,
|
|
@@ -1804,13 +1805,14 @@ async function isSettled(value: unknown): Promise<boolean> {
|
|
|
1804
1805
|
}
|
|
1805
1806
|
|
|
1806
1807
|
/**
|
|
1807
|
-
* Evict a shell whose doc record cannot be replayed
|
|
1808
|
-
* delete, so the entry is
|
|
1809
|
-
*
|
|
1810
|
-
* treats as a MISS (shellServePlan,
|
|
1811
|
-
* replay as `no-segment-snapshot`. The
|
|
1812
|
-
* entry. Only for a broken entry: a
|
|
1813
|
-
* (`snapshotFailure: "unavailable"`)
|
|
1808
|
+
* Evict a shell whose doc record cannot be replayed, or one with a loader pin
|
|
1809
|
+
* that failed to decode. The store has no shell delete, so the entry is
|
|
1810
|
+
* overwritten with a tombstone: a navigationOnly entry with no document half
|
|
1811
|
+
* and no snapshot, which document serving treats as a MISS (shellServePlan,
|
|
1812
|
+
* the build shell included) and partial replay as `no-segment-snapshot`. The
|
|
1813
|
+
* recapture overwrites it with a sound entry. Only for a broken entry: a
|
|
1814
|
+
* snapshot read that was merely slow (`snapshotFailure: "unavailable"`)
|
|
1815
|
+
* leaves the entry alone.
|
|
1814
1816
|
*/
|
|
1815
1817
|
async function degradeUnreplayableShell(
|
|
1816
1818
|
descriptor: ShellCaptureDescriptor,
|
|
@@ -1878,6 +1880,21 @@ function serveShellHit(
|
|
|
1878
1880
|
const tailT0 = tailTiming ? performance.now() : 0;
|
|
1879
1881
|
const scheduleRecapture = (): void =>
|
|
1880
1882
|
scheduleShellCapture(ctx, request, env, url, reqCtx, ssrModule, descriptor);
|
|
1883
|
+
/** Report a broken entry, then tombstone and recapture it. Never throws. */
|
|
1884
|
+
const retireEntry = async (reason: string): Promise<void> => {
|
|
1885
|
+
reportCacheError(
|
|
1886
|
+
new Error(`shell entry for "${descriptor.key}": ${reason}`),
|
|
1887
|
+
"cache-read",
|
|
1888
|
+
"[ShellServe] retire",
|
|
1889
|
+
);
|
|
1890
|
+
try {
|
|
1891
|
+
await degradeUnreplayableShell(descriptor, entry, scheduleRecapture);
|
|
1892
|
+
} catch (error) {
|
|
1893
|
+
reportCacheError(error, "cache-write", "[ShellServe] retire");
|
|
1894
|
+
}
|
|
1895
|
+
};
|
|
1896
|
+
/** The first loader pin of the entry that failed to decode (dc-t89). */
|
|
1897
|
+
let undecodablePin: string | undefined;
|
|
1881
1898
|
|
|
1882
1899
|
const createTailContext = (): RequestContext<any> => {
|
|
1883
1900
|
const tailCtx: RequestContext<any> = Object.create(reqCtx);
|
|
@@ -2016,7 +2033,11 @@ function serveShellHit(
|
|
|
2016
2033
|
const seedStart =
|
|
2017
2034
|
INTERNAL_RANGO_DEBUG || tailTiming ? performance.now() : 0;
|
|
2018
2035
|
const loaderSeed =
|
|
2019
|
-
records.length > 0
|
|
2036
|
+
records.length > 0
|
|
2037
|
+
? await buildShellLoaderSeed(records, (key) => {
|
|
2038
|
+
undecodablePin ??= key;
|
|
2039
|
+
})
|
|
2040
|
+
: undefined;
|
|
2020
2041
|
if (tailTiming) {
|
|
2021
2042
|
const seededAt = performance.now();
|
|
2022
2043
|
tailTiming.seedMs = Math.round(seededAt - tailT0);
|
|
@@ -2179,10 +2200,8 @@ function serveShellHit(
|
|
|
2179
2200
|
// snapshot read was only slow stays, and the next request reads it
|
|
2180
2201
|
// again.
|
|
2181
2202
|
if ((await document.snapshotFailure) !== "unavailable") {
|
|
2182
|
-
await
|
|
2183
|
-
|
|
2184
|
-
entry,
|
|
2185
|
-
scheduleRecapture,
|
|
2203
|
+
await retireEntry(
|
|
2204
|
+
"the doc record could not supply the handler layer",
|
|
2186
2205
|
);
|
|
2187
2206
|
}
|
|
2188
2207
|
controller.enqueue(new TextEncoder().encode(shellReloadScript()));
|
|
@@ -2207,6 +2226,19 @@ function serveShellHit(
|
|
|
2207
2226
|
}
|
|
2208
2227
|
publishTailTiming(tailTiming, metricsStore, request, url);
|
|
2209
2228
|
controller.error(error);
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
// A pin that failed to decode left its loader to this request's run:
|
|
2232
|
+
// this response hydrated once over a prelude rendered from the
|
|
2233
|
+
// capture's value (dc-t89). Retire the entry once the response is
|
|
2234
|
+
// closed, and back the key off so a pin that keeps failing costs one
|
|
2235
|
+
// mismatch per window. No reload: that would cost a second page load.
|
|
2236
|
+
if (undecodablePin !== undefined) {
|
|
2237
|
+
const pin = undecodablePin;
|
|
2238
|
+
markCaptureBackoff(descriptor.key);
|
|
2239
|
+
reqCtx.waitUntil(() =>
|
|
2240
|
+
retireEntry(`the loader pin "${pin}" failed to decode`),
|
|
2241
|
+
);
|
|
2210
2242
|
}
|
|
2211
2243
|
},
|
|
2212
2244
|
});
|
|
@@ -208,13 +208,17 @@ export const SHELL_CAPTURE_TASK_HARD_CAP_MS = 25_000;
|
|
|
208
208
|
* (ShellCaptureDebugEvent.refusal, `caches.refusal` on a `router.prerender()`
|
|
209
209
|
* result): `identity` (a request-scoped read: cookies(), headers(), the
|
|
210
210
|
* theme, a `{ cache: false }` variable), `dynamic` (`ctx.dynamic()`),
|
|
211
|
-
* `loader` (a `{ ssr: false }` loader rejected,
|
|
212
|
-
* redirect()/notFound()
|
|
213
|
-
*
|
|
211
|
+
* `loader` (a `{ ssr: false }` loader rejected, settled with
|
|
212
|
+
* redirect()/notFound(), or a promise it pushed rejected),
|
|
213
|
+
* `no-record` (no doc record: the route's cache() refused the write, or the
|
|
214
|
+
* handler output failed), `handles` (a pushed
|
|
214
215
|
* handle value did not finish encoding), `size` (over the store's value
|
|
215
|
-
* limit
|
|
216
|
-
*
|
|
217
|
-
*
|
|
216
|
+
* limit, or `ssr: false` loader pins over `ppr.maxSnapshotBytes`: a
|
|
217
|
+
* navigation-only entry was stored in place of the document shell),
|
|
218
|
+
* `record-expired` (the route cache() record
|
|
219
|
+
* the capture wrote ran out first), `invalidated` (a tag of the shell was
|
|
220
|
+
* invalidated after the capture started), `uncacheable` (the store cannot
|
|
221
|
+
* hold the entry).
|
|
218
222
|
*/
|
|
219
223
|
export type ShellCaptureRefusal =
|
|
220
224
|
| "identity"
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -508,6 +508,11 @@ function refuse(
|
|
|
508
508
|
return "refused";
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
+
/** A bake-lane loader's promise push, as a refusal names it. */
|
|
512
|
+
function describeLoaderPromisePush(push: LoaderPromisePush): string {
|
|
513
|
+
return `the promise the loader "${push.loaderId}" pushed to the handle "${push.handleName}"`;
|
|
514
|
+
}
|
|
515
|
+
|
|
511
516
|
/** The no-shell cause for handler output that missed the capture deadline. */
|
|
512
517
|
const HANDLER_OUTPUT_TIMEOUT_REASON: string =
|
|
513
518
|
"the handler output (a promise it passes or pushes, or an async server " +
|
|
@@ -537,9 +542,8 @@ export const DEFAULT_SHELL_ENTRY_MAX_BYTES: number = 25 * 1024 * 1024;
|
|
|
537
542
|
* while still fitting any sane pinned-ring payload. Applied ONLY in
|
|
538
543
|
* captureAndStoreShell (the single defaulting site — resolvePprConfig passes
|
|
539
544
|
* the option through undefaulted), so every producer and direct caller gets
|
|
540
|
-
* the same policy. The doc record is exempt; over the cap the
|
|
541
|
-
*
|
|
542
|
-
* live store — see PartialPrerenderProps.maxSnapshotBytes).
|
|
545
|
+
* the same policy. The doc record is exempt; over the cap only the
|
|
546
|
+
* navigation half is stored (see PartialPrerenderProps.maxSnapshotBytes).
|
|
543
547
|
*/
|
|
544
548
|
export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
|
|
545
549
|
|
|
@@ -548,12 +552,8 @@ const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
|
|
|
548
552
|
|
|
549
553
|
/**
|
|
550
554
|
* Warn once per key that the capture's loader pins exceeded the route's
|
|
551
|
-
* `maxSnapshotBytes` cap
|
|
552
|
-
*
|
|
553
|
-
* pinned loaders lose their captured values, so their cached data can drift
|
|
554
|
-
* from the frozen prelude and hydration repairs it client-side. Once per
|
|
555
|
-
* key: the same page recaptures on every TTL roll and would otherwise
|
|
556
|
-
* re-warn forever.
|
|
555
|
+
* `maxSnapshotBytes` cap, so no document shell was stored (dc-t89). Once per
|
|
556
|
+
* key: the route recaptures every backoff window.
|
|
557
557
|
*/
|
|
558
558
|
function warnSnapshotOverCapOnce(
|
|
559
559
|
key: string,
|
|
@@ -565,14 +565,10 @@ function warnSnapshotOverCapOnce(
|
|
|
565
565
|
key,
|
|
566
566
|
() =>
|
|
567
567
|
`[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
|
|
568
|
-
`loader pins, over the ${capBytes}-byte cap
|
|
569
|
-
"
|
|
570
|
-
"
|
|
571
|
-
"
|
|
572
|
-
"mismatch client-side. Raise the cap via the route's ppr option " +
|
|
573
|
-
"({ maxSnapshotBytes }) if the entry still fits your store's value limit " +
|
|
574
|
-
"(Cloudflare KV: 25 MiB per value), or shrink the cache()'d data the " +
|
|
575
|
-
"loaders read.",
|
|
568
|
+
`ssr: false loader pins, over the ${capBytes}-byte cap: the document ` +
|
|
569
|
+
"shell was not stored; raise maxSnapshotBytes or shrink the ssr: false " +
|
|
570
|
+
"data. Documents of this route are a MISS until then; client " +
|
|
571
|
+
"navigations still replay its handler layer.",
|
|
576
572
|
);
|
|
577
573
|
}
|
|
578
574
|
|
|
@@ -678,7 +674,11 @@ export interface ShellCaptureDebugEvent {
|
|
|
678
674
|
preludeBytes?: number;
|
|
679
675
|
/** Serialized snapshot size in UTF-8 bytes. Absent when nothing was recorded. */
|
|
680
676
|
snapshotBytes?: number;
|
|
681
|
-
/**
|
|
677
|
+
/**
|
|
678
|
+
* True when the loader pins exceeded maxSnapshotBytes: no document shell
|
|
679
|
+
* was stored, only a navigation-only entry (the doc record, or a tombstone
|
|
680
|
+
* for a prerender-served capture). The outcome is `refused`, refusal `size`.
|
|
681
|
+
*/
|
|
682
682
|
snapshotSkipped?: boolean;
|
|
683
683
|
/** A bake-lane loader settled into a shell that uses TTL/SWR-only invalidation. */
|
|
684
684
|
untaggedBake?: true;
|
|
@@ -1048,8 +1048,8 @@ export interface ShellCaptureDescriptor {
|
|
|
1048
1048
|
captureTimeout?: number;
|
|
1049
1049
|
store?: SegmentCacheStore<any>;
|
|
1050
1050
|
/**
|
|
1051
|
-
* Cap (serialized UTF-8 bytes) on the entry's
|
|
1052
|
-
*
|
|
1051
|
+
* Cap (serialized UTF-8 bytes) on the entry's loader pins; over it only the
|
|
1052
|
+
* navigation half is stored and the attempt refuses (reported once per
|
|
1053
1053
|
* key). Absent = DEFAULT_PPR_MAX_SNAPSHOT_BYTES, applied in
|
|
1054
1054
|
* captureAndStoreShell — the single defaulting site.
|
|
1055
1055
|
*/
|
|
@@ -1667,8 +1667,9 @@ export type CaptureRecordOutcome =
|
|
|
1667
1667
|
* component showed one value in the prelude and another on every HIT.
|
|
1668
1668
|
*
|
|
1669
1669
|
* Order, all bounded by the one capture deadline (`ppr.captureTimeout`):
|
|
1670
|
-
* top-level handle promises
|
|
1671
|
-
*
|
|
1670
|
+
* top-level handle promises (a bake-lane loader's pushed promises among
|
|
1671
|
+
* them, #1182), the handler pushes' nested promises, and the bake-lane
|
|
1672
|
+
* loader containers settle first (the record encodes the handles
|
|
1672
1673
|
* a HIT restores, and its handle encode has its own 5s timeout that must
|
|
1673
1674
|
* never be the one that fires); then the capture's onResponse callbacks fire
|
|
1674
1675
|
* with a synthetic 200 (the implicit doc scope's cacheRoute, an explicit
|
|
@@ -1711,7 +1712,33 @@ export async function settleCaptureRecord(
|
|
|
1711
1712
|
if (!settled.done) {
|
|
1712
1713
|
if (refuseOnCaptureGuard(capture.key, derivedCtx))
|
|
1713
1714
|
return { kind: "refused", refusal: guardRefusal(derivedCtx) };
|
|
1714
|
-
|
|
1715
|
+
const pending = derivation.loaderPromisePushes.find(
|
|
1716
|
+
(p) => p.state === "pending",
|
|
1717
|
+
);
|
|
1718
|
+
return {
|
|
1719
|
+
kind: "timeout",
|
|
1720
|
+
reason: pending
|
|
1721
|
+
? `${describeLoaderPromisePush(pending)} did not settle within ppr.captureTimeout`
|
|
1722
|
+
: HANDLER_OUTPUT_TIMEOUT_REASON,
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
// A rejected value must not bake, and the record's encode would fail on
|
|
1726
|
+
// it anyway: refuse before the write starts. A guard trip in that promise
|
|
1727
|
+
// names the read instead.
|
|
1728
|
+
const rejected = derivation.loaderPromisePushes.find(
|
|
1729
|
+
(p) => p.state === "rejected",
|
|
1730
|
+
);
|
|
1731
|
+
if (rejected) {
|
|
1732
|
+
if (refuseOnCaptureGuard(capture.key, derivedCtx))
|
|
1733
|
+
return { kind: "refused", refusal: guardRefusal(derivedCtx) };
|
|
1734
|
+
warnCaptureRefusedOnce(
|
|
1735
|
+
capture.key,
|
|
1736
|
+
`${describeLoaderPromisePush(rejected)} rejected during capture; a ` +
|
|
1737
|
+
"failed value must not bake into the shared shell. Catch it and push " +
|
|
1738
|
+
"a fallback, or push it from a loader without ssr: false.",
|
|
1739
|
+
PPR_LANE_HINT,
|
|
1740
|
+
);
|
|
1741
|
+
return { kind: "refused", refusal: "loader" };
|
|
1715
1742
|
}
|
|
1716
1743
|
|
|
1717
1744
|
const callbacks = derivedCtx._onResponseCallbacks?.splice(0) ?? [];
|
|
@@ -2006,6 +2033,20 @@ export interface CaptureContextDerivation {
|
|
|
2006
2033
|
* record encodes its handles, so that encode never races a pending value.
|
|
2007
2034
|
*/
|
|
2008
2035
|
handlerPushesSettled: () => Promise<void>;
|
|
2036
|
+
/**
|
|
2037
|
+
* Every promise a bake-lane loader body pushed (#1182), in push order.
|
|
2038
|
+
* The record-first step waits for them (they are in the recorded cut) and
|
|
2039
|
+
* names the loader and the handle of one that rejects or misses the
|
|
2040
|
+
* deadline.
|
|
2041
|
+
*/
|
|
2042
|
+
loaderPromisePushes: readonly LoaderPromisePush[];
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/** A promise a bake-lane loader pushed at capture, owned by that loader. */
|
|
2046
|
+
export interface LoaderPromisePush {
|
|
2047
|
+
loaderId: string;
|
|
2048
|
+
handleName: string;
|
|
2049
|
+
state: "pending" | "fulfilled" | "rejected";
|
|
2009
2050
|
}
|
|
2010
2051
|
|
|
2011
2052
|
/**
|
|
@@ -2041,30 +2082,35 @@ export function deriveShellCaptureContext(
|
|
|
2041
2082
|
// masked), their pushes are in the captured HTML (<head> title/meta,
|
|
2042
2083
|
// useHandle echoes), and a HIT does not run a promise-free bake-lane
|
|
2043
2084
|
// loader (loader-cache.ts), so the stored record carries them: each
|
|
2044
|
-
//
|
|
2045
|
-
// `owner` (HandleStore.push), the record stores it
|
|
2085
|
+
// push from a loader body whose value, once settled, is thenable-free
|
|
2086
|
+
// keeps that loader as `owner` (HandleStore.push), the record stores it
|
|
2046
2087
|
// (CachedEntryData.handleOwners), and a replay that serves the loader
|
|
2047
|
-
// from its pin restores it through pushRestored (restoreHandles).
|
|
2048
|
-
//
|
|
2049
|
-
//
|
|
2050
|
-
//
|
|
2051
|
-
//
|
|
2052
|
-
//
|
|
2053
|
-
//
|
|
2054
|
-
//
|
|
2055
|
-
//
|
|
2056
|
-
//
|
|
2057
|
-
//
|
|
2058
|
-
//
|
|
2059
|
-
//
|
|
2060
|
-
//
|
|
2061
|
-
//
|
|
2062
|
-
//
|
|
2063
|
-
//
|
|
2064
|
-
//
|
|
2065
|
-
//
|
|
2088
|
+
// from its pin restores it through pushRestored (restoreHandles). A
|
|
2089
|
+
// pushed promise (push(promise), .defer()) is judged by its settled
|
|
2090
|
+
// value, as push(await promise) would be (#1182): the record-first step
|
|
2091
|
+
// waits for it under the capture deadline, and one that rejects or
|
|
2092
|
+
// misses the deadline refuses the capture. Those copies stand: a
|
|
2093
|
+
// promise-carrying bake-lane loader that runs on the replay reads the
|
|
2094
|
+
// store, so its pushes that settle thenable-free (and those replayed
|
|
2095
|
+
// inside its body) are dropped and only the ones holding a promise are
|
|
2096
|
+
// added. A loader the replay does not serve from a pin is a hole: one
|
|
2097
|
+
// the route also runs on the live lane, or one whose pin the entry
|
|
2098
|
+
// lost. Its run replaces its copies (pushPlaceholder, #936), those of
|
|
2099
|
+
// the dependencies it awaits included (they are credited to it). Nested
|
|
2100
|
+
// thenables stay masked (the promise shape is the liveness declaration,
|
|
2101
|
+
// mask-nested.ts). A push whose settled value holds a masked nested
|
|
2102
|
+
// promise, one credited to a live-lane body, or one no body or replay
|
|
2103
|
+
// names (a loader-cache replay names its loader: pushReplayed below)
|
|
2104
|
+
// stays tagged (HandleStore.push loaderPush) so the handle encode cannot
|
|
2105
|
+
// stall on a never-resolving mask. A tagged push is out of the record
|
|
2106
|
+
// (captureHandles) and out of the capture's own render
|
|
2107
|
+
// (resolvedHandleStream `recordedOnly`, issue #1035): the prelude is
|
|
2108
|
+
// rendered from what the record keeps, which is what a HIT hydrates
|
|
2109
|
+
// with. The loader records then carry `runs: 1` so a HIT runs those
|
|
2110
|
+
// bodies for them, and what they push reaches the client after
|
|
2066
2111
|
// hydration (HandleStore.freezeDocumentSnapshot).
|
|
2067
2112
|
const handlerPushSettles: Promise<void>[] = [];
|
|
2113
|
+
const loaderPromisePushes: LoaderPromisePush[] = [];
|
|
2068
2114
|
// A replay (a loader-cache HIT, loader-cache.ts replayLoaderHandles, or a
|
|
2069
2115
|
// route cache() record's owned values, restoreHandles) re-pushes a loader's
|
|
2070
2116
|
// recorded values through pushReplayed or pushPlaceholder, which call
|
|
@@ -2118,39 +2164,62 @@ export function deriveShellCaptureContext(
|
|
|
2118
2164
|
if (report.thenable) maskedNestedThenable = true;
|
|
2119
2165
|
return masked;
|
|
2120
2166
|
};
|
|
2167
|
+
// The loader the push is credited to (see the funnel comment above). A
|
|
2168
|
+
// push under a live-lane loader's body, at any depth, is that hole's
|
|
2169
|
+
// output: its live run replaces it on a HIT and a navigation. Otherwise
|
|
2170
|
+
// the first loader around the push that the route registers decides: a
|
|
2171
|
+
// bake-lane one keeps the push under the innermost body, and a
|
|
2172
|
+
// dependency on neither lane is walked past. A replay of a hole's push
|
|
2173
|
+
// is that hole's, unless a live-lane body around it takes it first.
|
|
2174
|
+
const live = liveLaneOwner();
|
|
2175
|
+
const bodyLoaderId = live ?? getCurrentLoaderBodyId() ?? replayOwner;
|
|
2121
2176
|
let masked: unknown;
|
|
2122
|
-
let loaderPush = true;
|
|
2123
2177
|
let owner: string | undefined;
|
|
2124
|
-
if (isThenable(value)) {
|
|
2125
|
-
// Deferred (thenable) loader pushes always keep the tag — the
|
|
2126
|
-
// carve-out below is for settled values only. Promise.resolve first: a
|
|
2127
|
-
// loader cache() entry replays a deferred push as the thenable Flight
|
|
2128
|
-
// decoded it to, whose then() returns nothing, and the slot was
|
|
2129
|
-
// `undefined` (the empty element of issue #1035).
|
|
2130
|
-
masked = Promise.resolve(value).then(mask);
|
|
2131
|
-
} else {
|
|
2178
|
+
if (!isThenable(value)) {
|
|
2132
2179
|
masked = mask(value);
|
|
2133
|
-
if (!maskedNestedThenable)
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2180
|
+
if (!maskedNestedThenable) owner = bodyLoaderId;
|
|
2181
|
+
} else if (live === undefined && bodyLoaderId !== undefined) {
|
|
2182
|
+
// Owned now, judged by its settled value (funnel comment above).
|
|
2183
|
+
// Promise.resolve first: a loader cache() entry replays a deferred
|
|
2184
|
+
// push as the thenable Flight decoded it to, whose then() returns
|
|
2185
|
+
// nothing (issue #1035). The retag runs inside the slot's own then(),
|
|
2186
|
+
// so it lands before the wait on it (settleCaptureRecord) resumes and
|
|
2187
|
+
// the record encodes. The state handler is attached now, which also
|
|
2188
|
+
// marks a rejection as handled.
|
|
2189
|
+
const slot: Promise<unknown> = Promise.resolve(value).then((settled) => {
|
|
2190
|
+
const judged = mask(settled);
|
|
2191
|
+
if (maskedNestedThenable) {
|
|
2192
|
+
freshHandleStore.unrecordPush(handleName, segmentId, slot);
|
|
2193
|
+
derivedCtx._shellCaptureUnrecordedLoaderPush = true;
|
|
2194
|
+
}
|
|
2195
|
+
return judged;
|
|
2196
|
+
});
|
|
2197
|
+
const push: LoaderPromisePush = {
|
|
2198
|
+
loaderId: bodyLoaderId,
|
|
2199
|
+
handleName,
|
|
2200
|
+
state: "pending",
|
|
2201
|
+
};
|
|
2202
|
+
slot.then(
|
|
2203
|
+
() => {
|
|
2204
|
+
push.state = "fulfilled";
|
|
2205
|
+
},
|
|
2206
|
+
() => {
|
|
2207
|
+
push.state = "rejected";
|
|
2208
|
+
},
|
|
2209
|
+
);
|
|
2210
|
+
loaderPromisePushes.push(push);
|
|
2211
|
+
masked = slot;
|
|
2212
|
+
owner = bodyLoaderId;
|
|
2213
|
+
} else {
|
|
2214
|
+
// A hole's promise, or one no body or replay names: tagged. Nothing
|
|
2215
|
+
// awaits the slot at capture, so its rejection is handled here.
|
|
2216
|
+
const tagged = Promise.resolve(value).then(mask);
|
|
2217
|
+
tagged.catch(() => {});
|
|
2218
|
+
masked = tagged;
|
|
2151
2219
|
}
|
|
2152
2220
|
// A push the record cannot keep reaches a HIT only from a live run of
|
|
2153
2221
|
// its loader, so the loader records ask for one (runs: 1).
|
|
2222
|
+
const loaderPush = owner === undefined;
|
|
2154
2223
|
if (loaderPush) derivedCtx._shellCaptureUnrecordedLoaderPush = true;
|
|
2155
2224
|
rawCapturePush(handleName, segmentId, masked, loaderPush, owner);
|
|
2156
2225
|
};
|
|
@@ -2271,6 +2340,7 @@ export function deriveShellCaptureContext(
|
|
|
2271
2340
|
freshHandleStore,
|
|
2272
2341
|
handlerPushesSettled: () =>
|
|
2273
2342
|
Promise.all(handlerPushSettles).then(() => undefined),
|
|
2343
|
+
loaderPromisePushes,
|
|
2274
2344
|
};
|
|
2275
2345
|
}
|
|
2276
2346
|
|
|
@@ -2481,10 +2551,13 @@ async function captureAndStoreShell(
|
|
|
2481
2551
|
runs,
|
|
2482
2552
|
},
|
|
2483
2553
|
});
|
|
2554
|
+
} else {
|
|
2555
|
+
throw new Error(`[rango] no shell pin for "${segmentKey}"`);
|
|
2484
2556
|
}
|
|
2485
|
-
} catch {
|
|
2486
|
-
//
|
|
2487
|
-
//
|
|
2557
|
+
} catch (error) {
|
|
2558
|
+
// Unpinned, the loader would run on every HIT over this prelude
|
|
2559
|
+
// (dc-t89): refuse like an encode error.
|
|
2560
|
+
reqCtx._renderErrors?.push(error);
|
|
2488
2561
|
}
|
|
2489
2562
|
}
|
|
2490
2563
|
}
|
|
@@ -2528,12 +2601,11 @@ async function captureAndStoreShell(
|
|
|
2528
2601
|
snapshot = kept;
|
|
2529
2602
|
}
|
|
2530
2603
|
|
|
2531
|
-
// Snapshot size guard
|
|
2532
|
-
//
|
|
2533
|
-
//
|
|
2534
|
-
//
|
|
2535
|
-
//
|
|
2536
|
-
// bounded separately below, by the store's value limit.
|
|
2604
|
+
// Snapshot size guard: `maxSnapshotBytes` bounds the loader pins, and a
|
|
2605
|
+
// document HIT needs every pin its prelude baked (dc-t89). Over the cap
|
|
2606
|
+
// the entry keeps the navigation half only (the doc record, or nothing:
|
|
2607
|
+
// a tombstone that also shadows a build shell) and the attempt refuses.
|
|
2608
|
+
// The whole entry is bounded separately below, by the store's limit.
|
|
2537
2609
|
if (snapshot.length > 0) {
|
|
2538
2610
|
const docRecord =
|
|
2539
2611
|
docKey !== undefined
|
|
@@ -2554,9 +2626,10 @@ async function captureAndStoreShell(
|
|
|
2554
2626
|
stats.snapshotSkipped = true;
|
|
2555
2627
|
}
|
|
2556
2628
|
}
|
|
2629
|
+
const navigationOnly = capture.navigationOnly || stats.snapshotSkipped;
|
|
2557
2630
|
// Exempt from maxSnapshotBytes: the HIT needs it for its HTML and
|
|
2558
2631
|
// hydration data to match.
|
|
2559
|
-
if (prerenderHandles) {
|
|
2632
|
+
if (prerenderHandles && !navigationOnly) {
|
|
2560
2633
|
snapshot.push({
|
|
2561
2634
|
family: "handles",
|
|
2562
2635
|
key: SHELL_HANDLES_RECORD_KEY,
|
|
@@ -2605,8 +2678,8 @@ async function captureAndStoreShell(
|
|
|
2605
2678
|
// JSON head as an escaped string.
|
|
2606
2679
|
const entryBytes = estimateShellEntryBytes({
|
|
2607
2680
|
// A navigation-only entry stores no document half.
|
|
2608
|
-
preludeBytes:
|
|
2609
|
-
postponed:
|
|
2681
|
+
preludeBytes: navigationOnly ? 0 : result.prelude.length,
|
|
2682
|
+
postponed: navigationOnly ? undefined : result.postponed,
|
|
2610
2683
|
snapshot,
|
|
2611
2684
|
tags: shellTags,
|
|
2612
2685
|
docKey: storedDocKey,
|
|
@@ -2642,7 +2715,7 @@ async function captureAndStoreShell(
|
|
|
2642
2715
|
// consumes only snapshot/docKey), so storing it would ride every KV
|
|
2643
2716
|
// write/read as dead weight — the prerender still ran above as the
|
|
2644
2717
|
// completeness arbiter and sanity gate, its output is dropped here.
|
|
2645
|
-
...(
|
|
2718
|
+
...(navigationOnly
|
|
2646
2719
|
? {}
|
|
2647
2720
|
: {
|
|
2648
2721
|
// bytesToBase64 encodes the view's own region, so a prelude
|
|
@@ -2665,7 +2738,7 @@ async function captureAndStoreShell(
|
|
|
2665
2738
|
// looks the record up by this key (serveShellHit fixedDocKey);
|
|
2666
2739
|
// undefined only for a prerender-served entry.
|
|
2667
2740
|
docKey: storedDocKey,
|
|
2668
|
-
navigationOnly:
|
|
2741
|
+
navigationOnly: navigationOnly || undefined,
|
|
2669
2742
|
createdAt: captureStartedAt,
|
|
2670
2743
|
};
|
|
2671
2744
|
// Capped to the route record the capture used: the policy the
|
|
@@ -2753,8 +2826,9 @@ async function captureAndStoreShell(
|
|
|
2753
2826
|
}
|
|
2754
2827
|
// A shell was captured (ordinary store I/O failure is reported, not retried).
|
|
2755
2828
|
// An acknowledged invalidation rejection returned `refused` above so the
|
|
2756
|
-
// scheduler backs the key off instead of recapturing on every request
|
|
2757
|
-
|
|
2829
|
+
// scheduler backs the key off instead of recapturing on every request, as
|
|
2830
|
+
// does an over-cap entry: no document shell was stored.
|
|
2831
|
+
return stats.snapshotSkipped ? refuse(stats, "size") : "stored";
|
|
2758
2832
|
} finally {
|
|
2759
2833
|
// Stop the hop loop for the pathological never-quiets path (quiesce never
|
|
2760
2834
|
// fired, capture returned via maxWaitMs). On the normal path the loop already
|
|
@@ -2774,7 +2848,8 @@ export {
|
|
|
2774
2848
|
};
|
|
2775
2849
|
|
|
2776
2850
|
// Exported for unit tests that pin the refused-capture backoff policy directly
|
|
2777
|
-
// (dev cap vs production exponential growth, stored-clears, cold-start re-probe)
|
|
2851
|
+
// (dev cap vs production exponential growth, stored-clears, cold-start re-probe),
|
|
2852
|
+
// and markCaptureBackoff for serveShellHit (a pin that failed to decode, dc-t89).
|
|
2778
2853
|
// These are the same module-level functions the schedule path uses; a test that
|
|
2779
2854
|
// drove them through a real capture round-trip could not assert the exact window
|
|
2780
2855
|
// arithmetic without a full cold render.
|