@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
@@ -11,12 +11,177 @@ export interface LocationStateEntry {
11
11
  readonly __rsc_ls_lazy?: boolean;
12
12
  }
13
13
 
14
+ /** Prefix of every typed location-state slot in `history.state`. */
15
+ export const LOCATION_STATE_KEY_PREFIX: string = "__rsc_ls_";
16
+
17
+ export function isLocationStateKey(key: string): boolean {
18
+ return key.startsWith(LOCATION_STATE_KEY_PREFIX);
19
+ }
20
+
21
+ /**
22
+ * `clearOnReload` is part of a slot's KEY (`<key>~r`); the value is always
23
+ * stored raw. Slots outlive deploys, so nothing that reads one may depend on a
24
+ * stored format: a definition without the option reads the plain key and never
25
+ * sees such a slot.
26
+ *
27
+ * An injected key ends in `#<ExportName>` (expose-id-utils.ts makeStubId; only
28
+ * identifier exports are keyed) and "~" cannot occur in an identifier, so no
29
+ * file or export name produces a suffix. withLocationStateKey rejects it.
30
+ */
31
+ export const LOCATION_STATE_KEY_SUFFIX_SEPARATOR: string = "~";
32
+ export const CLEAR_ON_RELOAD_KEY_SUFFIX: string = "~r";
33
+
34
+ /**
35
+ * A history entry records the app version its location state (typed slots and
36
+ * plain `state`) was written under, in this one field; a read returns only
37
+ * state recorded under the version this document loaded with. Entries outlive
38
+ * deploys, and state an older release stored would otherwise reach code that
39
+ * no longer expects its shape. An entry without the field counts as another
40
+ * version.
41
+ *
42
+ * Not a slot key (no LOCATION_STATE_KEY_PREFIX): hasLocationState must not
43
+ * count it.
44
+ */
45
+ const LOCATION_STATE_VERSION_KEY = "__rsc_lsv";
46
+
47
+ let documentVersion: string | undefined;
48
+
49
+ /**
50
+ * @internal The version the document loaded under (rsc-router.tsx
51
+ * initBrowserApp). Deliberately not getAppVersion(): a dev HMR bump moves that
52
+ * one inside a running session (bridge.updateVersion), and the state that
53
+ * session wrote must stay readable until the next document load.
54
+ */
55
+ export function setLocationStateVersion(version: string | undefined): void {
56
+ documentVersion = version;
57
+ }
58
+
59
+ /**
60
+ * The state of a `{ state }` snapshot when its location state may be read:
61
+ * an object recorded under the client's version. Without a snapshot, the
62
+ * current entry (`window.history` is a `{ state }`), and nothing on the
63
+ * server.
64
+ */
65
+ export function readableLocationState(
66
+ location: { readonly state: unknown } | undefined = typeof window ===
67
+ "undefined"
68
+ ? undefined
69
+ : window.history,
70
+ ): Record<string, unknown> | undefined {
71
+ const state = location?.state;
72
+ return state !== null &&
73
+ typeof state === "object" &&
74
+ (state as Record<string, unknown>)[LOCATION_STATE_VERSION_KEY] ===
75
+ documentVersion
76
+ ? (state as Record<string, unknown>)
77
+ : undefined;
78
+ }
79
+
80
+ /**
81
+ * One history entry's location state as useLocationState readers receive it:
82
+ * its typed slots and plain `state`, or undefined when it holds none.
83
+ */
84
+ export type LocationStateSnapshot =
85
+ | Readonly<Record<string, unknown>>
86
+ | undefined;
87
+
88
+ /**
89
+ * The snapshot of `entryState`: the state object a commit pushes, restores or
90
+ * merges into its history entry. Only state recorded under the client's
91
+ * version is in it (readableLocationState).
92
+ *
93
+ * The snapshot is a React context value, so identity decides who re-renders.
94
+ * It is `previous` itself when every slot holds the value `previous` holds:
95
+ * an entry without state after another, a primitive carried over, or the
96
+ * object a reader got from `previous` passed back by the navigation. Any
97
+ * other value is new, equal content included: a link clicked twice announces
98
+ * its state twice (a flash message, a scroll target). A new value is a
99
+ * structured clone, as history.state holds it, so a reader never shares an
100
+ * object with the caller of the navigation.
101
+ */
102
+ export function locationStateSnapshot(
103
+ entryState: unknown,
104
+ previous?: LocationStateSnapshot,
105
+ ): LocationStateSnapshot {
106
+ const state = readableLocationState({ state: entryState });
107
+ let next: Record<string, unknown> | undefined;
108
+ let kept = 0;
109
+ for (const key in state) {
110
+ if (key !== "state" && !isLocationStateKey(key)) continue;
111
+ next ??= {};
112
+ const value = state[key];
113
+ if (
114
+ previous &&
115
+ Object.hasOwn(previous, key) &&
116
+ Object.is(previous[key], value)
117
+ ) {
118
+ next[key] = value;
119
+ kept++;
120
+ } else {
121
+ next[key] = structuredClone(value);
122
+ }
123
+ }
124
+ return previous &&
125
+ next &&
126
+ kept === Object.keys(next).length &&
127
+ kept === Object.keys(previous).length
128
+ ? previous
129
+ : next;
130
+ }
131
+
132
+ /**
133
+ * Record the client's version on a state object that carries location state
134
+ * and no record yet. A client without a version (a renderRoute tree) writes
135
+ * none.
136
+ */
137
+ export function stampLocationState(state: Record<string, unknown>): void {
138
+ if (documentVersion !== undefined) {
139
+ state[LOCATION_STATE_VERSION_KEY] = documentVersion;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * `entryState` plus `additions`, recorded under the client's version. The
145
+ * record is per entry, so location state another version left there is dropped
146
+ * first, with its record: under the new one it would be readable again. Router
147
+ * bookkeeping (idx, scroll key, intercept) is kept.
148
+ */
149
+ export function addLocationState(
150
+ entryState: unknown,
151
+ additions?: Record<string, unknown>,
152
+ ): Record<string, unknown> {
153
+ // history.state may be a primitive if non-Rango code stored one; spreading a
154
+ // string would yield indexed char keys.
155
+ const next: Record<string, unknown> = {
156
+ ...(typeof entryState === "object" ? entryState : null),
157
+ };
158
+ if (next[LOCATION_STATE_VERSION_KEY] !== documentVersion) {
159
+ for (const key in next) {
160
+ if (
161
+ key === "state" ||
162
+ key === LOCATION_STATE_VERSION_KEY ||
163
+ isLocationStateKey(key)
164
+ ) {
165
+ delete next[key];
166
+ }
167
+ }
168
+ stampLocationState(next);
169
+ }
170
+ return Object.assign(next, additions);
171
+ }
172
+
14
173
  /**
15
174
  * Options for createLocationState
16
175
  */
17
176
  export interface LocationStateOptions {
18
177
  /** When true, the state is cleared from history after first read (flash message pattern) */
19
178
  flash?: boolean;
179
+ /**
180
+ * When true, the state does not survive a document load of its entry (a
181
+ * refresh, a back/forward that loads the document): the server rendered
182
+ * without it. Not together with `flash`.
183
+ */
184
+ clearOnReload?: boolean;
20
185
  }
21
186
 
22
187
  type LocationStateUnsafeFn = (...args: never[]) => unknown;
@@ -236,13 +401,16 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
236
401
  (...args: TArgs): LocationStateEntry;
237
402
  /**
238
403
  * Injected by Vite plugin - do not set manually. Unit tests without the
239
- * plugin use withLocationStateKey() from @rangojs/router/testing.
404
+ * plugin use withLocationStateKey() from @rangojs/router/testing. Reads back
405
+ * as the slot's key in `history.state`: the injected key plus the
406
+ * `clearOnReload` suffix.
240
407
  */
241
408
  __rsc_ls_key: string;
242
409
  /** Whether this state auto-clears after first read */
243
410
  readonly __rsc_ls_flash: boolean;
244
411
  /**
245
- * Read the current value from history.state.
412
+ * Read the current value from history.state. State the entry holds from
413
+ * another version of the app (an older build) reads as undefined.
246
414
  *
247
415
  * Returns undefined during SSR (no `window`). To stay hydration-safe, do
248
416
  * NOT call read() inline during the initial render — the server returns
@@ -263,13 +431,14 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
263
431
  /**
264
432
  * Statically write the value into the current history entry under this
265
433
  * definition's key, preserving any other keys already on history.state
266
- * (e.g. router bookkeeping, other LocationState slots).
434
+ * (e.g. router bookkeeping, other LocationState slots). Location state
435
+ * another version of the app left on the entry is dropped.
267
436
  *
268
- * This is the non-reactive counterpart to read(): it does not dispatch any
269
- * event, so components reading via useLocationState() will NOT re-render
270
- * until the next navigation/popstate. Use it when you only need the value
271
- * to be there on the next read() or on the next mount (including after
272
- * back/forward and hard refresh of the same entry).
437
+ * This is the non-reactive counterpart to read(): it changes the history
438
+ * entry and nothing a useLocationState() reader shows, whether the reader
439
+ * is mounted now or mounts later. read() returns the value at once; a
440
+ * reader gets it when the entry is next restored: back/forward to it, or
441
+ * (unless the definition sets `clearOnReload`) a hard refresh of it.
273
442
  *
274
443
  * Client-only: throws when called on the server (no history available).
275
444
  */
@@ -279,8 +448,8 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
279
448
  * leaving any other keys on history.state untouched. Idempotent: removing
280
449
  * a slot that isn't present is a no-op.
281
450
  *
282
- * Same non-reactive semantics as write(): no event is dispatched, so
283
- * useLocationState() readers will NOT re-render until the next navigation.
451
+ * Same non-reactive semantics as write(): useLocationState() readers keep
452
+ * the value until the entry is next restored.
284
453
  *
285
454
  * Client-only: throws when called on the server (no history available).
286
455
  */
@@ -321,9 +490,8 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
321
490
  * const snap = ProductState.read();
322
491
  *
323
492
  * // Static write to current history entry (non-reactive, client-side only).
324
- * // Survives back/forward and hard refresh; useLocationState() readers will
325
- * // NOT see the new value until the next navigation. Pair with .read() or a
326
- * // fresh mount.
493
+ * // Survives back/forward and hard refresh; useLocationState() readers see
494
+ * // the new value when the entry is next restored. Pair with .read().
327
495
  * ProductState.write({ name: "Widget", price: 9.99 });
328
496
  *
329
497
  * // Manually clear the slot (non-reactive, client-side only).
@@ -357,6 +525,19 @@ export function createLocationState<TState>(
357
525
  TState
358
526
  > {
359
527
  const flash = options?.flash ?? false;
528
+ const clearOnReload = options?.clearOnReload ?? false;
529
+ // Empty without `clearOnReload`: the injected key is then the storage key,
530
+ // as it was before the option existed.
531
+ const keySuffix = clearOnReload ? CLEAR_ON_RELOAD_KEY_SUFFIX : "";
532
+ // Flash state ends at its first read, so the pair could only drop a message
533
+ // nobody has seen. Folded out of production, like the missing-key check.
534
+ if (process.env.NODE_ENV !== "production" && flash && clearOnReload) {
535
+ throw new Error(
536
+ "[rango] createLocationState: `flash` and `clearOnReload` cannot be combined. " +
537
+ "Flash state is removed at its first read, so `clearOnReload` could only " +
538
+ "drop a flash message that has not been shown yet.",
539
+ );
540
+ }
360
541
  let _key: string | undefined;
361
542
 
362
543
  // Dev and test throw; production folds the check away (the plugin always
@@ -395,7 +576,7 @@ export function createLocationState<TState>(
395
576
  Object.defineProperty(fn, "__rsc_ls_key", {
396
577
  get: () => getKey(),
397
578
  set: (k: string) => {
398
- _key = k;
579
+ _key = k && k + keySuffix;
399
580
  },
400
581
  enumerable: true,
401
582
  configurable: true,
@@ -407,16 +588,8 @@ export function createLocationState<TState>(
407
588
  });
408
589
 
409
590
  Object.defineProperty(fn, "read", {
410
- value: (location?: { readonly state: unknown }): TState | undefined => {
411
- if (location !== undefined) {
412
- const state = location.state;
413
- return state !== null && typeof state === "object"
414
- ? ((state as Record<string, unknown>)[getKey()] as TState | undefined)
415
- : undefined;
416
- }
417
- if (typeof window === "undefined") return undefined;
418
- return window.history.state?.[getKey()] as TState | undefined;
419
- },
591
+ value: (location?: { readonly state: unknown }): TState | undefined =>
592
+ readableLocationState(location)?.[getKey()] as TState | undefined,
420
593
  enumerable: true,
421
594
  });
422
595
 
@@ -428,16 +601,9 @@ export function createLocationState<TState>(
428
601
  "It mutates window.history.state and cannot run on the server.",
429
602
  );
430
603
  }
431
- const key = getKey();
432
- // history.state may be a non-null primitive (string/number/boolean) if
433
- // non-Rango code called pushState/replaceState with one. `?? {}` only
434
- // catches null/undefined, so spreading a primitive would yield indexed
435
- // char/no keys and corrupt history.state. Coerce any non-object to a fresh
436
- // dict — mirrors the delete() guard.
437
- const existing = window.history.state;
438
- const current =
439
- existing !== null && typeof existing === "object" ? existing : {};
440
- replaceCurrentHistoryState({ ...current, [key]: value });
604
+ replaceCurrentHistoryState(
605
+ addLocationState(window.history.state, { [getKey()]: value }),
606
+ );
441
607
  },
442
608
  enumerable: true,
443
609
  });
@@ -1,7 +1,11 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect, useRef } from "react";
3
+ import { useContext, useEffect, useSyncExternalStore } from "react";
4
+ import { subscribeToNothing } from "./subscribe-to-nothing.js";
5
+ import { LocationStateContext } from "./context.js";
6
+ import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
4
7
  import {
8
+ readableLocationState,
5
9
  replaceCurrentHistoryState,
6
10
  type LocationStateDefinition,
7
11
  } from "./location-state-shared.js";
@@ -15,29 +19,25 @@ export {
15
19
  type LocationStateOptions,
16
20
  } from "./location-state-shared.js";
17
21
 
18
- function readLocationStateValue<TState>(
19
- key: string | undefined,
20
- ): TState | undefined {
21
- if (typeof window === "undefined") return undefined;
22
- if (key) {
23
- return window.history.state?.[key] as TState | undefined;
24
- }
25
- // Plain state: stored under history.state.state
26
- return window.history.state?.state as TState | undefined;
27
- }
28
-
29
- function hasHydrated(): boolean {
30
- return (
31
- typeof document !== "undefined" &&
32
- document.documentElement.hasAttribute("data-hydrated")
33
- );
22
+ /**
23
+ * Server and hydration renders must match the SSR output (`undefined`).
24
+ * React then re-renders with `getSnapshot`, including a reader whose Suspense
25
+ * boundary hydrates after the root has set `data-hydrated`.
26
+ */
27
+ function getServerSnapshot(): undefined {
28
+ return undefined;
34
29
  }
35
30
 
36
31
  /**
37
- * Hook to read location state from history.state
32
+ * Hook to read the location state of the history entry on screen
33
+ *
34
+ * A reader sees an entry's state together with that entry's tree: on a
35
+ * navigation the value changes in the React commit that brings the
36
+ * destination, so content a pending navigation keeps on screen keeps the
37
+ * state of the entry being left, for a reader that mounts there too (#1029).
38
38
  *
39
39
  * Behavior depends on the definition:
40
- * - Normal state: persists across navigations, reactive to popstate
40
+ * - Normal state: persists across navigations and back/forward
41
41
  * - Flash state (created with { flash: true }): read once, cleared after paint
42
42
  *
43
43
  * Overloaded:
@@ -67,69 +67,34 @@ export function useLocationState<TArgs extends unknown[], TState>(
67
67
  ): TState | undefined {
68
68
  const key = definition?.__rsc_ls_key;
69
69
  const isFlash = definition?.__rsc_ls_flash ?? false;
70
+ const entry = useContext(LocationStateContext);
71
+ const optimistic = useContext(OptimisticLocationContext);
70
72
 
71
- // Track whether the initial render returned undefined because the page
72
- // hadn't hydrated yet. If so, the mount effect catches up by reading
73
- // history.state once. If not, we already have the right value and must
74
- // not re-read on mount — under StrictMode, the flash-cleanup effect runs
75
- // before the second setup pass, so a re-read would clobber the captured
76
- // value with the now-cleared `undefined`.
77
- const initialReadDeferredRef = useRef(false);
78
-
79
- const [state, setState] = useState<TState | undefined>(() => {
80
- if (!hasHydrated()) {
81
- initialReadDeferredRef.current = true;
82
- return undefined;
83
- }
84
- return readLocationStateValue<TState>(key);
85
- });
86
-
87
- // Subscribe to popstate and programmatic state changes
88
- useEffect(() => {
89
- const handlePopstate = () => {
90
- setState(readLocationStateValue<TState>(key));
91
- };
92
-
93
- // Handle programmatic state changes (same-page navigation with
94
- // ctx.setLocationState where components don't remount)
95
- const handleLocationState = () => {
96
- if (key) {
97
- const val = readLocationStateValue<TState>(key);
98
- if (isFlash) {
99
- // For flash state, only update if there's a new value
100
- if (val !== undefined) {
101
- setState(val);
102
- }
103
- } else {
104
- setState(val);
105
- }
106
- } else {
107
- setState(readLocationStateValue<TState>(key));
108
- }
109
- };
110
-
111
- if (initialReadDeferredRef.current) {
112
- initialReadDeferredRef.current = false;
113
- setState(readLocationStateValue<TState>(key));
114
- }
115
-
116
- window.addEventListener("popstate", handlePopstate);
117
- window.addEventListener("__rsc_locationstate", handleLocationState);
118
- return () => {
119
- window.removeEventListener("popstate", handlePopstate);
120
- window.removeEventListener("__rsc_locationstate", handleLocationState);
121
- };
122
- }, [key, isFlash]);
73
+ // Typed state: the slot under its key. Plain state: the `state` slot.
74
+ // Inside an optimistically rendered clientUrls() destination the provider
75
+ // still holds the entry being left: read the entry the navigation will push.
76
+ const shown = (optimistic ? readableLocationState(optimistic) : entry)?.[
77
+ key || "state"
78
+ ] as TState | undefined;
79
+ // useSyncExternalStore only for its server snapshot: no other hook tells a
80
+ // hydrating render from a client one.
81
+ const state = useSyncExternalStore<TState | undefined>(
82
+ subscribeToNothing,
83
+ () => shown,
84
+ getServerSnapshot,
85
+ );
123
86
 
124
- // Flash: clear from history.state after paint so subsequent navigations don't see it.
125
- // Depends on `state` so it re-runs when state is set via the event listener.
87
+ // Flash: removed from history.state after paint, so a reload or a return to
88
+ // the entry does not show it again. The provider's snapshot keeps it, so it
89
+ // stays on screen until the next commit of the entry's state. Not while the
90
+ // value is the optimistic entry's: history.state is another entry's.
126
91
  useEffect(() => {
127
- if (isFlash && key && state !== undefined) {
92
+ if (isFlash && key && !optimistic && state !== undefined) {
128
93
  const cleaned = { ...window.history.state };
129
94
  delete cleaned[key];
130
95
  replaceCurrentHistoryState(cleaned);
131
96
  }
132
- }, [isFlash, key, state]);
97
+ }, [isFlash, key, state, optimistic]);
133
98
 
134
99
  return state;
135
100
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `subscribe` for a useSyncExternalStore whose snapshot nothing notifies: the
3
+ * hook is there for its server snapshot (useLocationState, useHandle), or the
4
+ * value only changes with a render (Link's non-adaptive prefetch strategy).
5
+ * One function, so its identity is stable across renders and hooks.
6
+ */
7
+ export const subscribeToNothing: () => () => void = () => () => {};
@@ -6,6 +6,7 @@ import {
6
6
  useEffect,
7
7
  useRef,
8
8
  useOptimistic,
9
+ useSyncExternalStore,
9
10
  startTransition,
10
11
  } from "react";
11
12
  import type { Handle } from "../../handle.js";
@@ -13,6 +14,9 @@ import { collectHandleData } from "../../handle.js";
13
14
  import type { HandleData } from "../types.js";
14
15
  import { NavigationStoreContext } from "./context.js";
15
16
  import { shallowEqual } from "./shallow-equal.js";
17
+ import { subscribeToNothing } from "./subscribe-to-nothing.js";
18
+
19
+ const noHydrationState = (): undefined => undefined;
16
20
 
17
21
  /**
18
22
  * Hook to access collected handle data.
@@ -43,6 +47,21 @@ export function useHandle<T, A, S>(
43
47
  ): Rango.FlightSerialize<A> | S {
44
48
  const ctx = useContext(NavigationStoreContext);
45
49
 
50
+ // useSyncExternalStore only for its server snapshot, as useLocationState
51
+ // (#992): no other hook tells a hydrating render from a client one. A
52
+ // hydrating render gets the handle state the document's HTML was rendered
53
+ // with (EventController.getHydrationHandleState), whenever its boundary
54
+ // hydrates; the mount effect below moves it on to the live state. That
55
+ // state is undefined while the live state is still it, and the client
56
+ // snapshot is always undefined: a reader that hydrated before anything
57
+ // arrived late is not rendered again, and to React the store never
58
+ // changes (issue #1035).
59
+ const hydrationState = useSyncExternalStore(
60
+ subscribeToNothing,
61
+ noHydrationState,
62
+ () => ctx?.eventController.getHydrationHandleState(),
63
+ );
64
+
46
65
  const [value, setValue] = useState<Rango.FlightSerialize<A> | S>(() => {
47
66
  if (!ctx) {
48
67
  const collected = collectHandleData(
@@ -53,7 +72,7 @@ export function useHandle<T, A, S>(
53
72
  return selector ? selector(collected) : collected;
54
73
  }
55
74
 
56
- const state = ctx.eventController.getHandleState();
75
+ const state = hydrationState ?? ctx.eventController.getHandleState();
57
76
  const collected = collectHandleData(
58
77
  handle,
59
78
  state.data,
@@ -39,7 +39,11 @@ import {
39
39
  } from "./intercept-utils.js";
40
40
  import { createAppShellRef } from "./app-shell.js";
41
41
  import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
42
- import { stripShellMissMarker } from "./history-state.js";
42
+ import {
43
+ clearLocationStateOnDocumentLoad,
44
+ stripShellMissMarker,
45
+ } from "./history-state.js";
46
+ import { setLocationStateVersion } from "./react/location-state-shared.js";
43
47
  import { setActiveInterceptTargets } from "../client-urls/navigation.js";
44
48
 
45
49
  // Vite HMR types are provided by vite/client
@@ -174,6 +178,9 @@ export async function initBrowserApp(
174
178
  // event controller): the server rendered the marked request for the clean
175
179
  // URL, so the client starts from the same one and hydration agrees.
176
180
  stripShellMissMarker();
181
+ // Before the first await: the store's entry memory, readers and the
182
+ // navigation / action lanes that deliver server-set state all come later.
183
+ clearLocationStateOnDocumentLoad();
177
184
  bootLog("flight decode: awaiting initial payload from document stream");
178
185
  const initialPayload =
179
186
  await deps.createFromReadableStream<RscPayload>(rscStream);
@@ -211,6 +218,8 @@ export async function initBrowserApp(
211
218
  // default name active during that wait would discard this router's messages.
212
219
  const version = initialPayload.metadata?.version;
213
220
  initRangoState(version ?? "0", initialPayload.metadata?.stateCookieName);
221
+ // Before anything can read location state: hydration starts further down.
222
+ setLocationStateVersion(version);
214
223
 
215
224
  // Seed the intercept-target set for the initial location so the FIRST
216
225
  // clientUrls navigation already declines intercepted targets (refreshed on
@@ -247,6 +256,10 @@ export async function initBrowserApp(
247
256
  initialLocation: new URL(window.location.href),
248
257
  });
249
258
 
259
+ // The document's entry, after the clearOnReload pass and under the version
260
+ // set above: what readers get once they have hydrated.
261
+ eventController.commitLocationState(window.history.state);
262
+
250
263
  // Initialize event controller with segment order (even without handles)
251
264
  eventController.setHandleData({}, initialPayload.metadata?.matched);
252
265
 
@@ -294,9 +307,9 @@ export async function initBrowserApp(
294
307
  if (lateGenerator) {
295
308
  void (async () => {
296
309
  try {
297
- // Never apply before React commits hydration — a pre-commit
298
- // application mutates the state useHandle initializers read during
299
- // the hydration render (mismatch). Yields buffer in the generator
310
+ // Applied once the root has hydrated (see hydrationCommitted): the
311
+ // readers that hydrated with it are subscribed by then and take
312
+ // the update as an ordinary one. Yields buffer in the generator
300
313
  // meanwhile; nothing is lost.
301
314
  await hydrationCommitted;
302
315
  for await (const rawLateData of lateGenerator) {
@@ -324,6 +337,10 @@ export async function initBrowserApp(
324
337
  } else {
325
338
  bootLog("handles: none in payload");
326
339
  }
340
+ // What every reader hydrates with, whenever its boundary hydrates
341
+ // (useHandle): the late channel above changes the live state once the root
342
+ // has hydrated, and a navigation can before a streamed boundary has.
343
+ eventController.freezeHydrationHandleState();
327
344
 
328
345
  // Create composable utilities
329
346
  const client = createNavigationClient(deps);
@@ -621,13 +638,18 @@ export async function initBrowserApp(
621
638
  // re-runs the root effect; the second flush is not a second hydration).
622
639
  let hydrationCommitLogged = false;
623
640
 
624
- // Hydration-commit barrier for the late handle channel. useHandle's useState
625
- // initializer reads the eventController's LIVE state during the hydration
626
- // render, so applying a late handle update BEFORE hydration commits changes
627
- // that state out from under React — the classic "external changing data"
628
- // hydration mismatch (seen when a loader's push lost the handler-barrier race
629
- // by milliseconds and its late yield landed mid-hydration). The root effect
630
- // below resolves this; the late consumer awaits it before applying anything.
641
+ // Hydration-commit barrier for the late handle channel: the late consumer
642
+ // applies nothing before the root's effects have run. A hydrating render
643
+ // reads the handle state frozen before hydrateRoot (useHandle,
644
+ // EventController.getHydrationHandleState), so an earlier application would
645
+ // no longer mismatch (it did while the initializer read the LIVE state: seen
646
+ // when a loader's push lost the handler-barrier race by milliseconds and its
647
+ // late yield landed mid-hydration). What the barrier still buys: a reader
648
+ // that hydrates after the live state moved on is rendered a second time
649
+ // right after, since React re-renders a reader whose hydration state is not
650
+ // the client one. Readers that hydrate with the root would all pay that;
651
+ // after the commit they are subscribed and take the update as an ordinary
652
+ // one. The root effect below resolves this.
631
653
  let resolveHydrationCommitted!: () => void;
632
654
  const hydrationCommitted: Promise<void> = new Promise((resolve) => {
633
655
  resolveHydrationCommitted = resolve;
@@ -36,6 +36,7 @@ import {
36
36
  isForeignRouterId,
37
37
  } from "./response-adapter.js";
38
38
  import { mergeLocationState } from "./history-state.js";
39
+ import { stampLocationState } from "./react/location-state-shared.js";
39
40
  import { classifyActionOutcome } from "./action-coordinator.js";
40
41
  import { getAppVersion } from "./app-version.js";
41
42
  import { collectClientRevalidationDecisions } from "../client-urls/navigation.js";
@@ -79,11 +80,22 @@ export interface ServerActionBridgeConfigWithController extends ServerActionBrid
79
80
  function applyActionLocationState(
80
81
  handle: ActionHandle,
81
82
  locationState: Record<string, unknown> | undefined,
83
+ eventController: EventController,
82
84
  ): void {
83
85
  if (!locationState) return;
84
86
  const winning = handle.claimLocationState(locationState);
85
87
  if (Object.keys(winning).length > 0) {
86
88
  mergeLocationState(winning);
89
+ // The entry and its tree stay, so readers take the action's slots now,
90
+ // not with its revalidated tree: several terminals commit no tree, and a
91
+ // navigation during that render drops it. Added to what readers hold,
92
+ // not re-read from the entry: a flash value on screen was already cleared
93
+ // from history, and a write()/delete() stays unseen until the entry is
94
+ // restored.
95
+ const shown = { ...eventController.getLocationState(), ...winning };
96
+ stampLocationState(shown);
97
+ eventController.commitLocationState(shown, true);
98
+ eventController.flushRouteState();
87
99
  }
88
100
  }
89
101
 
@@ -734,7 +746,11 @@ export function createServerActionBridge(
734
746
  // before the normal branch's async renderSegments) so a slow render racing
735
747
  // a navigation cannot drop it.
736
748
  if (scenario.type !== "navigated-away") {
737
- applyActionLocationState(handle, metadata?.locationState);
749
+ applyActionLocationState(
750
+ handle,
751
+ metadata?.locationState,
752
+ eventController,
753
+ );
738
754
  }
739
755
  // transition({ when }) `action` for this action's commit, whichever
740
756
  // lane applies it (normal, or a refetch below).