@rangojs/router 0.20.0 → 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 (178) 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/testing/build-versions.d.ts +32 -0
  35. package/dist/types/testing/dom.entry.d.ts +1 -1
  36. package/dist/types/testing/flight.entry.d.ts +1 -1
  37. package/dist/types/testing/index.d.ts +2 -0
  38. package/dist/types/testing/internal/seed-vars.d.ts +7 -2
  39. package/dist/types/testing/location-state-key.d.ts +5 -1
  40. package/dist/types/testing/render-route.d.ts +77 -6
  41. package/dist/types/testing/serve-shell-request.d.ts +21 -0
  42. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  43. package/dist/types/types/handler-context.d.ts +8 -5
  44. package/dist/types/vite/discovery/build-versions.d.ts +393 -0
  45. package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
  46. package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
  47. package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
  48. package/dist/types/vite/discovery/state.d.ts +14 -0
  49. package/dist/types/vite/encryption-key.d.ts +18 -0
  50. package/dist/types/vite/plugin-types.d.ts +27 -0
  51. package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
  52. package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
  53. package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
  54. package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
  55. package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
  56. package/dist/types/vite/plugins/virtual-entries.d.ts +18 -1
  57. package/dist/types/vite/router-discovery.d.ts +6 -0
  58. package/dist/types/vite/utils/client-chunks.d.ts +2 -0
  59. package/dist/types/vite/utils/compare-strings.d.ts +7 -0
  60. package/dist/types/vite/utils/package-resolution.d.ts +8 -0
  61. package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
  62. package/dist/vite/index.js +1537 -444
  63. package/package.json +1 -2
  64. package/skills/cache-guide/SKILL.md +84 -20
  65. package/skills/caching/SKILL.md +16 -5
  66. package/skills/client-urls/SKILL.md +16 -0
  67. package/skills/cloudflare/SKILL.md +2 -1
  68. package/skills/comparison/references/framework-comparison.md +16 -12
  69. package/skills/hooks/SKILL.md +3 -0
  70. package/skills/hooks/handle-and-actions.md +6 -0
  71. package/skills/hooks/navigation.md +28 -0
  72. package/skills/hooks/state.md +209 -8
  73. package/skills/hooks/urls.md +40 -1
  74. package/skills/host-router/SKILL.md +13 -0
  75. package/skills/intercept/SKILL.md +6 -0
  76. package/skills/loader/SKILL.md +19 -1
  77. package/skills/middleware/SKILL.md +2 -0
  78. package/skills/ppr/SKILL.md +119 -40
  79. package/skills/prerender/SKILL.md +7 -3
  80. package/skills/rango/SKILL.md +8 -7
  81. package/skills/router-setup/SKILL.md +15 -1
  82. package/skills/testing/SKILL.md +30 -26
  83. package/skills/testing/client-components.md +193 -8
  84. package/skills/testing/handles.md +2 -0
  85. package/skills/testing/loader.md +1 -1
  86. package/skills/testing/middleware.md +1 -1
  87. package/skills/testing/render-handler.md +18 -18
  88. package/skills/testing/serve-shell-request.md +63 -14
  89. package/skills/testing/server-actions.md +17 -17
  90. package/skills/testing/server-tree.md +1 -1
  91. package/skills/typesafety/generated-files-and-cli.md +1 -1
  92. package/skills/use-cache/SKILL.md +16 -5
  93. package/skills/vercel/SKILL.md +3 -3
  94. package/src/bin/rango.ts +1 -1
  95. package/src/browser/event-controller.ts +75 -2
  96. package/src/browser/history-state.ts +39 -17
  97. package/src/browser/navigation-bridge.ts +41 -18
  98. package/src/browser/navigation-transaction.ts +15 -10
  99. package/src/browser/partial-update.ts +10 -2
  100. package/src/browser/rango-state.ts +3 -1
  101. package/src/browser/react/Link.tsx +2 -3
  102. package/src/browser/react/NavigationProvider.tsx +25 -1
  103. package/src/browser/react/context.ts +13 -0
  104. package/src/browser/react/location-state-shared.ts +200 -34
  105. package/src/browser/react/location-state.ts +40 -75
  106. package/src/browser/react/subscribe-to-nothing.ts +7 -0
  107. package/src/browser/react/use-handle.ts +20 -1
  108. package/src/browser/rsc-router.tsx +33 -11
  109. package/src/browser/server-action-bridge.ts +17 -1
  110. package/src/cache/cache-scope.ts +18 -8
  111. package/src/cache/cf/cf-cache-store.ts +75 -55
  112. package/src/cache/cf/cf-cache-types.ts +12 -4
  113. package/src/cache/handle-snapshot.ts +69 -87
  114. package/src/cache/index.ts +4 -0
  115. package/src/cache/shell-snapshot.ts +35 -5
  116. package/src/cache/types.ts +21 -10
  117. package/src/cache/vercel/vercel-cache-store.ts +48 -20
  118. package/src/client-urls/client-root.tsx +1 -0
  119. package/src/client-urls/navigation.ts +10 -0
  120. package/src/client-urls/optimistic-location.ts +10 -4
  121. package/src/handles/deferred-resolution.ts +31 -0
  122. package/src/prerender/build-shell-capture.ts +11 -9
  123. package/src/router/manifest.ts +2 -0
  124. package/src/router/match-middleware/cache-lookup.ts +11 -18
  125. package/src/router/navigation-snapshot.ts +25 -6
  126. package/src/router/request-classification.ts +15 -12
  127. package/src/router/router-interfaces.ts +2 -1
  128. package/src/router/router-options.ts +17 -7
  129. package/src/router/segment-resolution/loader-cache.ts +95 -60
  130. package/src/router-versions.ts +60 -0
  131. package/src/rsc/full-payload.ts +6 -2
  132. package/src/rsc/handler-context.ts +5 -0
  133. package/src/rsc/handler.ts +14 -4
  134. package/src/rsc/rsc-rendering.ts +12 -1
  135. package/src/rsc/shell-build-manifest.ts +10 -7
  136. package/src/rsc/shell-capture.ts +36 -26
  137. package/src/rsc/types.ts +4 -6
  138. package/src/server/build-version-table.ts +115 -0
  139. package/src/server/handle-store.ts +344 -127
  140. package/src/server/request-context.ts +62 -11
  141. package/src/ssr/ssr-root.tsx +16 -4
  142. package/src/testing/build-versions.ts +64 -0
  143. package/src/testing/dispatch.ts +6 -0
  144. package/src/testing/dom.entry.ts +1 -0
  145. package/src/testing/flight.entry.ts +1 -0
  146. package/src/testing/index.ts +3 -0
  147. package/src/testing/internal/context.ts +2 -1
  148. package/src/testing/internal/seed-vars.ts +14 -2
  149. package/src/testing/location-state-key.ts +18 -8
  150. package/src/testing/render-handler.ts +2 -1
  151. package/src/testing/render-route.tsx +277 -43
  152. package/src/testing/serve-shell-request.ts +80 -11
  153. package/src/testing/vitest-stubs/version.ts +4 -1
  154. package/src/types/handler-context.ts +8 -5
  155. package/src/vite/discovery/build-versions.ts +1281 -0
  156. package/src/vite/discovery/installed-packages.ts +159 -0
  157. package/src/vite/discovery/prerender-collection.ts +38 -1
  158. package/src/vite/discovery/router-versions-phase.ts +336 -0
  159. package/src/vite/discovery/shell-prerender-phase.ts +28 -38
  160. package/src/vite/discovery/state.ts +19 -0
  161. package/src/vite/discovery/virtual-module-codegen.ts +7 -2
  162. package/src/vite/encryption-key.ts +101 -9
  163. package/src/vite/plugin-types.ts +28 -0
  164. package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
  165. package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
  166. package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
  167. package/src/vite/plugins/expose-internal-ids.ts +12 -3
  168. package/src/vite/plugins/stable-rsc-output.ts +147 -0
  169. package/src/vite/plugins/version-plugin.ts +19 -24
  170. package/src/vite/plugins/version.d.ts +17 -2
  171. package/src/vite/plugins/virtual-entries.ts +31 -5
  172. package/src/vite/rango.ts +28 -3
  173. package/src/vite/router-discovery.ts +82 -13
  174. package/src/vite/utils/client-chunks.ts +5 -1
  175. package/src/vite/utils/compare-strings.ts +9 -0
  176. package/src/vite/utils/package-resolution.ts +9 -0
  177. package/src/vite/utils/prerender-utils.ts +4 -1
  178. package/src/vite/utils/shared-utils.ts +1 -1
@@ -128,7 +128,7 @@ it("asserts the server-rendered host content", async () => {
128
128
  - A client boundary's props come back as REAL JS values after deserialization (a `Date` is a `Date`, not a `$D...` encoding) — but there is NO hydration and NO interaction; boundaries are inert placeholders carrying props.
129
129
  - Server COMPONENTS do not survive Flight as identities (they are executed during serialization), so `findElements` matches the host elements they PRODUCED, not the component function. Client islands keep identity — use `findClientBoundaries` for those.
130
130
  - `findClientBoundaries` finds islands (`I` rows); `findElements` finds host elements. A `testId` on an island matches with `findClientBoundaries`; a `testId` on a host element matches with `findElements`. Use `textContent(node)` in place of `JSON.stringify(tree).toContain`.
131
- - A true interactive, clickable DOM `renderServer` is intentionally NOT shipped: in-process happy-dom hydration re-tests React more than your app and misses server/client divergence (the only hydration bug worth a dedicated test, which needs a real browser). Test interaction at e2e.
131
+ - A true interactive, clickable DOM `renderServer` is intentionally NOT shipped: in-process happy-dom hydration of a Flight tree re-tests React more than your app and misses the divergence that needs the real document (streaming order, client-reference identity, the browser's HTML parser). Test interaction at e2e. A CLIENT component's own server/client divergence is unit-reachable: `renderRoute` with `hydrate: true` ([`./client-components.md`](./client-components.md#hydration)).
132
132
 
133
133
  ## See also
134
134
 
@@ -113,7 +113,7 @@ and client helpers.
113
113
  CLI flags: the default mode fails (and writes nothing) when an `include()` cannot
114
114
  be resolved statically (factory functions, dynamic expressions); `--static`
115
115
  accepts partial output with warnings; `--runtime` uses Vite-based discovery for
116
- full coverage (needs `vite` and `@vitejs/plugin-rsc`, optional
116
+ full coverage (needs `vite`, optional
117
117
  `--config <path>`). Passing a directory scans it for router and `urls()` files.
118
118
 
119
119
  ### Named Routes, `$$routeNames`, And `router.routeMap`
@@ -485,11 +485,22 @@ the current time and expect freshness -- those are frozen at cache-write. The
485
485
  `cookies()`/`headers()` read guard applies to the cached function body, NOT to an
486
486
  inline action's body.
487
487
 
488
- Deploy note: captured values are encrypted with a per-build key by default. If
489
- cache entries can outlive a deploy (a persistent store such as the Cloudflare
490
- cache store), set `RANGO_ENCRYPTION_KEY` (base64-encoded 32 bytes) in the build
491
- environment so actions embedded in entries written by a previous deploy still
492
- decrypt; without it they fail until the entry expires or revalidates.
488
+ Deploy note: captured values are encrypted with a key that is generated per
489
+ build by default. Set a stable one in the plugin options:
490
+
491
+ ```ts
492
+ rango({ encryptionKey: process.env.RANGO_ENCRYPTION_KEY });
493
+ ```
494
+
495
+ The value is base64-encoded 32 bytes (`openssl rand -base64 32`), never a
496
+ literal, and is validated when `rango()` is called. `undefined` falls back to the
497
+ `RANGO_ENCRYPTION_KEY` environment variable, then to a key generated per build.
498
+ The key is part of the cache version of every router whose server code encrypts
499
+ inline server-action bound arguments, so without a stable key those routers get a
500
+ new version (and a cleared cache) on every build, and the build prints one note
501
+ when that applies. With a stable key, actions embedded in entries written by a
502
+ previous deploy still decrypt. A router that renders no inline action with bound
503
+ arguments keeps its cache without a stable key. See `/cache-guide`.
493
504
 
494
505
  ## Vite Transform
495
506
 
@@ -98,9 +98,7 @@ const memoryStore = new MemorySegmentCacheStore({ defaults });
98
98
  // passes, since the tag markers are regional and only expireTag is global. A
99
99
  // platform expireTag issued outside rango writes no marker, so every memo
100
100
  // serves the shell until its window passes.
101
- const runtimeCache = process.env.VERCEL
102
- ? getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID })
103
- : undefined;
101
+ const runtimeCache = process.env.VERCEL ? getCache() : undefined;
104
102
 
105
103
  function resolveCache() {
106
104
  if (runtimeCache) {
@@ -120,6 +118,8 @@ export const router = createRouter({ cache: resolveCache }).routes(/* ... */);
120
118
 
121
119
  The cache factory receives `(env, ctx)`: on Vercel `env` is `process.env` and `ctx` is `{ waitUntil }`; off-platform `ctx` is `undefined`. Cache semantics and tag invalidation: `/caching`, `/cache-guide`.
122
120
 
121
+ `getCache()` takes no deployment id: `VercelCacheStore` puts a version in front of every key itself (data version for families `s`, `i`; document version for `r`, `h`; none for tag markers), computed per `createRouter()` from its built code. A deploy that does not change a router keeps its entries; a deploy that changes it reads under new keys. `version` on the store (or on `createRouter`) replaces both versions. To clear the cache on every deploy, set `version` to a per-deploy value or keep a deployment-scoped `getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID })`. Two environments built from the same code share entries if they share a cache namespace; give each its own namespace or `version`.
122
+
123
123
  ## Host routers (multi-app)
124
124
 
125
125
  A multi-app host router deploys as a **single function** running `hostRouter.match()` for every request (mirrors the Cloudflare single-worker model). Two requirements:
package/src/bin/rango.ts CHANGED
@@ -76,7 +76,7 @@ if (command === "generate") {
76
76
  Modes:
77
77
  (default) Static parser with error on unresolvable includes
78
78
  --runtime Vite-based runtime discovery (100% coverage)
79
- Requires vite and @vitejs/plugin-rsc
79
+ Requires vite
80
80
  --static Static parser, accept partial output with warnings
81
81
 
82
82
  Options:
@@ -15,6 +15,11 @@ import {
15
15
  filterRouteSegmentIds,
16
16
  } from "./react/filter-segment-order.js";
17
17
  import { notifyListeners } from "./notify-listeners.js";
18
+ import { cloneHandleData } from "./navigation-store.js";
19
+ import {
20
+ locationStateSnapshot,
21
+ type LocationStateSnapshot,
22
+ } from "./react/location-state-shared.js";
18
23
 
19
24
  // Polyfill Symbol.dispose for Safari and older browsers
20
25
  if (typeof Symbol.dispose === "undefined") {
@@ -224,7 +229,8 @@ export interface EventController {
224
229
  getActionState(actionId: string): TrackedActionState;
225
230
  getLocation(): NavigationLocation;
226
231
 
227
- // Location updates (for popstate where navigation doesn't go through startNavigation)
232
+ // Location of a commit no transaction completes: a shallow navigation, a
233
+ // back/forward restored from the history cache, a back/forward that failed.
228
234
  setLocation(location: NavigationLocation): void;
229
235
 
230
236
  // Subscriptions
@@ -256,6 +262,23 @@ export interface EventController {
256
262
  resolvedIds?: string[],
257
263
  ): void;
258
264
  getHandleState(): HandleState;
265
+ /**
266
+ * Keep the current handle state as the one the document's HTML was rendered
267
+ * with. initBrowserApp calls it before hydrateRoot, once the document's
268
+ * handle snapshot is applied.
269
+ */
270
+ freezeHydrationHandleState(): void;
271
+ /**
272
+ * The handle state the document's HTML was rendered with, once the live
273
+ * state has moved on from it; undefined while the live state is still that
274
+ * state (nothing changed since the freeze, or nothing was frozen: a tree
275
+ * that was mounted, an SSR pass). A render React is hydrating reads it
276
+ * (useHandle), whenever its boundary hydrates: the late handle channel
277
+ * (rsc-router.tsx) waits for the root to hydrate, not for a `<Suspense>`
278
+ * boundary that hydrates after it, and by then the live state can hold
279
+ * values that boundary's HTML does not (issue #1035).
280
+ */
281
+ getHydrationHandleState(): HandleState | undefined;
259
282
  /**
260
283
  * Update ONLY `routeSegmentIds` (what `useSegments` reads) from `matched`,
261
284
  * leaving `data` and `segmentOrder` (what `useHandle` collects over) untouched.
@@ -269,6 +292,21 @@ export interface EventController {
269
292
  setParams(params: Record<string, string>): void;
270
293
  getParams(): Record<string, string>;
271
294
 
295
+ /**
296
+ * Record the committed history entry's location state from `entryState`:
297
+ * the state object the commit pushed, restored or merged into the entry,
298
+ * so the entry and its readers have one source. Notifies nobody:
299
+ * NavigationProvider takes the snapshot with the entry's payload
300
+ * (LocationStateContext). `treeless` marks a commit no payload follows (a
301
+ * shallow navigation, a commit that keeps every segment, an action's
302
+ * state): the provider takes it with this commit's state notification, in
303
+ * the batch of the location.
304
+ */
305
+ commitLocationState(entryState: unknown, treeless?: boolean): void;
306
+ getLocationState(): LocationStateSnapshot;
307
+ /** True once after a `treeless` commit. */
308
+ takeTreelessLocationState(): boolean;
309
+
272
310
  // Direct state access for advanced use
273
311
  getCurrentNavigation(): NavigationEntry | null;
274
312
  getInflightActions(): Map<string, ActionEntry>;
@@ -460,9 +498,14 @@ export function createEventController(
460
498
  let handleData: HandleData = {};
461
499
  let handleSegmentOrder: string[] = [];
462
500
  let routeSegmentIds: string[] = [];
501
+ // freezeHydrationHandleState: what the document's HTML was rendered with.
502
+ let hydrationHandleState: HandleState | undefined;
463
503
 
464
504
  let routeParams: Record<string, string> = {};
465
505
 
506
+ let locationState: LocationStateSnapshot;
507
+ let treelessLocationState = false;
508
+
466
509
  const stateListeners = new Set<StateListener>();
467
510
  const actionListeners = new Map<string, Set<ActionStateListener>>();
468
511
  const handleListeners = new Set<HandleListener>();
@@ -992,7 +1035,11 @@ export function createEventController(
992
1035
  const newRouteSegmentIds = filterRouteSegmentIds(rawMatched);
993
1036
 
994
1037
  if (isPartial && newSegmentOrder.length > 0) {
995
- // Partial update: merge new data with existing
1038
+ // Partial update: merge new data with existing, into a copy of the
1039
+ // containers. The object a reader was handed is never written to: the
1040
+ // hydration snapshot (freezeHydrationHandleState) and a history entry
1041
+ // restored from the store's cache can be that object.
1042
+ handleData = cloneHandleData(handleData);
996
1043
  for (const handleName of Object.keys(data)) {
997
1044
  if (!handleData[handleName]) {
998
1045
  handleData[handleName] = {};
@@ -1094,6 +1141,20 @@ export function createEventController(
1094
1141
  return routeParams;
1095
1142
  }
1096
1143
 
1144
+ function commitLocationState(entryState: unknown, treeless?: boolean): void {
1145
+ locationState = locationStateSnapshot(entryState, locationState);
1146
+ if (treeless) {
1147
+ treelessLocationState = true;
1148
+ notify();
1149
+ }
1150
+ }
1151
+
1152
+ function takeTreelessLocationState(): boolean {
1153
+ const taken = treelessLocationState;
1154
+ treelessLocationState = false;
1155
+ return taken;
1156
+ }
1157
+
1097
1158
  // ========================================================================
1098
1159
  // Return Controller
1099
1160
  // ========================================================================
@@ -1116,12 +1177,24 @@ export function createEventController(
1116
1177
  // Handles
1117
1178
  setHandleData,
1118
1179
  getHandleState,
1180
+ freezeHydrationHandleState: () => {
1181
+ hydrationHandleState = getHandleState();
1182
+ },
1183
+ getHydrationHandleState: () =>
1184
+ hydrationHandleState?.data === handleData
1185
+ ? undefined
1186
+ : hydrationHandleState,
1119
1187
  setRouteSegmentIds,
1120
1188
 
1121
1189
  // Params
1122
1190
  setParams,
1123
1191
  getParams,
1124
1192
 
1193
+ // Location state
1194
+ commitLocationState,
1195
+ getLocationState: () => locationState,
1196
+ takeTreelessLocationState,
1197
+
1125
1198
  // Subscriptions
1126
1199
  subscribe,
1127
1200
  subscribeToAction,
@@ -1,9 +1,13 @@
1
1
  import {
2
+ addLocationState,
3
+ CLEAR_ON_RELOAD_KEY_SUFFIX,
2
4
  isLocationStateDefinition,
3
5
  isLocationStateEntry,
6
+ isLocationStateKey,
4
7
  peekLocationStateKey,
5
8
  replaceCurrentHistoryState,
6
9
  resolveLocationStateEntries,
10
+ stampLocationState,
7
11
  } from "./react/location-state-shared.js";
8
12
 
9
13
  /**
@@ -13,7 +17,7 @@ function isTypedLocationState(
13
17
  state: unknown,
14
18
  ): state is Record<string, unknown> {
15
19
  if (state === null || typeof state !== "object") return false;
16
- return Object.keys(state).some((key) => key.startsWith("__rsc_ls_"));
20
+ return Object.keys(state).some(isLocationStateKey);
17
21
  }
18
22
 
19
23
  /**
@@ -100,16 +104,15 @@ export function buildHistoryState(
100
104
  Object.assign(result, serverState);
101
105
  }
102
106
 
107
+ if (hasLocationState(result)) stampLocationState(result);
108
+
103
109
  return Object.keys(result).length > 0 ? result : null;
104
110
  }
105
111
 
106
112
  /** Check if a history state object contains location state keys. */
107
113
  export function hasLocationState(state: unknown): boolean {
108
114
  if (!state || typeof state !== "object") return false;
109
- return (
110
- "state" in state ||
111
- Object.keys(state).some((k) => k.startsWith("__rsc_ls_"))
112
- );
115
+ return "state" in state || Object.keys(state).some(isLocationStateKey);
113
116
  }
114
117
 
115
118
  /**
@@ -157,19 +160,38 @@ export function stripShellMissMarker(): void {
157
160
  }
158
161
 
159
162
  /**
160
- * Merge server-set location state into the current history entry.
161
- * Replaces the current history state and dispatches notification event
162
- * so useLocationState hooks re-read from history.state.
163
+ * Remove the `clearOnReload` slots of the entry a document load starts on:
164
+ * the server rendered this document without them. One pass before hydration
165
+ * (initBrowserApp; renderRoute's `hydrate` mode), so no reader needs to know
166
+ * the page began as a document load, and a slot nobody reads is removed too.
167
+ * No server-set state exists yet to lose: a document response carries none
168
+ * (rsc-rendering.ts attaches it to partial payloads only).
169
+ *
170
+ * A slot is recognised by its key alone (the `~r` suffix): at start-up the
171
+ * module that defines it may not be loaded.
172
+ */
173
+ export function clearLocationStateOnDocumentLoad(): void {
174
+ const state: unknown = window.history.state;
175
+ if (state === null || typeof state !== "object") return;
176
+ let next: Record<string, unknown> | undefined;
177
+ for (const key in state) {
178
+ if (isLocationStateKey(key) && key.endsWith(CLEAR_ON_RELOAD_KEY_SUFFIX)) {
179
+ next ??= { ...state };
180
+ delete next[key];
181
+ }
182
+ }
183
+ if (next) window.history.replaceState(next, "");
184
+ }
185
+
186
+ /**
187
+ * Merge server-set location state into the current history entry and return
188
+ * the entry state written. Readers see it when the caller commits it
189
+ * (EventController.commitLocationState).
163
190
  */
164
191
  export function mergeLocationState(
165
192
  locationState: Record<string, unknown>,
166
- ): void {
167
- const merged = {
168
- ...window.history.state,
169
- ...locationState,
170
- };
171
- replaceCurrentHistoryState(merged);
172
- if (Object.keys(locationState).some((k) => k.startsWith("__rsc_ls_"))) {
173
- window.dispatchEvent(new Event("__rsc_locationstate"));
174
- }
193
+ ): Record<string, unknown> {
194
+ const entryState = addLocationState(window.history.state, locationState);
195
+ replaceCurrentHistoryState(entryState);
196
+ return entryState;
175
197
  }
@@ -14,11 +14,7 @@ import {
14
14
  createNavigationTransaction,
15
15
  resolveNavigationState,
16
16
  } from "./navigation-transaction.js";
17
- import {
18
- buildHistoryState,
19
- hasLocationState,
20
- pushHistoryWithIdx,
21
- } from "./history-state.js";
17
+ import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
22
18
  import {
23
19
  handleNavigationStart,
24
20
  handleNavigationEnd,
@@ -220,8 +216,6 @@ export function createNavigationBridge(
220
216
  // Save current scroll position before changing URL
221
217
  handleNavigationStart();
222
218
 
223
- // Snapshot old state before pushState/replaceState overwrites it
224
- const oldState = window.history.state;
225
219
  const shallowRouteName = store.getHistoryEntryMemory()?.routeName;
226
220
 
227
221
  // Update browser URL (carry intercept context into history state)
@@ -239,11 +233,8 @@ export function createNavigationBridge(
239
233
  ensureHistoryKey();
240
234
  // Same route, new entry (transition({ when }) back/forward source).
241
235
  store.rememberDisplayedEntry(shallowRouteName);
242
-
243
- // Notify useLocationState() hooks when state changes
244
- if (hasLocationState(oldState) || hasLocationState(historyState)) {
245
- window.dispatchEvent(new Event("__rsc_locationstate"));
246
- }
236
+ // Treeless: readers take the entry's state with setLocation below.
237
+ eventController.commitLocationState(historyState, true);
247
238
 
248
239
  // Update store history key so future navigations reference the right cache
249
240
  store.setHistoryKey(historyKey);
@@ -357,6 +348,8 @@ export function createNavigationBridge(
357
348
  const clientUrlPresentation = beginClientUrlNavigation(
358
349
  targetUrl,
359
350
  tx.handle.signal,
351
+ // The entry the commit will push, without what the server adds.
352
+ () => buildHistoryState(resolvedState),
360
353
  transitionOptOut
361
354
  ? () => true
362
355
  : (destination) =>
@@ -378,7 +371,7 @@ export function createNavigationBridge(
378
371
  url: targetUrl,
379
372
  params: { ...params, ...destination.params },
380
373
  routeName: destination.routeName,
381
- state: buildHistoryState(resolvedState),
374
+ state: destination.state,
382
375
  };
383
376
  },
384
377
  extra: destination.when,
@@ -578,9 +571,6 @@ export function createNavigationBridge(
578
571
  historyKey,
579
572
  );
580
573
 
581
- // Update location in event controller
582
- eventController.setLocation(new URL(url));
583
-
584
574
  // If this is an intercept, restore the intercept context
585
575
  if (isIntercept && interceptSourceUrl) {
586
576
  store.setInterceptSourceUrl(interceptSourceUrl);
@@ -648,9 +638,16 @@ export function createNavigationBridge(
648
638
  transitionGatedOff: gatedOff,
649
639
  });
650
640
  store.rememberDisplayedEntry();
651
- // Set params on event controller before onUpdate so both location
652
- // and params are current when the debounced notify() fires.
641
+ // The restored entry's location and params, set before onUpdate so
642
+ // the hooks take both in the batch that renders its tree. Not at
643
+ // the popstate event: the page being left is on screen until here
644
+ // (#1031). A restore that has to fetch gets its location from
645
+ // tx.commit() instead (navigation-transaction.ts).
646
+ eventController.setLocation(new URL(url));
653
647
  eventController.setParams(cachedParams);
648
+ // The entry this handler found at the event, which the cached tree
649
+ // was built for: history may have moved during the render above.
650
+ eventController.commitLocationState(historyState);
654
651
 
655
652
  const popstateUpdate = {
656
653
  root,
@@ -783,6 +780,32 @@ export function createNavigationBridge(
783
780
  return;
784
781
  }
785
782
 
783
+ // The browser is already on the entry that redirected. Replacing makes
784
+ // that entry the redirect target, so Back from the target reaches the
785
+ // entry before it; a push would leave the redirecting entry in history
786
+ // and Back would hit the redirect again. Before setLocation below, or
787
+ // the hooks would report the redirecting URL first.
788
+ if (error instanceof ServerRedirect) {
789
+ const redirectUrl = validateRedirectOrigin(
790
+ error.url,
791
+ window.location.origin,
792
+ );
793
+ if (!redirectUrl) {
794
+ return;
795
+ }
796
+ return this.navigate(redirectUrl, {
797
+ state: error.state,
798
+ replace: true,
799
+ _skipCache: true,
800
+ } as NavigateOptionsInternal);
801
+ }
802
+
803
+ // tx.commit() never ran, so the location is still the page being
804
+ // left. The error boundary below replaces that page, and history is
805
+ // already on the entry that failed: the hooks report that entry, in
806
+ // the boundary's batch.
807
+ eventController.setLocation(new URL(url));
808
+
786
809
  const networkError = toNetworkError(error, {
787
810
  url,
788
811
  operation: "navigation",
@@ -13,7 +13,6 @@ import type { EventController, NavigationHandle } from "./event-controller.js";
13
13
  import { debugLog } from "./logging.js";
14
14
  import {
15
15
  buildHistoryState,
16
- hasLocationState,
17
16
  mergeLocationState,
18
17
  pushHistoryWithIdx,
19
18
  } from "./history-state.js";
@@ -59,6 +58,11 @@ interface CommitOptions {
59
58
  * partial updater reuses it instead of deciding again.
60
59
  */
61
60
  transitionGatedOff?: boolean;
61
+ /**
62
+ * No payload follows this commit: every segment is kept and the tree on
63
+ * screen stays (EventController.commitLocationState).
64
+ */
65
+ treeless?: boolean;
62
66
  }
63
67
 
64
68
  /**
@@ -81,6 +85,8 @@ interface BoundCommitOverrides {
81
85
  serverState?: Record<string, unknown>;
82
86
  /** The committed route's name (payload metadata), remembered per history entry. */
83
87
  routeName?: string;
88
+ /** No payload follows this commit (CommitOptions.treeless). */
89
+ treeless?: boolean;
84
90
  }
85
91
 
86
92
  /**
@@ -187,10 +193,13 @@ export function createNavigationTransaction(
187
193
  }
188
194
 
189
195
  if (traversal) {
190
- if (serverState && Object.keys(serverState).length > 0) {
191
- mergeLocationState(serverState);
192
- }
196
+ // The entry as history restored it, plus what the server adds.
197
+ const entryState: unknown =
198
+ serverState && Object.keys(serverState).length > 0
199
+ ? mergeLocationState(serverState)
200
+ : window.history.state;
193
201
  store.rememberDisplayedEntry(opts.routeName);
202
+ eventController.commitLocationState(entryState, opts.treeless);
194
203
  handle.complete(parsedUrl);
195
204
  debugLog("[Browser] Traversal committed, historyKey:", historyKey);
196
205
  return { scroll };
@@ -202,15 +211,10 @@ export function createNavigationTransaction(
202
211
  serverState,
203
212
  );
204
213
 
205
- const oldState = window.history.state;
206
-
207
214
  pushHistoryWithIdx(historyState, url, replace ?? false);
208
215
  ensureHistoryKey();
209
216
  store.rememberDisplayedEntry(opts.routeName);
210
-
211
- if (hasLocationState(oldState) || hasLocationState(historyState)) {
212
- window.dispatchEvent(new Event("__rsc_locationstate"));
213
- }
217
+ eventController.commitLocationState(historyState, opts.treeless);
214
218
 
215
219
  handle.complete(parsedUrl);
216
220
 
@@ -269,6 +273,7 @@ export function createNavigationTransaction(
269
273
  cacheOnly,
270
274
  serverState,
271
275
  routeName: overrides?.routeName ?? opts.routeName,
276
+ treeless: overrides?.treeless,
272
277
  });
273
278
  },
274
279
  };
@@ -42,6 +42,7 @@ import {
42
42
  type TransitionWhenActionInput,
43
43
  } from "./transition-when.js";
44
44
  import { buildHistoryState } from "./history-state.js";
45
+ import { addLocationState } from "./react/location-state-shared.js";
45
46
  import type { TransitionWhenKind } from "../types/segments.js";
46
47
 
47
48
  function toScrollPayload(
@@ -225,8 +226,14 @@ export function createPartialUpdater(
225
226
  segments = segmentIds ?? segmentState.currentSegmentIds;
226
227
  }
227
228
 
229
+ // The page the client is on, which the server diffs the target against.
230
+ // tx.currentUrl is window.location when the transaction was created: on
231
+ // a back/forward the popstate event had already moved it to the target,
232
+ // and a server told the target is also the source revalidates nothing on
233
+ // the same route, so the page being left stayed on screen (#1030). The
234
+ // store's URL is the committed entry's until tx.commit().
228
235
  const previousUrl =
229
- mode.type === "leave-intercept"
236
+ mode.type === "leave-intercept" || (tx.traversal && !interceptSourceUrl)
230
237
  ? segmentState.currentUrl || tx.currentUrl
231
238
  : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
232
239
 
@@ -279,7 +286,7 @@ export function createPartialUpdater(
279
286
  params: metadata.params ?? mergeSegmentParams(segmentsToCommit),
280
287
  routeName: metadata.routeName,
281
288
  state: tx.traversal
282
- ? { ...window.history.state, ...metadata.locationState }
289
+ ? addLocationState(window.history.state, metadata.locationState)
283
290
  : buildHistoryState(tx.state, undefined, metadata.locationState),
284
291
  }),
285
292
  action:
@@ -499,6 +506,7 @@ export function createPartialUpdater(
499
506
  );
500
507
  tx.commit(matchedIds, existingSegments, {
501
508
  routeName: payload.metadata.routeName,
509
+ treeless: true,
502
510
  });
503
511
  debugLog("[Browser] Navigation complete (no re-render)");
504
512
  return;
@@ -7,7 +7,9 @@
7
7
  * cache keys responses by (URL, X-Rango-State value).
8
8
  *
9
9
  * Value format: `{buildVersion}:{invalidationTimestamp}`
10
- * - Build version changes on deploy, busting all cached prefetches at boot.
10
+ * - Build version: the router's document version. It changes when a deploy
11
+ * changes the router's server code or the client assets, busting all cached
12
+ * prefetches at boot; a deploy that changes neither keeps them.
11
13
  * - Timestamp rotates on invalidation (server action, invalidateClientCache).
12
14
  *
13
15
  * Storage is a session cookie named by the server-resolved name passed to
@@ -20,6 +20,7 @@ import {
20
20
  subscribeToPrefetchScopeChange,
21
21
  } from "../link-interceptor.js";
22
22
  import { subscribeToLocationChange } from "../event-controller.js";
23
+ import { subscribeToNothing } from "./subscribe-to-nothing.js";
23
24
  import { isLocationStateDefinition } from "./location-state-shared.js";
24
25
 
25
26
  export type StateOrGetter<T = unknown> = T | (() => T);
@@ -51,8 +52,6 @@ import type { PrefetchStrategy } from "../../router/prefetch-default.js";
51
52
  export type { PrefetchStrategy } from "../../router/prefetch-default.js";
52
53
  export { resolveAdaptiveStrategy } from "../prefetch/default-strategy.js";
53
54
 
54
- const IGNORE_STRATEGY_CHANGES = (_listener: () => void) => () => {};
55
-
56
55
  /**
57
56
  * Link component props
58
57
  */
@@ -274,7 +273,7 @@ export const Link: ForwardRefExoticComponent<
274
273
  const resolvedStrategy = useSyncExternalStore(
275
274
  configuredStrategy === "adaptive"
276
275
  ? subscribeToAdaptiveStrategyChange
277
- : IGNORE_STRATEGY_CHANGES,
276
+ : subscribeToNothing,
278
277
  () => resolveAdaptiveStrategy(configuredStrategy),
279
278
  () => (configuredStrategy === "adaptive" ? "hover" : configuredStrategy),
280
279
  );
@@ -11,6 +11,7 @@ import React, {
11
11
  type ReactNode,
12
12
  } from "react";
13
13
  import {
14
+ LocationStateContext,
14
15
  NavigationStoreContext,
15
16
  type NavigationStoreContextValue,
16
17
  } from "./context.js";
@@ -315,6 +316,11 @@ export function NavigationProvider({
315
316
  }: NavigationProviderProps): ReactNode {
316
317
  // Track current payload for rendering (this triggers re-renders)
317
318
  const [payload, setPayload] = useState(initialPayload);
319
+ // The location state of the entry `payload` renders (LocationStateContext).
320
+ // Only ever set next to setPayload, or for a commit that has no payload.
321
+ const [locationState, setLocationState] = useState(
322
+ eventController.getLocationState,
323
+ );
318
324
 
319
325
  /**
320
326
  * Navigate to a URL (delegates to bridge)
@@ -373,6 +379,19 @@ export function NavigationProvider({
373
379
  return startConnectionWarmup();
374
380
  }, [warmupEnabled]);
375
381
 
382
+ // A commit no payload follows (a shallow navigation, a commit that keeps
383
+ // every segment, merged server-set state): the tree on screen is already
384
+ // the entry's, so its state is taken with the commit's notification.
385
+ useEffect(
386
+ () =>
387
+ eventController.subscribe(() => {
388
+ if (eventController.takeTreelessLocationState()) {
389
+ setLocationState(eventController.getLocationState());
390
+ }
391
+ }),
392
+ [eventController],
393
+ );
394
+
376
395
  // Cancel non-matching prefetches when navigation starts.
377
396
  // Frees connections so the navigation fetch isn't competing with
378
397
  // speculative prefetches. The prefetch matching the navigation target
@@ -421,6 +440,9 @@ export function NavigationProvider({
421
440
  root: update.root,
422
441
  metadata: update.metadata,
423
442
  });
443
+ // The state of the entry this payload renders, as its commit recorded
444
+ // it: one update, so one lane, so one React commit with the tree.
445
+ setLocationState(eventController.getLocationState());
424
446
 
425
447
  // Update route params. Only reset when the server actually sends a params
426
448
  // map — an absent `params` field means "no change" (e.g., legacy action
@@ -506,7 +528,9 @@ export function NavigationProvider({
506
528
 
507
529
  return (
508
530
  <NavigationStoreContext.Provider value={contextValue}>
509
- {content}
531
+ <LocationStateContext.Provider value={locationState}>
532
+ {content}
533
+ </LocationStateContext.Provider>
510
534
  </NavigationStoreContext.Provider>
511
535
  );
512
536
  }
@@ -4,6 +4,7 @@ import { createContext, type Context } from "react";
4
4
  import type { NavigationStore, NavigateOptionsInternal } from "../types.js";
5
5
  import type { EventController } from "../event-controller.js";
6
6
  import type { PrefetchStrategy } from "../../router/prefetch-default.js";
7
+ import type { LocationStateSnapshot } from "./location-state-shared.js";
7
8
 
8
9
  /**
9
10
  * Navigation context value provided by NavigationProvider
@@ -66,3 +67,15 @@ export interface NavigationStoreContextValue {
66
67
  */
67
68
  export const NavigationStoreContext: Context<NavigationStoreContextValue | null> =
68
69
  createContext<NavigationStoreContextValue | null>(null);
70
+
71
+ /**
72
+ * The location state of the history entry whose tree is on screen, for
73
+ * useLocationState. NavigationProvider holds it as React state next to the
74
+ * payload and sets both in one update, so a render sees an entry's state
75
+ * exactly when it sees that entry's tree: a transition React holds renders
76
+ * the destination's value, an urgent render of the content still on screen
77
+ * (a reader mounting there included) the value of the entry being left
78
+ * (#1029). Undefined on the server and outside a provider.
79
+ */
80
+ export const LocationStateContext: Context<LocationStateSnapshot> =
81
+ createContext<LocationStateSnapshot>(undefined);