@rangojs/router 0.19.1 → 0.21.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/dist/bin/rango.js +26 -14
- package/dist/types/browser/event-controller.d.ts +32 -0
- package/dist/types/browser/history-state.d.ts +16 -4
- package/dist/types/browser/navigation-transaction.d.ts +7 -0
- package/dist/types/browser/rango-state.d.ts +3 -1
- package/dist/types/browser/react/context.d.ts +11 -0
- package/dist/types/browser/react/location-state-shared.d.ts +84 -10
- package/dist/types/browser/react/location-state.d.ts +7 -2
- package/dist/types/browser/react/subscribe-to-nothing.d.ts +7 -0
- package/dist/types/cache/cache-scope.d.ts +4 -5
- package/dist/types/cache/cf/cf-cache-store.d.ts +27 -6
- package/dist/types/cache/cf/cf-cache-types.d.ts +12 -4
- package/dist/types/cache/handle-snapshot.d.ts +36 -50
- package/dist/types/cache/index.d.ts +2 -0
- package/dist/types/cache/shell-snapshot.d.ts +19 -3
- package/dist/types/cache/types.d.ts +21 -10
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +28 -16
- package/dist/types/client-urls/navigation.d.ts +7 -1
- package/dist/types/client-urls/optimistic-location.d.ts +10 -4
- package/dist/types/handles/deferred-resolution.d.ts +11 -1
- package/dist/types/prerender/build-shell-capture.d.ts +8 -6
- package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
- package/dist/types/router/navigation-snapshot.d.ts +8 -1
- package/dist/types/router/router-interfaces.d.ts +2 -1
- package/dist/types/router/router-options.d.ts +17 -7
- package/dist/types/router/segment-resolution/loader-cache.d.ts +21 -9
- package/dist/types/router-versions.d.ts +39 -0
- package/dist/types/rsc/handler-context.d.ts +5 -0
- package/dist/types/rsc/shell-build-manifest.d.ts +3 -2
- package/dist/types/rsc/types.d.ts +4 -6
- package/dist/types/server/build-version-table.d.ts +44 -0
- package/dist/types/server/handle-store.d.ts +87 -46
- package/dist/types/server/request-context.d.ts +22 -8
- package/dist/types/ssr/entry-preload-priority.d.ts +69 -0
- package/dist/types/ssr/index.d.ts +1 -0
- package/dist/types/ssr/preinit-client-references.d.ts +17 -5
- package/dist/types/testing/build-versions.d.ts +32 -0
- package/dist/types/testing/dom.entry.d.ts +1 -1
- package/dist/types/testing/flight.entry.d.ts +1 -1
- package/dist/types/testing/index.d.ts +2 -0
- package/dist/types/testing/internal/seed-vars.d.ts +7 -2
- package/dist/types/testing/location-state-key.d.ts +5 -1
- package/dist/types/testing/render-route.d.ts +77 -6
- package/dist/types/testing/serve-shell-request.d.ts +21 -0
- package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
- package/dist/types/types/handler-context.d.ts +8 -5
- package/dist/types/vite/discovery/build-versions.d.ts +393 -0
- package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
- package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
- package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
- package/dist/types/vite/discovery/state.d.ts +14 -0
- package/dist/types/vite/encryption-key.d.ts +18 -0
- package/dist/types/vite/plugin-types.d.ts +47 -6
- package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
- package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
- package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
- package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
- package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
- package/dist/types/vite/plugins/virtual-entries.d.ts +21 -2
- package/dist/types/vite/router-discovery.d.ts +6 -0
- package/dist/types/vite/utils/client-chunks.d.ts +24 -2
- package/dist/types/vite/utils/compare-strings.d.ts +7 -0
- package/dist/types/vite/utils/package-resolution.d.ts +8 -0
- package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
- package/dist/vite/index.js +1544 -420
- package/package.json +1 -2
- package/skills/bundle-analysis/SKILL.md +1 -1
- package/skills/cache-guide/SKILL.md +84 -20
- package/skills/caching/SKILL.md +16 -5
- package/skills/client-urls/SKILL.md +16 -0
- package/skills/cloudflare/SKILL.md +2 -1
- package/skills/comparison/references/framework-comparison.md +16 -12
- package/skills/hooks/SKILL.md +3 -0
- package/skills/hooks/handle-and-actions.md +6 -0
- package/skills/hooks/navigation.md +28 -0
- package/skills/hooks/state.md +209 -8
- package/skills/hooks/urls.md +40 -1
- package/skills/host-router/SKILL.md +13 -0
- package/skills/intercept/SKILL.md +6 -0
- package/skills/loader/SKILL.md +19 -1
- package/skills/middleware/SKILL.md +2 -0
- package/skills/ppr/SKILL.md +119 -40
- package/skills/prerender/SKILL.md +7 -3
- package/skills/rango/SKILL.md +8 -7
- package/skills/router-setup/SKILL.md +23 -3
- package/skills/testing/SKILL.md +30 -26
- package/skills/testing/client-components.md +193 -8
- package/skills/testing/handles.md +2 -0
- package/skills/testing/loader.md +1 -1
- package/skills/testing/middleware.md +1 -1
- package/skills/testing/render-handler.md +18 -18
- package/skills/testing/serve-shell-request.md +63 -14
- package/skills/testing/server-actions.md +17 -17
- package/skills/testing/server-tree.md +1 -1
- package/skills/typesafety/generated-files-and-cli.md +1 -1
- package/skills/use-cache/SKILL.md +16 -5
- package/skills/vercel/SKILL.md +3 -3
- package/src/bin/rango.ts +1 -1
- package/src/browser/event-controller.ts +75 -2
- package/src/browser/history-state.ts +39 -17
- package/src/browser/navigation-bridge.ts +41 -18
- package/src/browser/navigation-transaction.ts +15 -10
- package/src/browser/partial-update.ts +10 -2
- package/src/browser/rango-state.ts +3 -1
- package/src/browser/react/Link.tsx +2 -3
- package/src/browser/react/NavigationProvider.tsx +25 -1
- package/src/browser/react/context.ts +13 -0
- package/src/browser/react/location-state-shared.ts +200 -34
- package/src/browser/react/location-state.ts +40 -75
- package/src/browser/react/subscribe-to-nothing.ts +7 -0
- package/src/browser/react/use-handle.ts +20 -1
- package/src/browser/rsc-router.tsx +33 -11
- package/src/browser/server-action-bridge.ts +17 -1
- package/src/cache/cache-scope.ts +18 -8
- package/src/cache/cf/cf-cache-store.ts +75 -55
- package/src/cache/cf/cf-cache-types.ts +12 -4
- package/src/cache/handle-snapshot.ts +69 -87
- package/src/cache/index.ts +4 -0
- package/src/cache/shell-snapshot.ts +35 -5
- package/src/cache/types.ts +21 -10
- package/src/cache/vercel/vercel-cache-store.ts +48 -20
- package/src/client-urls/client-root.tsx +1 -0
- package/src/client-urls/navigation.ts +10 -0
- package/src/client-urls/optimistic-location.ts +10 -4
- package/src/handles/deferred-resolution.ts +31 -0
- package/src/prerender/build-shell-capture.ts +11 -9
- package/src/router/manifest.ts +2 -0
- package/src/router/match-middleware/cache-lookup.ts +11 -18
- package/src/router/navigation-snapshot.ts +25 -6
- package/src/router/request-classification.ts +15 -12
- package/src/router/router-interfaces.ts +2 -1
- package/src/router/router-options.ts +17 -7
- package/src/router/segment-resolution/loader-cache.ts +95 -60
- package/src/router-versions.ts +60 -0
- package/src/rsc/full-payload.ts +6 -2
- package/src/rsc/handler-context.ts +5 -0
- package/src/rsc/handler.ts +14 -4
- package/src/rsc/rsc-rendering.ts +12 -1
- package/src/rsc/shell-build-manifest.ts +10 -7
- package/src/rsc/shell-capture.ts +36 -26
- package/src/rsc/types.ts +4 -6
- package/src/server/build-version-table.ts +115 -0
- package/src/server/handle-store.ts +344 -127
- package/src/server/request-context.ts +62 -11
- package/src/ssr/entry-preload-priority.ts +308 -0
- package/src/ssr/index.tsx +46 -9
- package/src/ssr/preinit-client-references.ts +17 -5
- package/src/ssr/ssr-root.tsx +16 -4
- package/src/testing/build-versions.ts +64 -0
- package/src/testing/dispatch.ts +6 -0
- package/src/testing/dom.entry.ts +1 -0
- package/src/testing/flight.entry.ts +1 -0
- package/src/testing/index.ts +3 -0
- package/src/testing/internal/context.ts +2 -1
- package/src/testing/internal/seed-vars.ts +14 -2
- package/src/testing/location-state-key.ts +18 -8
- package/src/testing/render-handler.ts +2 -1
- package/src/testing/render-route.tsx +277 -43
- package/src/testing/serve-shell-request.ts +80 -11
- package/src/testing/vitest-stubs/version.ts +4 -1
- package/src/types/handler-context.ts +8 -5
- package/src/vite/discovery/build-versions.ts +1281 -0
- package/src/vite/discovery/installed-packages.ts +159 -0
- package/src/vite/discovery/prerender-collection.ts +38 -1
- package/src/vite/discovery/router-versions-phase.ts +336 -0
- package/src/vite/discovery/shell-prerender-phase.ts +28 -38
- package/src/vite/discovery/state.ts +19 -0
- package/src/vite/discovery/virtual-module-codegen.ts +7 -2
- package/src/vite/encryption-key.ts +101 -9
- package/src/vite/plugin-types.ts +48 -6
- package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
- package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
- package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
- package/src/vite/plugins/expose-internal-ids.ts +12 -3
- package/src/vite/plugins/stable-rsc-output.ts +147 -0
- package/src/vite/plugins/version-plugin.ts +19 -24
- package/src/vite/plugins/version.d.ts +17 -2
- package/src/vite/plugins/virtual-entries.ts +40 -7
- package/src/vite/rango.ts +30 -3
- package/src/vite/router-discovery.ts +82 -13
- package/src/vite/utils/client-chunks.ts +84 -14
- package/src/vite/utils/compare-strings.ts +9 -0
- package/src/vite/utils/package-resolution.ts +9 -0
- package/src/vite/utils/prerender-utils.ts +4 -1
- package/src/vite/utils/shared-utils.ts +1 -1
|
@@ -47,6 +47,7 @@ import { type MetricsStore } from "./context.js";
|
|
|
47
47
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
48
48
|
import { getFetchableLoader } from "./fetchable-loader-store.js";
|
|
49
49
|
import type { SegmentCacheStore } from "../cache/types.js";
|
|
50
|
+
import type { RouterVersions } from "../router-versions.js";
|
|
50
51
|
import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
|
|
51
52
|
import type { ExecutionContext, RequestScope } from "../types/request-scope.js";
|
|
52
53
|
import type { TransitionWhenRecord } from "../router/transition-when.js";
|
|
@@ -377,7 +378,7 @@ export interface RequestContext<
|
|
|
377
378
|
|
|
378
379
|
/**
|
|
379
380
|
* @internal Loader-family snapshot seed for a shell HIT's tail render:
|
|
380
|
-
*
|
|
381
|
+
* loader id -> the capture's elided container (already Flight-deserialized
|
|
381
382
|
* by serveShellHit) plus its capture-computed hole bit. resolveLoaderData
|
|
382
383
|
* overlays it onto the fresh run's container (recorded paths pinned,
|
|
383
384
|
* hole-marker paths keep the fresh nested promises) so the payload's baked
|
|
@@ -385,7 +386,9 @@ export interface RequestContext<
|
|
|
385
386
|
* and does not run the loader. Two owners: serveShellHit sets it on the
|
|
386
387
|
* HIT tail's derived context, and matchPartialWithPprReplay sets it on the
|
|
387
388
|
* request context of a PPR partial replay once its doc record hits, and
|
|
388
|
-
* restores the previous value after the match.
|
|
389
|
+
* restores the previous value after the match. Read through servedPins
|
|
390
|
+
* (loader-cache.ts) only: it also decides which of the record's
|
|
391
|
+
* loader-owned handle pushes stand (loaderPins).
|
|
389
392
|
*/
|
|
390
393
|
_shellLoaderSeed?: Map<
|
|
391
394
|
string,
|
|
@@ -446,9 +449,7 @@ export interface RequestContext<
|
|
|
446
449
|
* scope replaces any route-derived cache() scope for the tail's match
|
|
447
450
|
* (resolveShellImplicitCacheScope), and a lookup that does not hit throws
|
|
448
451
|
* ShellRecordUnavailableError instead of resolving segments
|
|
449
|
-
* (withCacheLookup): a HIT never runs a handler.
|
|
450
|
-
* loader-owned handle pushes restore as authoritative
|
|
451
|
-
* (HandleStore.pushRestored): the prelude rendered them.
|
|
452
|
+
* (withCacheLookup): a HIT never runs a handler.
|
|
452
453
|
*/
|
|
453
454
|
docTail?: true;
|
|
454
455
|
/**
|
|
@@ -857,6 +858,15 @@ export interface RequestContext<
|
|
|
857
858
|
* lookups per router (route-map-builder.ts) */
|
|
858
859
|
_routerId?: string;
|
|
859
860
|
|
|
861
|
+
/**
|
|
862
|
+
* @internal Cache versions of the router serving this request. The cache
|
|
863
|
+
* factory builds a store per request without knowing the router, so a store
|
|
864
|
+
* reads its key versions from here, lazily, the same way CFCacheStore
|
|
865
|
+
* resolves its base URL (getCacheVersions, server/build-version-table.ts).
|
|
866
|
+
* Unset on a context no router created; a store then uses the build version.
|
|
867
|
+
*/
|
|
868
|
+
_versions?: RouterVersions;
|
|
869
|
+
|
|
860
870
|
/**
|
|
861
871
|
* @internal RouteSnapshot from classifyRequest, reused by match/matchPartial
|
|
862
872
|
* to avoid a second resolveRoute call. Cleared on HMR invalidation.
|
|
@@ -966,6 +976,7 @@ export type PublicRequestContext<
|
|
|
966
976
|
| "_tracing"
|
|
967
977
|
| "_basename"
|
|
968
978
|
| "_routerId"
|
|
979
|
+
| "_versions"
|
|
969
980
|
| "_setStatus"
|
|
970
981
|
| "_rotateStateCookie"
|
|
971
982
|
| "_setKeepCacheDirective"
|
|
@@ -1130,8 +1141,13 @@ export interface CreateRequestContextOptions<TEnv> {
|
|
|
1130
1141
|
themeConfig?: ResolvedThemeConfig | null;
|
|
1131
1142
|
/** Resolved rango state cookie name, for the server seat of invalidateClientCache(). */
|
|
1132
1143
|
stateCookieName?: string;
|
|
1133
|
-
/**
|
|
1134
|
-
|
|
1144
|
+
/**
|
|
1145
|
+
* The versions of the router serving this request (resolveRouterVersions).
|
|
1146
|
+
* Stored as `_versions` for the cache stores; `document` is also the prefix
|
|
1147
|
+
* of a server-rotated rango state value, which the browser compares with the
|
|
1148
|
+
* payload's `metadata.version`.
|
|
1149
|
+
*/
|
|
1150
|
+
versions?: RouterVersions;
|
|
1135
1151
|
}
|
|
1136
1152
|
|
|
1137
1153
|
/**
|
|
@@ -1159,7 +1175,7 @@ export function createRequestContext<TEnv>(
|
|
|
1159
1175
|
build = false,
|
|
1160
1176
|
themeConfig,
|
|
1161
1177
|
stateCookieName,
|
|
1162
|
-
|
|
1178
|
+
versions,
|
|
1163
1179
|
} = options;
|
|
1164
1180
|
// ctx.request.headers is guarded like headers() (#976); every router read
|
|
1165
1181
|
// of this request's headers goes through requestHeaders().
|
|
@@ -1194,6 +1210,12 @@ export function createRequestContext<TEnv>(
|
|
|
1194
1210
|
// Set-Cookie drain (rsc/helpers.ts) run outside any latched funnel scope,
|
|
1195
1211
|
// where assertCachedHeaderWriteAllowed is a no-op.
|
|
1196
1212
|
let rawStubHeaders = stubResponse.headers;
|
|
1213
|
+
// Node's util.inspect unwraps this Proxy and calls undici's Headers hook
|
|
1214
|
+
// with the proxy as `this`. That reads `#headersList` and throws (#1000).
|
|
1215
|
+
// Inspect does not consult the get trap, so the hook is an own property of
|
|
1216
|
+
// the target and formats `rawStubHeaders`. Read on each call: setStatus
|
|
1217
|
+
// rebinds that let to a new Headers.
|
|
1218
|
+
const NODE_INSPECT = Symbol.for("nodejs.util.inspect.custom");
|
|
1197
1219
|
const guardedStubHeaders: Headers = new Proxy(new Headers(), {
|
|
1198
1220
|
get(_target, prop) {
|
|
1199
1221
|
const raw = rawStubHeaders;
|
|
@@ -1208,6 +1230,22 @@ export function createRequestContext<TEnv>(
|
|
|
1208
1230
|
return (value as (...a: unknown[]) => unknown).bind(raw);
|
|
1209
1231
|
},
|
|
1210
1232
|
});
|
|
1233
|
+
Object.defineProperty(guardedStubHeaders, NODE_INSPECT, {
|
|
1234
|
+
value(...args: unknown[]): unknown {
|
|
1235
|
+
const hook = (rawStubHeaders as unknown as Record<symbol, unknown>)[
|
|
1236
|
+
NODE_INSPECT
|
|
1237
|
+
];
|
|
1238
|
+
if (typeof hook === "function") return hook.apply(rawStubHeaders, args);
|
|
1239
|
+
// DOM Headers has forEach and not entries. Node always has the hook above.
|
|
1240
|
+
const listed: Record<string, string> = {};
|
|
1241
|
+
rawStubHeaders.forEach((value, key) => {
|
|
1242
|
+
listed[key] = value;
|
|
1243
|
+
});
|
|
1244
|
+
return listed;
|
|
1245
|
+
},
|
|
1246
|
+
configurable: true,
|
|
1247
|
+
writable: true,
|
|
1248
|
+
});
|
|
1211
1249
|
const shadowStubHeaders = (res: Response): void => {
|
|
1212
1250
|
Object.defineProperty(res, "headers", {
|
|
1213
1251
|
value: guardedStubHeaders,
|
|
@@ -1429,7 +1467,7 @@ export function createRequestContext<TEnv>(
|
|
|
1429
1467
|
const prevRaw =
|
|
1430
1468
|
(requestHeaders(request).get("x-rango-state") || null) ??
|
|
1431
1469
|
getRawCookieValue(cookieHeader, stateCookieName);
|
|
1432
|
-
const value = mintStateValue(
|
|
1470
|
+
const value = mintStateValue(versions?.document ?? "0", prevRaw);
|
|
1433
1471
|
// rawStubHeaders: guard-exempt internal writer — invalidateClientCache()
|
|
1434
1472
|
// is documented callable from loaders and during shell capture.
|
|
1435
1473
|
rawStubHeaders.append(
|
|
@@ -1505,6 +1543,7 @@ export function createRequestContext<TEnv>(
|
|
|
1505
1543
|
|
|
1506
1544
|
_handleStore: handleStore,
|
|
1507
1545
|
_cacheStore: cacheStore,
|
|
1546
|
+
_versions: versions,
|
|
1508
1547
|
_searchParamsFilter: searchParamsFilter,
|
|
1509
1548
|
_explicitTaggedStores: explicitTaggedStores,
|
|
1510
1549
|
_requestTags: new Set<string>(),
|
|
@@ -1515,11 +1554,23 @@ export function createRequestContext<TEnv>(
|
|
|
1515
1554
|
|
|
1516
1555
|
waitUntil(fn: () => Promise<void>): void {
|
|
1517
1556
|
if (ctx.build) return;
|
|
1518
|
-
//
|
|
1557
|
+
// A deferred task does not rely on the platform carrying the request's
|
|
1558
|
+
// async context into it. Deployed workerd lost it once ("use cache"
|
|
1559
|
+
// background revalidation, cache-runtime.ts); Node and miniflare never
|
|
1560
|
+
// do, so no local run shows a loss. A cache store builds its key inside
|
|
1561
|
+
// the task and reads the serving router's versions and the request host
|
|
1562
|
+
// from the context: without it the write lands under the whole-build
|
|
1563
|
+
// pair, which no router with its own version reads. So the task
|
|
1564
|
+
// re-enters the context it was scheduled under: the store active then
|
|
1565
|
+
// (a derived context), else this one.
|
|
1566
|
+
const scheduledUnder = requestContextStorage.getStore() ?? ctx;
|
|
1567
|
+
// Wrap in Promise.resolve().then(...) so a SYNCHRONOUS throw in a
|
|
1519
1568
|
// non-async callback becomes a rejected promise handed to the host's
|
|
1520
1569
|
// waitUntil (logged as a background failure), instead of escaping into
|
|
1521
1570
|
// the request flow. Mirrors fireAndForgetWaitUntil's deferral.
|
|
1522
|
-
const task = Promise.resolve().then(
|
|
1571
|
+
const task = Promise.resolve().then(() =>
|
|
1572
|
+
requestContextStorage.run(scheduledUnder, fn),
|
|
1573
|
+
);
|
|
1523
1574
|
// Track the task promise so the PPR shell capture can settle the
|
|
1524
1575
|
// foreground's deferred cache writes before its own match/render (the
|
|
1525
1576
|
// ordering edge; see _pendingBackgroundTasks). The capture task itself
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client entry's head hint at default fetch priority, after the head chunk
|
|
3
|
+
* scripts (issue #1025).
|
|
4
|
+
*
|
|
5
|
+
* For `bootstrapModules`, Fizz writes `<link rel="modulepreload"
|
|
6
|
+
* fetchPriority="low">` into the head and claims the URL
|
|
7
|
+
* (`moduleScriptResources[url] = null`, react-dom 19.3.0
|
|
8
|
+
* `react-dom-server.edge.production.js:546-565`) before the render starts, so
|
|
9
|
+
* no `preloadModule`/`preinitModule` call can replace it, and the
|
|
10
|
+
* `bootstrapModules` entry shape has no priority field. Chromium fetches that
|
|
11
|
+
* hint at Low, and once it boosts in-viewport images to High after first
|
|
12
|
+
* layout, the entry queues behind them; the executing end-of-shell module
|
|
13
|
+
* script reuses the in-flight Low fetch through the module map. Hydration
|
|
14
|
+
* then waits on a few-hundred-byte file while the head chunk scripts it
|
|
15
|
+
* imports (High) have long arrived: measured +3.8 s on a priority-honouring
|
|
16
|
+
* HTTP/2 link at Slow 4G.
|
|
17
|
+
*
|
|
18
|
+
* The fix serves that one tag without the attribute, so it fetches at
|
|
19
|
+
* Chromium's modulepreload default (High), and moves it after the head
|
|
20
|
+
* chunk scripts. Fizz flushes the bootstrap hint before the head chunk scripts; at
|
|
21
|
+
* High in that position it took one of HTTP/1.1's six connections ahead of
|
|
22
|
+
* the react chunk and delayed the last head chunk (hydration +91 ms, LCP
|
|
23
|
+
* +84 ms measured). After the scripts it is the last High request in the
|
|
24
|
+
* queue, so it never displaces a chunk hydration also needs. The accepted
|
|
25
|
+
* cost: an HTTP/2 server that sends equal-priority streams first in, first
|
|
26
|
+
* out delivers it with the last head chunk instead of before it (text-only
|
|
27
|
+
* pages +12 to +56 ms measured, against -0.15 to -3.3 s hydration on image
|
|
28
|
+
* pages; CHANGELOG has the matrix). Everything else about the bootstrap pair
|
|
29
|
+
* stays Fizz's: the nonce, the executing `id="_R_"` tag, the URL claim that
|
|
30
|
+
* keeps plugin-rsc's and the preinit hook's calls for the entry inert, and the
|
|
31
|
+
* PPR prelude/resume behaviour.
|
|
32
|
+
*
|
|
33
|
+
* The match is the exact tag Fizz writes for a string `bootstrapModules`
|
|
34
|
+
* entry (`rel`, `fetchPriority`, `nonce`, `href` in that order), not a
|
|
35
|
+
* pattern, and only before the first `<body`. A Fizz version that writes it
|
|
36
|
+
* differently leaves the bytes untouched: the output keeps the Low hint.
|
|
37
|
+
*
|
|
38
|
+
* Where the tag goes: right after the run of Fizz resource tags that directly
|
|
39
|
+
* follows the hint, before the first token that is anything else (`</head>`,
|
|
40
|
+
* the app's own head children, or body content in a head-less document).
|
|
41
|
+
* Fizz flushes the head chunk scripts and the bulk preloads straight after
|
|
42
|
+
* the bootstrap hint (react-dom 19.3.0 `flushCompletedQueues`, `:7321-7325`),
|
|
43
|
+
* as `<script …></script>` with no body and `<link …/>`; the scan accepts
|
|
44
|
+
* only those two shapes and stops at any other token without reading into it.
|
|
45
|
+
* It never searches the app's head content for `</head>` or `<body`: React
|
|
46
|
+
* does not escape either inside `<script>` or `<style>` text, so a head script
|
|
47
|
+
* holding the string `"<body>"` once received the tag inside its source and
|
|
48
|
+
* threw. Fizz escapes `>` in attribute values, so the first `>` ends a tag.
|
|
49
|
+
* Every insertion point is the boundary between two complete tags; a stream
|
|
50
|
+
* that ends mid-run, or a run tag longer than {@link MAX_HELD_TAG}, gets the
|
|
51
|
+
* tag before the unfinished one. Only that one tag is held back while the run
|
|
52
|
+
* streams; each complete run tag is forwarded as soon as it is scanned.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const LOW_PRIORITY_ATTR = ' fetchPriority="low"';
|
|
56
|
+
const encoder = new TextEncoder();
|
|
57
|
+
const BODY_OPEN = encoder.encode("<body");
|
|
58
|
+
const LINK_OPEN = encoder.encode("<link");
|
|
59
|
+
const SCRIPT_OPEN = encoder.encode("<script");
|
|
60
|
+
const SCRIPT_CLOSE = encoder.encode("</script>");
|
|
61
|
+
const GT = 0x3e;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Longest run tag held while its end has not arrived. Fizz's chunk scripts
|
|
65
|
+
* and preload links are a few hundred bytes; past this the tag goes before
|
|
66
|
+
* the held one, which keeps the per-chunk work bounded.
|
|
67
|
+
*/
|
|
68
|
+
const MAX_HELD_TAG = 8192;
|
|
69
|
+
|
|
70
|
+
/** React's escapeTextForBrowser: the attribute escaping Fizz applies. */
|
|
71
|
+
function escapeAttr(value: string): string {
|
|
72
|
+
return value.replace(/["&'<>]/g, (c) =>
|
|
73
|
+
c === '"'
|
|
74
|
+
? """
|
|
75
|
+
: c === "&"
|
|
76
|
+
? "&"
|
|
77
|
+
: c === "'"
|
|
78
|
+
? "'"
|
|
79
|
+
: c === "<"
|
|
80
|
+
? "<"
|
|
81
|
+
: ">",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface EntryHintBytes {
|
|
86
|
+
low: Uint8Array;
|
|
87
|
+
auto: Uint8Array;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function entryHintBytes(
|
|
91
|
+
entryUrl: string,
|
|
92
|
+
nonce: string | undefined,
|
|
93
|
+
): EntryHintBytes {
|
|
94
|
+
const tail =
|
|
95
|
+
(nonce ? ` nonce="${escapeAttr(nonce)}"` : "") +
|
|
96
|
+
` href="${escapeAttr(entryUrl)}"/>`;
|
|
97
|
+
return {
|
|
98
|
+
low: encoder.encode(`<link rel="modulepreload"${LOW_PRIORITY_ATTR}${tail}`),
|
|
99
|
+
auto: encoder.encode(`<link rel="modulepreload"${tail}`),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function indexOfBytes(
|
|
104
|
+
haystack: Uint8Array,
|
|
105
|
+
needle: Uint8Array,
|
|
106
|
+
from = 0,
|
|
107
|
+
): number {
|
|
108
|
+
const first = needle[0];
|
|
109
|
+
const last = haystack.length - needle.length;
|
|
110
|
+
outer: for (let i = from; i <= last; i++) {
|
|
111
|
+
if (haystack[i] !== first) continue;
|
|
112
|
+
for (let j = 1; j < needle.length; j++) {
|
|
113
|
+
if (haystack[i + j] !== needle[j]) continue outer;
|
|
114
|
+
}
|
|
115
|
+
return i;
|
|
116
|
+
}
|
|
117
|
+
return -1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Length of the longest suffix of `buf` that is a proper prefix of `needle`:
|
|
122
|
+
* the bytes a chunk boundary may have split off a match.
|
|
123
|
+
*/
|
|
124
|
+
function partialSuffixLength(buf: Uint8Array, needle: Uint8Array): number {
|
|
125
|
+
const max = Math.min(buf.length, needle.length - 1);
|
|
126
|
+
outer: for (let len = max; len > 0; len--) {
|
|
127
|
+
const start = buf.length - len;
|
|
128
|
+
for (let j = 0; j < len; j++) {
|
|
129
|
+
if (buf[start + j] !== needle[j]) continue outer;
|
|
130
|
+
}
|
|
131
|
+
return len;
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
137
|
+
const out = new Uint8Array(a.length + b.length);
|
|
138
|
+
out.set(a, 0);
|
|
139
|
+
out.set(b, a.length);
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Index of the hint in `buf` when it precedes the first `<body`, else -1. */
|
|
144
|
+
function findHint(buf: Uint8Array, hint: EntryHintBytes): number {
|
|
145
|
+
const bodyAt = indexOfBytes(buf, BODY_OPEN);
|
|
146
|
+
return indexOfBytes(bodyAt === -1 ? buf : buf.subarray(0, bodyAt), hint.low);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 1 when `needle` starts at `pos`, 0 when it does not, -1 when `buf` ends
|
|
151
|
+
* before that can be decided (the available bytes match a prefix).
|
|
152
|
+
*/
|
|
153
|
+
function matchAt(buf: Uint8Array, pos: number, needle: Uint8Array): number {
|
|
154
|
+
const n = Math.min(needle.length, buf.length - pos);
|
|
155
|
+
for (let j = 0; j < n; j++) {
|
|
156
|
+
if (buf[pos + j] !== needle[j]) return 0;
|
|
157
|
+
}
|
|
158
|
+
return n === needle.length ? 1 : -1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** HTML whitespace, `/` or `>`: the byte that ends a tag name. */
|
|
162
|
+
function endsTagName(byte: number): boolean {
|
|
163
|
+
return (
|
|
164
|
+
byte === 0x20 ||
|
|
165
|
+
byte === 0x09 ||
|
|
166
|
+
byte === 0x0a ||
|
|
167
|
+
byte === 0x0c ||
|
|
168
|
+
byte === 0x0d ||
|
|
169
|
+
byte === 0x2f ||
|
|
170
|
+
byte === GT
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface RunScan {
|
|
175
|
+
/** The run ended: `at` is the first byte of the token after it. */
|
|
176
|
+
ended: boolean;
|
|
177
|
+
/** Insertion point, or the start of the unfinished token when not ended. */
|
|
178
|
+
at: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Skip the run of Fizz resource tags at the start of `buf`: `<link …>` and
|
|
183
|
+
* `<script …></script>` with nothing between the two tags.
|
|
184
|
+
*/
|
|
185
|
+
function scanRun(buf: Uint8Array): RunScan {
|
|
186
|
+
let pos = 0;
|
|
187
|
+
while (pos < buf.length) {
|
|
188
|
+
const link = matchAt(buf, pos, LINK_OPEN);
|
|
189
|
+
const script = link === 1 ? 0 : matchAt(buf, pos, SCRIPT_OPEN);
|
|
190
|
+
if (link !== 1 && script !== 1) {
|
|
191
|
+
return { ended: link === 0 && script === 0, at: pos };
|
|
192
|
+
}
|
|
193
|
+
const nameEnd = pos + (link === 1 ? LINK_OPEN.length : SCRIPT_OPEN.length);
|
|
194
|
+
if (nameEnd >= buf.length) return { ended: false, at: pos };
|
|
195
|
+
if (!endsTagName(buf[nameEnd]!)) return { ended: true, at: pos };
|
|
196
|
+
const gt = buf.indexOf(GT, nameEnd);
|
|
197
|
+
if (gt === -1) return { ended: false, at: pos };
|
|
198
|
+
let end = gt + 1;
|
|
199
|
+
if (script === 1) {
|
|
200
|
+
const close = matchAt(buf, end, SCRIPT_CLOSE);
|
|
201
|
+
// An inline script body: the hint goes before the <script, never in it.
|
|
202
|
+
if (close !== 1) return { ended: close === 0, at: pos };
|
|
203
|
+
end += SCRIPT_CLOSE.length;
|
|
204
|
+
}
|
|
205
|
+
pos = end;
|
|
206
|
+
}
|
|
207
|
+
return { ended: false, at: pos };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `rest` with `hint` inserted at `at`. */
|
|
211
|
+
function insertAt(rest: Uint8Array, at: number, hint: Uint8Array): Uint8Array {
|
|
212
|
+
const out = new Uint8Array(rest.length + hint.length);
|
|
213
|
+
out.set(rest.subarray(0, at), 0);
|
|
214
|
+
out.set(hint, at);
|
|
215
|
+
out.set(rest.subarray(at), at + hint.length);
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Rewrite a complete document prefix (the PPR shell capture's prelude).
|
|
221
|
+
* Returns `bytes` unchanged when no hint precedes the first `<body`.
|
|
222
|
+
*/
|
|
223
|
+
export function rewriteEntryPreloadPriority(
|
|
224
|
+
bytes: Uint8Array,
|
|
225
|
+
entryUrl: string,
|
|
226
|
+
nonce?: string,
|
|
227
|
+
): Uint8Array {
|
|
228
|
+
const hint = entryHintBytes(entryUrl, nonce);
|
|
229
|
+
const at = findHint(bytes, hint);
|
|
230
|
+
if (at === -1) return bytes;
|
|
231
|
+
const rest = bytes.subarray(at + hint.low.length);
|
|
232
|
+
return concat(
|
|
233
|
+
bytes.subarray(0, at),
|
|
234
|
+
insertAt(rest, scanRun(rest).at, hint.auto),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Streaming form for live SSR: the same rewrite as
|
|
240
|
+
* {@link rewriteEntryPreloadPriority}, then pass-through. Before the hint is
|
|
241
|
+
* found, only a chunk tail that may be the start of a split `<body` or hint
|
|
242
|
+
* tag is held back. After it, complete run tags are forwarded as they are
|
|
243
|
+
* scanned and only an unfinished one (at most {@link MAX_HELD_TAG} bytes) is
|
|
244
|
+
* held, so every byte is scanned a bounded number of times. Once the tag is
|
|
245
|
+
* placed, or once `<body` passes without a hint, chunks are forwarded
|
|
246
|
+
* untouched and unscanned.
|
|
247
|
+
*/
|
|
248
|
+
export function entryPreloadPriorityTransform(
|
|
249
|
+
entryUrl: string,
|
|
250
|
+
nonce?: string,
|
|
251
|
+
): TransformStream<Uint8Array, Uint8Array> {
|
|
252
|
+
const hint = entryHintBytes(entryUrl, nonce);
|
|
253
|
+
let carry: Uint8Array | null = null;
|
|
254
|
+
// The unfinished run tag after the removed hint (possibly empty).
|
|
255
|
+
let held: Uint8Array | null = null;
|
|
256
|
+
let done = false;
|
|
257
|
+
const place = (
|
|
258
|
+
rest: Uint8Array,
|
|
259
|
+
controller: TransformStreamDefaultController<Uint8Array>,
|
|
260
|
+
): void => {
|
|
261
|
+
const scan = scanRun(rest);
|
|
262
|
+
if (scan.ended || rest.length - scan.at > MAX_HELD_TAG) {
|
|
263
|
+
done = true;
|
|
264
|
+
held = null;
|
|
265
|
+
controller.enqueue(insertAt(rest, scan.at, hint.auto));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (scan.at > 0) controller.enqueue(rest.slice(0, scan.at));
|
|
269
|
+
held = rest.slice(scan.at);
|
|
270
|
+
};
|
|
271
|
+
return new TransformStream<Uint8Array, Uint8Array>({
|
|
272
|
+
transform(chunk, controller) {
|
|
273
|
+
if (done) {
|
|
274
|
+
controller.enqueue(chunk);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (held) {
|
|
278
|
+
place(concat(held, chunk), controller);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const buf: Uint8Array = carry ? concat(carry, chunk) : chunk;
|
|
282
|
+
carry = null;
|
|
283
|
+
const at = findHint(buf, hint);
|
|
284
|
+
if (at !== -1) {
|
|
285
|
+
if (at > 0) controller.enqueue(buf.slice(0, at));
|
|
286
|
+
place(buf.subarray(at + hint.low.length), controller);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (indexOfBytes(buf, BODY_OPEN) !== -1) {
|
|
290
|
+
done = true;
|
|
291
|
+
controller.enqueue(buf);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const hold = Math.max(
|
|
295
|
+
partialSuffixLength(buf, hint.low),
|
|
296
|
+
partialSuffixLength(buf, BODY_OPEN),
|
|
297
|
+
);
|
|
298
|
+
if (hold > 0) carry = buf.slice(buf.length - hold);
|
|
299
|
+
const out = hold > 0 ? buf.subarray(0, buf.length - hold) : buf;
|
|
300
|
+
if (out.length > 0) controller.enqueue(out);
|
|
301
|
+
},
|
|
302
|
+
flush(controller) {
|
|
303
|
+
if (carry) controller.enqueue(carry);
|
|
304
|
+
// The stream ended inside the run: the tag goes before the unfinished one.
|
|
305
|
+
if (held) controller.enqueue(concat(hint.auto, held));
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
}
|
package/src/ssr/index.tsx
CHANGED
|
@@ -2,6 +2,10 @@ import React from "react";
|
|
|
2
2
|
import { createSsrRootComponent, deserializeSsrPayload } from "./ssr-root.js";
|
|
3
3
|
import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
|
|
4
4
|
import { runWithPreinitNonce } from "./preinit-client-references.js";
|
|
5
|
+
import {
|
|
6
|
+
entryPreloadPriorityTransform,
|
|
7
|
+
rewriteEntryPreloadPriority,
|
|
8
|
+
} from "./entry-preload-priority.js";
|
|
5
9
|
import {
|
|
6
10
|
POST_QUIESCE_TASK_HOPS,
|
|
7
11
|
SHELL_CAPTURE_MAX_WAIT_MS,
|
|
@@ -189,6 +193,7 @@ export interface SSRDependencies<TEnv = unknown> {
|
|
|
189
193
|
* undefined keeps the inline bootstrap verbatim, so a custom SSR entry that
|
|
190
194
|
* never installed the preinit hook cannot drift into the half-converted
|
|
191
195
|
* state on upgrade (the generated entry always passes an explicit value).
|
|
196
|
+
* Any other value throws when the handler is created.
|
|
192
197
|
*/
|
|
193
198
|
headScripts?: HeadScriptsOption;
|
|
194
199
|
|
|
@@ -521,9 +526,23 @@ const MISSING_BOOTSTRAP_MSG =
|
|
|
521
526
|
* getClientEntryUrl only counts under an explicit `headScripts: "preinit"` —
|
|
522
527
|
* any other headScripts keeps the inline path, so its presence alone is a
|
|
523
528
|
* misconfiguration worth flagging rather than silently ignoring.
|
|
529
|
+
* An unknown headScripts value throws: a custom SSR entry bypasses the
|
|
530
|
+
* rango({ headScripts }) check in getVirtualEntrySSR, and a typo such as
|
|
531
|
+
* "prenit" would otherwise run the inline path as if it were "preload".
|
|
532
|
+
* undefined stays valid (see {@link resolveBootstrap}).
|
|
524
533
|
*/
|
|
525
534
|
function assertBootstrapDeps(deps: SSRDependencies): void {
|
|
526
|
-
const
|
|
535
|
+
const { headScripts } = deps;
|
|
536
|
+
if (
|
|
537
|
+
headScripts !== undefined &&
|
|
538
|
+
headScripts !== "preinit" &&
|
|
539
|
+
headScripts !== "preload"
|
|
540
|
+
) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`[ssr] headScripts must be "preinit" or "preload", received ${JSON.stringify(headScripts)}`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
const preinit = headScripts === "preinit";
|
|
527
546
|
if (deps.getClientEntryUrl && !preinit) {
|
|
528
547
|
console.warn(
|
|
529
548
|
'[ssr] getClientEntryUrl is ignored without headScripts: "preinit"; ' +
|
|
@@ -550,13 +569,18 @@ type BootstrapOptions = Pick<
|
|
|
550
569
|
* `headScripts: "preinit"`, getClientEntryUrl() (sync — nothing to race)
|
|
551
570
|
* short-circuits to bootstrapModules, and inline content that is exactly
|
|
552
571
|
* `import("<entry-url>")` converts to the URL. React then emits a
|
|
553
|
-
* `<link rel="modulepreload"
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
572
|
+
* `<link rel="modulepreload">` hint in the head plus the executing
|
|
573
|
+
* `<script type="module" src async>` at end of shell — the entry fetch starts
|
|
574
|
+
* with the first flushed bytes instead of when the parser reaches an opaque
|
|
575
|
+
* inline script that only reveals the URL once executed. Fizz writes that hint
|
|
576
|
+
* with `fetchPriority="low"` ahead of the head chunk scripts; the handlers
|
|
577
|
+
* serve it without the attribute after the head chunk scripts
|
|
578
|
+
* (entry-preload-priority.ts, issue #1025), because at Low the entry queued
|
|
579
|
+
* behind in-viewport images and gated hydration (+3.8 s measured on a
|
|
580
|
+
* prioritising HTTP/2 link). Fizz stamps the request nonce on both tags, and
|
|
581
|
+
* under PPR both land in the stored prelude; on resume React has already
|
|
582
|
+
* cleared the bootstrap fields from the postponed state, so nothing re-emits.
|
|
583
|
+
* The conversion is an explicit opt-in:
|
|
560
584
|
* undefined headScripts (a custom SSR entry that predates the option, which
|
|
561
585
|
* also never installed the preinit hook) keeps the inline bootstrap
|
|
562
586
|
* byte-for-byte — converting by default would break CSPs that allowlist the
|
|
@@ -746,8 +770,15 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
|
|
|
746
770
|
await htmlStream.allReady;
|
|
747
771
|
}
|
|
748
772
|
|
|
773
|
+
// The entry's Fizz hint at default priority, after the head chunk
|
|
774
|
+
// scripts (entry-preload-priority.ts).
|
|
775
|
+
const entryUrl = bootstrap.bootstrapModules?.[0];
|
|
776
|
+
const html = entryUrl
|
|
777
|
+
? htmlStream.pipeThrough(entryPreloadPriorityTransform(entryUrl, nonce))
|
|
778
|
+
: htmlStream;
|
|
779
|
+
|
|
749
780
|
// Inject RSC payload into HTML as <script nonce="...">__FLIGHT_DATA__</script>
|
|
750
|
-
return
|
|
781
|
+
return html.pipeThrough(injectRSCPayload(rscStream2, { nonce }));
|
|
751
782
|
} catch (error) {
|
|
752
783
|
reportRenderError(onError, error);
|
|
753
784
|
throw error;
|
|
@@ -952,6 +983,12 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
952
983
|
const result = await prerenderPromise;
|
|
953
984
|
prelude = await readStreamToUint8Array(result.prelude);
|
|
954
985
|
postponed = result.postponed;
|
|
986
|
+
// Same hint rewrite as renderHTML, applied to the stored prelude; a
|
|
987
|
+
// resume never re-emits the bootstrap pair (Fizz clears it).
|
|
988
|
+
const entryUrl = bootstrap.bootstrapModules?.[0];
|
|
989
|
+
if (entryUrl) {
|
|
990
|
+
prelude = rewriteEntryPreloadPriority(prelude, entryUrl);
|
|
991
|
+
}
|
|
955
992
|
} catch (error) {
|
|
956
993
|
// Identity match: swallow ONLY our own deliberate abort
|
|
957
994
|
// (error === abortReason). Not error.name — capture aborts before this
|
|
@@ -81,11 +81,23 @@ export function runWithPreinitNonce<T>(
|
|
|
81
81
|
*
|
|
82
82
|
* Known trades (deliberate, measured neutral-to-positive on the e2e apps —
|
|
83
83
|
* PR #694 has the Lighthouse/hydration numbers):
|
|
84
|
-
* - Fetch priority:
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
84
|
+
* - Fetch priority: Chromium gives a parser-inserted `async` module script
|
|
85
|
+
* initial priority High: below the render-blocking stylesheet (Highest),
|
|
86
|
+
* and alongside the LCP image once Chromium boosts the in-viewport image to
|
|
87
|
+
* High (#1021). Every head chunk gets the same priority: the shared
|
|
88
|
+
* dependency chunks (react, router, entry.rsc and the bundler runtime) and
|
|
89
|
+
* every client-component chunk. The entry itself is a Fizz bootstrapModules
|
|
90
|
+
* hint, not a preinit (Fizz claims its URL, so this hook's call for it is
|
|
91
|
+
* inert); the handlers serve that hint at default priority, High as well,
|
|
92
|
+
* after these scripts (entry-preload-priority.ts, #1025).
|
|
93
|
+
* Lowering the chunks to `fetchpriority="low"` was measured and rejected
|
|
94
|
+
* (#1021; Chromium 153, 20 interleaved runs per variant and cell). On pages
|
|
95
|
+
* with images above the fold the Low chunks queue behind the boosted
|
|
96
|
+
* images: hydration came 206 to 6407 ms later on HTTP/1.1 and 65 to 642 ms
|
|
97
|
+
* later on HTTP/2. The one gain, FCP -136 ms, needed an image-free page
|
|
98
|
+
* with 35 KB of render-blocking CSS on HTTP/1.1 Slow 4G, and hydration was
|
|
99
|
+
* still 98 ms later there. `headScripts: "preload"` is the way to get Low
|
|
100
|
+
* chunk fetches.
|
|
89
101
|
* - Build only: plugin-rsc's dev load path reports `js: []` per reference, so
|
|
90
102
|
* dev documents have no head chunk scripts — a client module whose module
|
|
91
103
|
* scope assumes body-parsed DOM can break in production only. The
|
package/src/ssr/ssr-root.tsx
CHANGED
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
} from "../browser/react/filter-segment-order.js";
|
|
8
8
|
import { ThemeProvider } from "../theme/ThemeProvider.js";
|
|
9
9
|
import { NonceContext } from "../browser/react/nonce-context.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
LocationStateContext,
|
|
12
|
+
NavigationStoreContext,
|
|
13
|
+
} from "../browser/react/context.js";
|
|
11
14
|
import type { NavigationStoreContextValue } from "../browser/react/context.js";
|
|
12
15
|
import type { HandleData } from "../browser/types.js";
|
|
13
16
|
import type { ResolvedSegment } from "../types.js";
|
|
@@ -119,11 +122,17 @@ function createSsrEventController(opts: {
|
|
|
119
122
|
flushRouteState: () => {},
|
|
120
123
|
setHandleData: () => {},
|
|
121
124
|
getHandleState: () => handleState,
|
|
125
|
+
freezeHydrationHandleState: () => {},
|
|
126
|
+
// The server render is the state the document is rendered with.
|
|
127
|
+
getHydrationHandleState: () => undefined,
|
|
122
128
|
setRouteSegmentIds: () => {},
|
|
123
129
|
setParams: (nextParams) => {
|
|
124
130
|
params = nextParams;
|
|
125
131
|
},
|
|
126
132
|
getParams: () => params,
|
|
133
|
+
commitLocationState: () => {},
|
|
134
|
+
getLocationState: () => undefined,
|
|
135
|
+
takeTreelessLocationState: () => false,
|
|
127
136
|
setLocation: () => {},
|
|
128
137
|
startNavigation: () => {
|
|
129
138
|
throw new Error("Navigation not supported during SSR");
|
|
@@ -294,7 +303,7 @@ export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
|
|
|
294
303
|
};
|
|
295
304
|
|
|
296
305
|
// Build content tree from segments.
|
|
297
|
-
// Order must match NavigationProvider: NavigationStoreContext > NonceContext > ThemeProvider > content
|
|
306
|
+
// Order must match NavigationProvider: NavigationStoreContext > LocationStateContext > NonceContext > ThemeProvider > content
|
|
298
307
|
// Memoize like payload/handles above: renderSegments is async, so
|
|
299
308
|
// React.use() on a fresh promise suspends and replays SsrRoot, which
|
|
300
309
|
// would re-run the entire segment-tree build on every initial render.
|
|
@@ -324,10 +333,13 @@ export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
|
|
|
324
333
|
<NonceContext.Provider value={nonce}>{content}</NonceContext.Provider>
|
|
325
334
|
);
|
|
326
335
|
|
|
327
|
-
// Wrap with NavigationStoreContext for useNavigation hook
|
|
336
|
+
// Wrap with NavigationStoreContext for useNavigation hook. The server has
|
|
337
|
+
// no history entry: location state is undefined, as readers hydrate.
|
|
328
338
|
return (
|
|
329
339
|
<NavigationStoreContext.Provider value={ssrContextValue!}>
|
|
330
|
-
{
|
|
340
|
+
<LocationStateContext.Provider value={undefined}>
|
|
341
|
+
{content}
|
|
342
|
+
</LocationStateContext.Provider>
|
|
331
343
|
</NavigationStoreContext.Provider>
|
|
332
344
|
);
|
|
333
345
|
};
|