@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.
- package/dist/bin/rango.js +26 -14
- package/dist/types/browser/event-controller.d.ts +32 -0
- package/dist/types/browser/history-state.d.ts +16 -4
- package/dist/types/browser/navigation-transaction.d.ts +7 -0
- package/dist/types/browser/rango-state.d.ts +3 -1
- package/dist/types/browser/react/context.d.ts +11 -0
- package/dist/types/browser/react/location-state-shared.d.ts +84 -10
- package/dist/types/browser/react/location-state.d.ts +7 -2
- package/dist/types/browser/react/subscribe-to-nothing.d.ts +7 -0
- package/dist/types/cache/cache-scope.d.ts +4 -5
- package/dist/types/cache/cf/cf-cache-store.d.ts +27 -6
- package/dist/types/cache/cf/cf-cache-types.d.ts +12 -4
- package/dist/types/cache/handle-snapshot.d.ts +36 -50
- package/dist/types/cache/index.d.ts +2 -0
- package/dist/types/cache/shell-snapshot.d.ts +19 -3
- package/dist/types/cache/types.d.ts +21 -10
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +28 -16
- package/dist/types/client-urls/navigation.d.ts +7 -1
- package/dist/types/client-urls/optimistic-location.d.ts +10 -4
- package/dist/types/handles/deferred-resolution.d.ts +11 -1
- package/dist/types/prerender/build-shell-capture.d.ts +8 -6
- package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
- package/dist/types/router/navigation-snapshot.d.ts +8 -1
- package/dist/types/router/router-interfaces.d.ts +2 -1
- package/dist/types/router/router-options.d.ts +17 -7
- package/dist/types/router/segment-resolution/loader-cache.d.ts +21 -9
- package/dist/types/router-versions.d.ts +39 -0
- package/dist/types/rsc/handler-context.d.ts +5 -0
- package/dist/types/rsc/shell-build-manifest.d.ts +3 -2
- package/dist/types/rsc/types.d.ts +4 -6
- package/dist/types/server/build-version-table.d.ts +44 -0
- package/dist/types/server/handle-store.d.ts +87 -46
- package/dist/types/server/request-context.d.ts +22 -8
- package/dist/types/testing/build-versions.d.ts +32 -0
- package/dist/types/testing/dom.entry.d.ts +1 -1
- package/dist/types/testing/flight.entry.d.ts +1 -1
- package/dist/types/testing/index.d.ts +2 -0
- package/dist/types/testing/internal/seed-vars.d.ts +7 -2
- package/dist/types/testing/location-state-key.d.ts +5 -1
- package/dist/types/testing/render-route.d.ts +77 -6
- package/dist/types/testing/serve-shell-request.d.ts +21 -0
- package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
- package/dist/types/types/handler-context.d.ts +8 -5
- package/dist/types/vite/discovery/build-versions.d.ts +393 -0
- package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
- package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
- package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
- package/dist/types/vite/discovery/state.d.ts +14 -0
- package/dist/types/vite/encryption-key.d.ts +18 -0
- package/dist/types/vite/plugin-types.d.ts +27 -0
- package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
- package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
- package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
- package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
- package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
- package/dist/types/vite/plugins/virtual-entries.d.ts +18 -1
- package/dist/types/vite/router-discovery.d.ts +6 -0
- package/dist/types/vite/utils/client-chunks.d.ts +2 -0
- package/dist/types/vite/utils/compare-strings.d.ts +7 -0
- package/dist/types/vite/utils/package-resolution.d.ts +8 -0
- package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
- package/dist/vite/index.js +1537 -444
- package/package.json +1 -2
- package/skills/cache-guide/SKILL.md +84 -20
- package/skills/caching/SKILL.md +16 -5
- package/skills/client-urls/SKILL.md +16 -0
- package/skills/cloudflare/SKILL.md +2 -1
- package/skills/comparison/references/framework-comparison.md +16 -12
- package/skills/hooks/SKILL.md +3 -0
- package/skills/hooks/handle-and-actions.md +6 -0
- package/skills/hooks/navigation.md +28 -0
- package/skills/hooks/state.md +209 -8
- package/skills/hooks/urls.md +40 -1
- package/skills/host-router/SKILL.md +13 -0
- package/skills/intercept/SKILL.md +6 -0
- package/skills/loader/SKILL.md +19 -1
- package/skills/middleware/SKILL.md +2 -0
- package/skills/ppr/SKILL.md +119 -40
- package/skills/prerender/SKILL.md +7 -3
- package/skills/rango/SKILL.md +8 -7
- package/skills/router-setup/SKILL.md +15 -1
- package/skills/testing/SKILL.md +30 -26
- package/skills/testing/client-components.md +193 -8
- package/skills/testing/handles.md +2 -0
- package/skills/testing/loader.md +1 -1
- package/skills/testing/middleware.md +1 -1
- package/skills/testing/render-handler.md +18 -18
- package/skills/testing/serve-shell-request.md +63 -14
- package/skills/testing/server-actions.md +17 -17
- package/skills/testing/server-tree.md +1 -1
- package/skills/typesafety/generated-files-and-cli.md +1 -1
- package/skills/use-cache/SKILL.md +16 -5
- package/skills/vercel/SKILL.md +3 -3
- package/src/bin/rango.ts +1 -1
- package/src/browser/event-controller.ts +75 -2
- package/src/browser/history-state.ts +39 -17
- package/src/browser/navigation-bridge.ts +41 -18
- package/src/browser/navigation-transaction.ts +15 -10
- package/src/browser/partial-update.ts +10 -2
- package/src/browser/rango-state.ts +3 -1
- package/src/browser/react/Link.tsx +2 -3
- package/src/browser/react/NavigationProvider.tsx +25 -1
- package/src/browser/react/context.ts +13 -0
- package/src/browser/react/location-state-shared.ts +200 -34
- package/src/browser/react/location-state.ts +40 -75
- package/src/browser/react/subscribe-to-nothing.ts +7 -0
- package/src/browser/react/use-handle.ts +20 -1
- package/src/browser/rsc-router.tsx +33 -11
- package/src/browser/server-action-bridge.ts +17 -1
- package/src/cache/cache-scope.ts +18 -8
- package/src/cache/cf/cf-cache-store.ts +75 -55
- package/src/cache/cf/cf-cache-types.ts +12 -4
- package/src/cache/handle-snapshot.ts +69 -87
- package/src/cache/index.ts +4 -0
- package/src/cache/shell-snapshot.ts +35 -5
- package/src/cache/types.ts +21 -10
- package/src/cache/vercel/vercel-cache-store.ts +48 -20
- package/src/client-urls/client-root.tsx +1 -0
- package/src/client-urls/navigation.ts +10 -0
- package/src/client-urls/optimistic-location.ts +10 -4
- package/src/handles/deferred-resolution.ts +31 -0
- package/src/prerender/build-shell-capture.ts +11 -9
- package/src/router/manifest.ts +2 -0
- package/src/router/match-middleware/cache-lookup.ts +11 -18
- package/src/router/navigation-snapshot.ts +25 -6
- package/src/router/request-classification.ts +15 -12
- package/src/router/router-interfaces.ts +2 -1
- package/src/router/router-options.ts +17 -7
- package/src/router/segment-resolution/loader-cache.ts +95 -60
- package/src/router-versions.ts +60 -0
- package/src/rsc/full-payload.ts +6 -2
- package/src/rsc/handler-context.ts +5 -0
- package/src/rsc/handler.ts +14 -4
- package/src/rsc/rsc-rendering.ts +12 -1
- package/src/rsc/shell-build-manifest.ts +10 -7
- package/src/rsc/shell-capture.ts +36 -26
- package/src/rsc/types.ts +4 -6
- package/src/server/build-version-table.ts +115 -0
- package/src/server/handle-store.ts +344 -127
- package/src/server/request-context.ts +62 -11
- package/src/ssr/ssr-root.tsx +16 -4
- package/src/testing/build-versions.ts +64 -0
- package/src/testing/dispatch.ts +6 -0
- package/src/testing/dom.entry.ts +1 -0
- package/src/testing/flight.entry.ts +1 -0
- package/src/testing/index.ts +3 -0
- package/src/testing/internal/context.ts +2 -1
- package/src/testing/internal/seed-vars.ts +14 -2
- package/src/testing/location-state-key.ts +18 -8
- package/src/testing/render-handler.ts +2 -1
- package/src/testing/render-route.tsx +277 -43
- package/src/testing/serve-shell-request.ts +80 -11
- package/src/testing/vitest-stubs/version.ts +4 -1
- package/src/types/handler-context.ts +8 -5
- package/src/vite/discovery/build-versions.ts +1281 -0
- package/src/vite/discovery/installed-packages.ts +159 -0
- package/src/vite/discovery/prerender-collection.ts +38 -1
- package/src/vite/discovery/router-versions-phase.ts +336 -0
- package/src/vite/discovery/shell-prerender-phase.ts +28 -38
- package/src/vite/discovery/state.ts +19 -0
- package/src/vite/discovery/virtual-module-codegen.ts +7 -2
- package/src/vite/encryption-key.ts +101 -9
- package/src/vite/plugin-types.ts +28 -0
- package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
- package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
- package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
- package/src/vite/plugins/expose-internal-ids.ts +12 -3
- package/src/vite/plugins/stable-rsc-output.ts +147 -0
- package/src/vite/plugins/version-plugin.ts +19 -24
- package/src/vite/plugins/version.d.ts +17 -2
- package/src/vite/plugins/virtual-entries.ts +31 -5
- package/src/vite/rango.ts +28 -3
- package/src/vite/router-discovery.ts +82 -13
- package/src/vite/utils/client-chunks.ts +5 -1
- package/src/vite/utils/compare-strings.ts +9 -0
- package/src/vite/utils/package-resolution.ts +9 -0
- package/src/vite/utils/prerender-utils.ts +4 -1
- package/src/vite/utils/shared-utils.ts +1 -1
|
@@ -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
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
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():
|
|
283
|
-
*
|
|
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
|
|
325
|
-
* //
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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 {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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:
|
|
125
|
-
//
|
|
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 {
|
|
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
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// the
|
|
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
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
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(
|
|
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).
|