@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.
Files changed (185) hide show
  1. package/dist/bin/rango.js +26 -14
  2. package/dist/types/browser/event-controller.d.ts +32 -0
  3. package/dist/types/browser/history-state.d.ts +16 -4
  4. package/dist/types/browser/navigation-transaction.d.ts +7 -0
  5. package/dist/types/browser/rango-state.d.ts +3 -1
  6. package/dist/types/browser/react/context.d.ts +11 -0
  7. package/dist/types/browser/react/location-state-shared.d.ts +84 -10
  8. package/dist/types/browser/react/location-state.d.ts +7 -2
  9. package/dist/types/browser/react/subscribe-to-nothing.d.ts +7 -0
  10. package/dist/types/cache/cache-scope.d.ts +4 -5
  11. package/dist/types/cache/cf/cf-cache-store.d.ts +27 -6
  12. package/dist/types/cache/cf/cf-cache-types.d.ts +12 -4
  13. package/dist/types/cache/handle-snapshot.d.ts +36 -50
  14. package/dist/types/cache/index.d.ts +2 -0
  15. package/dist/types/cache/shell-snapshot.d.ts +19 -3
  16. package/dist/types/cache/types.d.ts +21 -10
  17. package/dist/types/cache/vercel/vercel-cache-store.d.ts +28 -16
  18. package/dist/types/client-urls/navigation.d.ts +7 -1
  19. package/dist/types/client-urls/optimistic-location.d.ts +10 -4
  20. package/dist/types/handles/deferred-resolution.d.ts +11 -1
  21. package/dist/types/prerender/build-shell-capture.d.ts +8 -6
  22. package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
  23. package/dist/types/router/navigation-snapshot.d.ts +8 -1
  24. package/dist/types/router/router-interfaces.d.ts +2 -1
  25. package/dist/types/router/router-options.d.ts +17 -7
  26. package/dist/types/router/segment-resolution/loader-cache.d.ts +21 -9
  27. package/dist/types/router-versions.d.ts +39 -0
  28. package/dist/types/rsc/handler-context.d.ts +5 -0
  29. package/dist/types/rsc/shell-build-manifest.d.ts +3 -2
  30. package/dist/types/rsc/types.d.ts +4 -6
  31. package/dist/types/server/build-version-table.d.ts +44 -0
  32. package/dist/types/server/handle-store.d.ts +87 -46
  33. package/dist/types/server/request-context.d.ts +22 -8
  34. package/dist/types/ssr/entry-preload-priority.d.ts +69 -0
  35. package/dist/types/ssr/index.d.ts +1 -0
  36. package/dist/types/ssr/preinit-client-references.d.ts +17 -5
  37. package/dist/types/testing/build-versions.d.ts +32 -0
  38. package/dist/types/testing/dom.entry.d.ts +1 -1
  39. package/dist/types/testing/flight.entry.d.ts +1 -1
  40. package/dist/types/testing/index.d.ts +2 -0
  41. package/dist/types/testing/internal/seed-vars.d.ts +7 -2
  42. package/dist/types/testing/location-state-key.d.ts +5 -1
  43. package/dist/types/testing/render-route.d.ts +77 -6
  44. package/dist/types/testing/serve-shell-request.d.ts +21 -0
  45. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  46. package/dist/types/types/handler-context.d.ts +8 -5
  47. package/dist/types/vite/discovery/build-versions.d.ts +393 -0
  48. package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
  49. package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
  50. package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
  51. package/dist/types/vite/discovery/state.d.ts +14 -0
  52. package/dist/types/vite/encryption-key.d.ts +18 -0
  53. package/dist/types/vite/plugin-types.d.ts +47 -6
  54. package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
  55. package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
  56. package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
  57. package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
  58. package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
  59. package/dist/types/vite/plugins/virtual-entries.d.ts +21 -2
  60. package/dist/types/vite/router-discovery.d.ts +6 -0
  61. package/dist/types/vite/utils/client-chunks.d.ts +24 -2
  62. package/dist/types/vite/utils/compare-strings.d.ts +7 -0
  63. package/dist/types/vite/utils/package-resolution.d.ts +8 -0
  64. package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
  65. package/dist/vite/index.js +1544 -420
  66. package/package.json +1 -2
  67. package/skills/bundle-analysis/SKILL.md +1 -1
  68. package/skills/cache-guide/SKILL.md +84 -20
  69. package/skills/caching/SKILL.md +16 -5
  70. package/skills/client-urls/SKILL.md +16 -0
  71. package/skills/cloudflare/SKILL.md +2 -1
  72. package/skills/comparison/references/framework-comparison.md +16 -12
  73. package/skills/hooks/SKILL.md +3 -0
  74. package/skills/hooks/handle-and-actions.md +6 -0
  75. package/skills/hooks/navigation.md +28 -0
  76. package/skills/hooks/state.md +209 -8
  77. package/skills/hooks/urls.md +40 -1
  78. package/skills/host-router/SKILL.md +13 -0
  79. package/skills/intercept/SKILL.md +6 -0
  80. package/skills/loader/SKILL.md +19 -1
  81. package/skills/middleware/SKILL.md +2 -0
  82. package/skills/ppr/SKILL.md +119 -40
  83. package/skills/prerender/SKILL.md +7 -3
  84. package/skills/rango/SKILL.md +8 -7
  85. package/skills/router-setup/SKILL.md +23 -3
  86. package/skills/testing/SKILL.md +30 -26
  87. package/skills/testing/client-components.md +193 -8
  88. package/skills/testing/handles.md +2 -0
  89. package/skills/testing/loader.md +1 -1
  90. package/skills/testing/middleware.md +1 -1
  91. package/skills/testing/render-handler.md +18 -18
  92. package/skills/testing/serve-shell-request.md +63 -14
  93. package/skills/testing/server-actions.md +17 -17
  94. package/skills/testing/server-tree.md +1 -1
  95. package/skills/typesafety/generated-files-and-cli.md +1 -1
  96. package/skills/use-cache/SKILL.md +16 -5
  97. package/skills/vercel/SKILL.md +3 -3
  98. package/src/bin/rango.ts +1 -1
  99. package/src/browser/event-controller.ts +75 -2
  100. package/src/browser/history-state.ts +39 -17
  101. package/src/browser/navigation-bridge.ts +41 -18
  102. package/src/browser/navigation-transaction.ts +15 -10
  103. package/src/browser/partial-update.ts +10 -2
  104. package/src/browser/rango-state.ts +3 -1
  105. package/src/browser/react/Link.tsx +2 -3
  106. package/src/browser/react/NavigationProvider.tsx +25 -1
  107. package/src/browser/react/context.ts +13 -0
  108. package/src/browser/react/location-state-shared.ts +200 -34
  109. package/src/browser/react/location-state.ts +40 -75
  110. package/src/browser/react/subscribe-to-nothing.ts +7 -0
  111. package/src/browser/react/use-handle.ts +20 -1
  112. package/src/browser/rsc-router.tsx +33 -11
  113. package/src/browser/server-action-bridge.ts +17 -1
  114. package/src/cache/cache-scope.ts +18 -8
  115. package/src/cache/cf/cf-cache-store.ts +75 -55
  116. package/src/cache/cf/cf-cache-types.ts +12 -4
  117. package/src/cache/handle-snapshot.ts +69 -87
  118. package/src/cache/index.ts +4 -0
  119. package/src/cache/shell-snapshot.ts +35 -5
  120. package/src/cache/types.ts +21 -10
  121. package/src/cache/vercel/vercel-cache-store.ts +48 -20
  122. package/src/client-urls/client-root.tsx +1 -0
  123. package/src/client-urls/navigation.ts +10 -0
  124. package/src/client-urls/optimistic-location.ts +10 -4
  125. package/src/handles/deferred-resolution.ts +31 -0
  126. package/src/prerender/build-shell-capture.ts +11 -9
  127. package/src/router/manifest.ts +2 -0
  128. package/src/router/match-middleware/cache-lookup.ts +11 -18
  129. package/src/router/navigation-snapshot.ts +25 -6
  130. package/src/router/request-classification.ts +15 -12
  131. package/src/router/router-interfaces.ts +2 -1
  132. package/src/router/router-options.ts +17 -7
  133. package/src/router/segment-resolution/loader-cache.ts +95 -60
  134. package/src/router-versions.ts +60 -0
  135. package/src/rsc/full-payload.ts +6 -2
  136. package/src/rsc/handler-context.ts +5 -0
  137. package/src/rsc/handler.ts +14 -4
  138. package/src/rsc/rsc-rendering.ts +12 -1
  139. package/src/rsc/shell-build-manifest.ts +10 -7
  140. package/src/rsc/shell-capture.ts +36 -26
  141. package/src/rsc/types.ts +4 -6
  142. package/src/server/build-version-table.ts +115 -0
  143. package/src/server/handle-store.ts +344 -127
  144. package/src/server/request-context.ts +62 -11
  145. package/src/ssr/entry-preload-priority.ts +308 -0
  146. package/src/ssr/index.tsx +46 -9
  147. package/src/ssr/preinit-client-references.ts +17 -5
  148. package/src/ssr/ssr-root.tsx +16 -4
  149. package/src/testing/build-versions.ts +64 -0
  150. package/src/testing/dispatch.ts +6 -0
  151. package/src/testing/dom.entry.ts +1 -0
  152. package/src/testing/flight.entry.ts +1 -0
  153. package/src/testing/index.ts +3 -0
  154. package/src/testing/internal/context.ts +2 -1
  155. package/src/testing/internal/seed-vars.ts +14 -2
  156. package/src/testing/location-state-key.ts +18 -8
  157. package/src/testing/render-handler.ts +2 -1
  158. package/src/testing/render-route.tsx +277 -43
  159. package/src/testing/serve-shell-request.ts +80 -11
  160. package/src/testing/vitest-stubs/version.ts +4 -1
  161. package/src/types/handler-context.ts +8 -5
  162. package/src/vite/discovery/build-versions.ts +1281 -0
  163. package/src/vite/discovery/installed-packages.ts +159 -0
  164. package/src/vite/discovery/prerender-collection.ts +38 -1
  165. package/src/vite/discovery/router-versions-phase.ts +336 -0
  166. package/src/vite/discovery/shell-prerender-phase.ts +28 -38
  167. package/src/vite/discovery/state.ts +19 -0
  168. package/src/vite/discovery/virtual-module-codegen.ts +7 -2
  169. package/src/vite/encryption-key.ts +101 -9
  170. package/src/vite/plugin-types.ts +48 -6
  171. package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
  172. package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
  173. package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
  174. package/src/vite/plugins/expose-internal-ids.ts +12 -3
  175. package/src/vite/plugins/stable-rsc-output.ts +147 -0
  176. package/src/vite/plugins/version-plugin.ts +19 -24
  177. package/src/vite/plugins/version.d.ts +17 -2
  178. package/src/vite/plugins/virtual-entries.ts +40 -7
  179. package/src/vite/rango.ts +30 -3
  180. package/src/vite/router-discovery.ts +82 -13
  181. package/src/vite/utils/client-chunks.ts +84 -14
  182. package/src/vite/utils/compare-strings.ts +9 -0
  183. package/src/vite/utils/package-resolution.ts +9 -0
  184. package/src/vite/utils/prerender-utils.ts +4 -1
  185. 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
- * segment-key -> the capture's elided container (already Flight-deserialized
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. The record's
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
- /** Build version, used as the prefix of a server-rotated rango state value. */
1134
- version?: string;
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
- version: stateVersion,
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(stateVersion ?? "0", prevRaw);
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
- // Wrap in Promise.resolve().then(fn) so a SYNCHRONOUS throw in a
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(fn);
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
+ ? "&quot;"
75
+ : c === "&"
76
+ ? "&amp;"
77
+ : c === "'"
78
+ ? "&#x27;"
79
+ : c === "<"
80
+ ? "&lt;"
81
+ : "&gt;",
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 preinit = deps.headScripts === "preinit";
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" fetchpriority="low">` hint in the head plus the
554
- * executing `<script type="module" src async>` at end of shell — the entry
555
- * fetch starts with the first flushed bytes instead of when the parser reaches
556
- * an opaque inline script that only reveals the URL once executed. Fizz stamps
557
- * the request nonce on both tags, and under PPR both land in the stored
558
- * prelude; on resume React has already cleared the bootstrap fields from the
559
- * postponed state, so nothing re-emits. The conversion is an explicit opt-in:
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 htmlStream.pipeThrough(injectRSCPayload(rscStream2, { nonce }));
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: an executing async module script fetches at Chromium's
85
- * async-script priority, below a bare modulepreload hint; preinitModule
86
- * forwards no fetchPriority (react-dom's public API drops it — only
87
- * `preinit` forwards it). Execution-overlap is bought with hint priority,
88
- * the same trade Next.js ships via ReactDOM.preinit.
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
@@ -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 { NavigationStoreContext } from "../browser/react/context.js";
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
- {content}
340
+ <LocationStateContext.Provider value={undefined}>
341
+ {content}
342
+ </LocationStateContext.Provider>
331
343
  </NavigationStoreContext.Provider>
332
344
  );
333
345
  };