@rangojs/router 0.0.0-experimental.b02a2fec → 0.0.0-experimental.bf1b128c
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/README.md +50 -20
- package/dist/vite/index.js +1338 -462
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +7 -5
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/hooks/SKILL.md +28 -20
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +88 -16
- package/skills/loader/SKILL.md +66 -2
- package/skills/middleware/SKILL.md +32 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +765 -0
- package/skills/parallel/SKILL.md +66 -0
- package/skills/rango/SKILL.md +24 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +24 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +3 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/navigation-bridge.ts +71 -5
- package/src/browser/navigation-client.ts +64 -13
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +34 -3
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +148 -16
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +30 -2
- package/src/browser/react/NavigationProvider.tsx +50 -11
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +11 -1
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/types.ts +13 -0
- package/src/build/route-trie.ts +50 -24
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.tsx +82 -174
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +40 -9
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +7 -3
- package/src/route-definition/dsl-helpers.ts +175 -23
- package/src/route-definition/helpers-types.ts +63 -14
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-types.ts +7 -0
- package/src/router/handler-context.ts +24 -4
- package/src/router/lazy-includes.ts +6 -6
- package/src/router/loader-resolution.ts +3 -0
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +3 -3
- package/src/router/middleware-types.ts +2 -22
- package/src/router/middleware.ts +54 -7
- package/src/router/pattern-matching.ts +60 -9
- package/src/router/revalidation.ts +15 -1
- package/src/router/segment-resolution/revalidation.ts +63 -58
- package/src/router/trie-matching.ts +10 -4
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +8 -4
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +7 -0
- package/src/rsc/server-action.ts +2 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +11 -61
- package/src/server/context.ts +26 -3
- package/src/server/request-context.ts +10 -42
- package/src/types/handler-context.ts +12 -39
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +0 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +30 -4
- package/src/urls/response-types.ts +2 -10
- package/src/vite/debug.ts +184 -0
- package/src/vite/discovery/discover-routers.ts +31 -3
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +48 -1
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/plugins/cjs-to-esm.ts +5 -0
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +16 -4
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-action-id.ts +52 -28
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +516 -486
- package/src/vite/plugins/performance-tracks.ts +17 -9
- package/src/vite/plugins/use-cache-transform.ts +56 -43
- package/src/vite/plugins/version-injector.ts +37 -11
- package/src/vite/rango.ts +49 -14
- package/src/vite/router-discovery.ts +558 -53
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +20 -6
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
buildPrefetchKey,
|
|
16
|
+
buildSourceKey,
|
|
16
17
|
hasPrefetch,
|
|
17
18
|
markPrefetchInflight,
|
|
18
|
-
|
|
19
|
+
setInflightPromiseWithAliases,
|
|
19
20
|
storePrefetch,
|
|
20
21
|
clearPrefetchInflight,
|
|
21
22
|
currentGeneration,
|
|
@@ -23,6 +24,24 @@ import {
|
|
|
23
24
|
import { getRangoState } from "../rango-state.js";
|
|
24
25
|
import { enqueuePrefetch } from "./queue.js";
|
|
25
26
|
import { shouldPrefetch } from "./policy.js";
|
|
27
|
+
import { debugLog } from "../logging.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a URL resolves to the current page (same pathname + search).
|
|
31
|
+
* Used to prevent same-page prefetching, which produces a trivial diff
|
|
32
|
+
* that would corrupt the (default wildcard) prefetch cache entry.
|
|
33
|
+
*/
|
|
34
|
+
function isSamePage(url: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
const target = new URL(url, window.location.origin);
|
|
37
|
+
return (
|
|
38
|
+
target.pathname + target.search ===
|
|
39
|
+
window.location.pathname + window.location.search
|
|
40
|
+
);
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
26
45
|
|
|
27
46
|
/**
|
|
28
47
|
* Build an RSC partial URL for prefetching.
|
|
@@ -63,14 +82,33 @@ function buildPrefetchUrl(
|
|
|
63
82
|
* one branch in the in-memory cache. The returned Promise resolves to the
|
|
64
83
|
* sibling navigation branch (or null on failure) so navigation can safely
|
|
65
84
|
* reuse an in-flight prefetch via consumeInflightPrefetch().
|
|
85
|
+
*
|
|
86
|
+
* Inflight + storage key selection:
|
|
87
|
+
*
|
|
88
|
+
* - `forceSourceScope` (Link opted in with `prefetchKey=":source"`): single
|
|
89
|
+
* inflight registration under `sourceKey`; response stored under
|
|
90
|
+
* `sourceKey`. No wildcard leak is possible.
|
|
91
|
+
*
|
|
92
|
+
* - Otherwise: dual inflight registration under both `wildcardKey` and
|
|
93
|
+
* `sourceKey` so same-source navigations adopt directly via their own
|
|
94
|
+
* source key. Storage key is chosen at response time from the
|
|
95
|
+
* `X-RSC-Prefetch-Scope` header — `"source"` → `sourceKey` (intercept
|
|
96
|
+
* modals etc.), anything else → `wildcardKey`. Cross-source navigations
|
|
97
|
+
* that adopted via `wildcardKey` must bail out in `navigation-client.ts`
|
|
98
|
+
* if the adopted response turns out to be source-scoped.
|
|
66
99
|
*/
|
|
67
100
|
function executePrefetchFetch(
|
|
68
|
-
|
|
101
|
+
wildcardKey: string,
|
|
102
|
+
sourceKey: string,
|
|
69
103
|
fetchUrl: string,
|
|
104
|
+
forceSourceScope: boolean,
|
|
70
105
|
signal?: AbortSignal,
|
|
71
106
|
): Promise<Response | null> {
|
|
72
107
|
const gen = currentGeneration();
|
|
73
|
-
|
|
108
|
+
const inflightKeys = forceSourceScope
|
|
109
|
+
? [sourceKey]
|
|
110
|
+
: [wildcardKey, sourceKey];
|
|
111
|
+
for (const k of inflightKeys) markPrefetchInflight(k);
|
|
74
112
|
|
|
75
113
|
const promise: Promise<Response | null> = fetch(fetchUrl, {
|
|
76
114
|
priority: "low" as RequestPriority,
|
|
@@ -92,59 +130,153 @@ function executePrefetchFetch(
|
|
|
92
130
|
status: response.status,
|
|
93
131
|
statusText: response.statusText,
|
|
94
132
|
};
|
|
95
|
-
|
|
133
|
+
let storageKey: string;
|
|
134
|
+
if (forceSourceScope) {
|
|
135
|
+
storageKey = sourceKey;
|
|
136
|
+
} else {
|
|
137
|
+
const scope = response.headers.get("x-rsc-prefetch-scope");
|
|
138
|
+
storageKey = scope === "source" ? sourceKey : wildcardKey;
|
|
139
|
+
}
|
|
140
|
+
storePrefetch(storageKey, new Response(cacheStream, responseInit), gen);
|
|
96
141
|
return new Response(navStream, responseInit);
|
|
97
142
|
})
|
|
98
143
|
.catch(() => null)
|
|
99
144
|
.finally(() => {
|
|
100
|
-
clearPrefetchInflight(
|
|
145
|
+
clearPrefetchInflight(inflightKeys[0]!);
|
|
101
146
|
});
|
|
102
147
|
|
|
103
|
-
|
|
148
|
+
setInflightPromiseWithAliases(inflightKeys, promise);
|
|
104
149
|
return promise;
|
|
105
150
|
}
|
|
106
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Dedup check for prefetch entry presence.
|
|
154
|
+
*
|
|
155
|
+
* Forced `:source` must NOT dedupe against a pre-existing wildcard entry —
|
|
156
|
+
* otherwise the source slot would stay unpopulated and navigation from
|
|
157
|
+
* this source would fall through to the (potentially wrong) wildcard
|
|
158
|
+
* response, defeating the opt-out.
|
|
159
|
+
*/
|
|
160
|
+
function hasPrefetchHit(
|
|
161
|
+
forceSourceScope: boolean,
|
|
162
|
+
wildcardKey: string,
|
|
163
|
+
sourceKey: string,
|
|
164
|
+
): boolean {
|
|
165
|
+
return forceSourceScope
|
|
166
|
+
? hasPrefetch(sourceKey)
|
|
167
|
+
: hasPrefetch(wildcardKey) || hasPrefetch(sourceKey);
|
|
168
|
+
}
|
|
169
|
+
|
|
107
170
|
/**
|
|
108
171
|
* Prefetch (direct): fetch with low priority and store in in-memory cache.
|
|
109
172
|
* Used by hover strategy -- fires immediately without queueing.
|
|
173
|
+
*
|
|
174
|
+
* By default the wildcard key (Rango-state-keyed) is used for inflight
|
|
175
|
+
* dedup and for responses that are not source-sensitive; source-scoped
|
|
176
|
+
* storage is automatic when the server emits `X-RSC-Prefetch-Scope: source`.
|
|
177
|
+
*
|
|
178
|
+
* Pass `prefetchKey=":source"` to force source-scoped inflight + storage
|
|
179
|
+
* (e.g. when the target uses a custom `revalidate()` that reads
|
|
180
|
+
* `currentUrl` and the wildcard slot would serve the wrong diff).
|
|
110
181
|
*/
|
|
111
182
|
export function prefetchDirect(
|
|
112
183
|
url: string,
|
|
113
184
|
segmentIds: string[],
|
|
114
185
|
version?: string,
|
|
115
186
|
routerId?: string,
|
|
187
|
+
prefetchKey?: ":source",
|
|
116
188
|
): void {
|
|
117
189
|
if (!shouldPrefetch()) return;
|
|
118
190
|
|
|
119
191
|
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
120
192
|
if (!targetUrl) return;
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
193
|
+
const forceSourceScope = prefetchKey === ":source";
|
|
194
|
+
// Skip same-page prefetch — a same-page diff is trivial and would corrupt
|
|
195
|
+
// the wildcard cache entry used for cross-page navigation.
|
|
196
|
+
// When `:source` is forced the entry is source-scoped (single-aliased to
|
|
197
|
+
// itself), so it cannot poison any shared slot — allow it.
|
|
198
|
+
if (!forceSourceScope && isSamePage(url)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const sourceHref = window.location.href;
|
|
202
|
+
const rangoState = getRangoState();
|
|
203
|
+
const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
|
|
204
|
+
const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
|
|
205
|
+
if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
|
|
206
|
+
debugLog("[prefetch] direct dedup (key already exists)", {
|
|
207
|
+
url,
|
|
208
|
+
wildcardKey,
|
|
209
|
+
sourceKey,
|
|
210
|
+
forceSourceScope,
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
debugLog("[prefetch] direct fetch", {
|
|
215
|
+
url,
|
|
216
|
+
wildcardKey,
|
|
217
|
+
sourceKey,
|
|
218
|
+
source: sourceHref,
|
|
219
|
+
forceSourceScope,
|
|
220
|
+
});
|
|
221
|
+
executePrefetchFetch(
|
|
222
|
+
wildcardKey,
|
|
223
|
+
sourceKey,
|
|
224
|
+
targetUrl.toString(),
|
|
225
|
+
forceSourceScope,
|
|
226
|
+
);
|
|
124
227
|
}
|
|
125
228
|
|
|
126
229
|
/**
|
|
127
230
|
* Prefetch (queued): goes through the concurrency-limited queue.
|
|
128
231
|
* Used by viewport/render strategies to avoid flooding the server.
|
|
129
|
-
* Returns the
|
|
232
|
+
* Returns the inflight key (wildcard by default, source-scoped when
|
|
233
|
+
* `prefetchKey=":source"` is passed).
|
|
130
234
|
*/
|
|
131
235
|
export function prefetchQueued(
|
|
132
236
|
url: string,
|
|
133
237
|
segmentIds: string[],
|
|
134
238
|
version?: string,
|
|
135
239
|
routerId?: string,
|
|
240
|
+
prefetchKey?: ":source",
|
|
136
241
|
): string {
|
|
137
242
|
if (!shouldPrefetch()) return "";
|
|
138
243
|
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
139
244
|
if (!targetUrl) return "";
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
245
|
+
const forceSourceScope = prefetchKey === ":source";
|
|
246
|
+
if (!forceSourceScope && isSamePage(url)) {
|
|
247
|
+
return "";
|
|
248
|
+
}
|
|
249
|
+
const sourceHref = window.location.href;
|
|
250
|
+
const rangoState = getRangoState();
|
|
251
|
+
const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
|
|
252
|
+
const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
|
|
253
|
+
const queueKey = forceSourceScope ? sourceKey : wildcardKey;
|
|
254
|
+
if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
|
|
255
|
+
debugLog("[prefetch] queued dedup (key already exists)", {
|
|
256
|
+
url,
|
|
257
|
+
wildcardKey,
|
|
258
|
+
sourceKey,
|
|
259
|
+
forceSourceScope,
|
|
260
|
+
});
|
|
261
|
+
return queueKey;
|
|
262
|
+
}
|
|
142
263
|
const fetchUrlStr = targetUrl.toString();
|
|
143
|
-
enqueuePrefetch(
|
|
264
|
+
enqueuePrefetch(queueKey, (signal) => {
|
|
144
265
|
// Re-check at execution time: a hover-triggered prefetchDirect may
|
|
145
266
|
// have started or completed this key while the item sat in the queue.
|
|
146
|
-
if (
|
|
147
|
-
|
|
267
|
+
if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
|
|
268
|
+
return Promise.resolve();
|
|
269
|
+
}
|
|
270
|
+
if (!forceSourceScope && isSamePage(url)) {
|
|
271
|
+
return Promise.resolve();
|
|
272
|
+
}
|
|
273
|
+
return executePrefetchFetch(
|
|
274
|
+
wildcardKey,
|
|
275
|
+
sourceKey,
|
|
276
|
+
fetchUrlStr,
|
|
277
|
+
forceSourceScope,
|
|
278
|
+
signal,
|
|
279
|
+
).then(() => {});
|
|
148
280
|
});
|
|
149
|
-
return
|
|
281
|
+
return queueKey;
|
|
150
282
|
}
|
|
@@ -108,10 +108,29 @@ export function enqueuePrefetch(
|
|
|
108
108
|
scheduleDrain();
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Normalize a URL-like string for keep-alive matching: parse against a
|
|
113
|
+
* placeholder origin and strip internal `_rsc_*` query params. Returns
|
|
114
|
+
* `pathname + search` so comparisons ignore hash and the internal params
|
|
115
|
+
* that prefetch appends to targets (`_rsc_partial`, `_rsc_segments`,
|
|
116
|
+
* `_rsc_v`, `_rsc_rid`, `_rsc_stale`).
|
|
117
|
+
*/
|
|
118
|
+
function normalizeForMatch(urlish: string): string {
|
|
119
|
+
try {
|
|
120
|
+
const u = new URL(urlish, "http://placeholder");
|
|
121
|
+
for (const k of [...u.searchParams.keys()]) {
|
|
122
|
+
if (k.startsWith("_rsc_")) u.searchParams.delete(k);
|
|
123
|
+
}
|
|
124
|
+
return u.pathname + u.search;
|
|
125
|
+
} catch {
|
|
126
|
+
return urlish;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
111
130
|
/**
|
|
112
131
|
* Cancel queued prefetches and abort in-flight ones that don't match
|
|
113
132
|
* the current navigation target. If `keepUrl` is provided, the
|
|
114
|
-
* executing prefetch whose key
|
|
133
|
+
* executing prefetch whose key targets that URL is kept alive so
|
|
115
134
|
* navigation can reuse its response via consumeInflightPrefetch.
|
|
116
135
|
*
|
|
117
136
|
* Called when a navigation starts via the NavigationProvider's
|
|
@@ -124,11 +143,23 @@ export function cancelAllPrefetches(keepUrl?: string | null): void {
|
|
|
124
143
|
drainGeneration++;
|
|
125
144
|
|
|
126
145
|
// Abort in-flight prefetches that aren't for the navigation target.
|
|
127
|
-
//
|
|
128
|
-
//
|
|
146
|
+
// Key shapes (see prefetch/cache.ts buildPrefetchKey):
|
|
147
|
+
// wildcard: "rangoState\0/target?..."
|
|
148
|
+
// source-scoped: "rangoState\0sourceHref\0/target?..."
|
|
149
|
+
// The target portion is always the final \0-delimited segment and
|
|
150
|
+
// includes internal `_rsc_*` params (from buildPrefetchUrl); keepUrl
|
|
151
|
+
// comes from NavigationProvider's pendingUrl which is the bare
|
|
152
|
+
// navigation target. Normalize both sides before comparing.
|
|
153
|
+
const normalizedKeep = keepUrl ? normalizeForMatch(keepUrl) : null;
|
|
129
154
|
for (const [key, ac] of abortControllers) {
|
|
130
|
-
const
|
|
131
|
-
|
|
155
|
+
const lastNul = key.lastIndexOf("\0");
|
|
156
|
+
const target = lastNul >= 0 ? key.substring(lastNul + 1) : "";
|
|
157
|
+
if (
|
|
158
|
+
normalizedKeep &&
|
|
159
|
+
target &&
|
|
160
|
+
normalizeForMatch(target) === normalizedKeep
|
|
161
|
+
)
|
|
162
|
+
continue;
|
|
132
163
|
ac.abort();
|
|
133
164
|
abortControllers.delete(key);
|
|
134
165
|
if (executing.delete(key)) {
|
|
@@ -6,21 +6,37 @@
|
|
|
6
6
|
* navigation requests. The server responds with `Vary: X-Rango-State`,
|
|
7
7
|
* so the browser HTTP cache keys responses by (URL, X-Rango-State value).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Value format: `{buildVersion}:{invalidationTimestamp}`
|
|
10
10
|
* - Build version changes on deploy, busting all cached prefetches.
|
|
11
11
|
* - Timestamp changes on server action invalidation.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Storage key is namespaced per routerId (`rango-state:{routerId}`) so
|
|
14
|
+
* tabs in different apps on the same origin do not collide. Two tabs in
|
|
15
|
+
* the same app share a key → one tab's invalidation is picked up by the
|
|
16
|
+
* other via the `storage` event. A smooth cross-app transition in this
|
|
17
|
+
* tab rebinds to the target app's key; other tabs still in the old app
|
|
18
|
+
* keep their own key intact.
|
|
19
|
+
*
|
|
20
|
+
* If no routerId is supplied, falls back to a single legacy key for
|
|
21
|
+
* backward compatibility (single-app deployments unaffected).
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
|
-
const
|
|
24
|
+
const LEGACY_STORAGE_KEY = "rango-state";
|
|
25
|
+
|
|
26
|
+
function buildStorageKey(routerId: string | undefined): string {
|
|
27
|
+
return routerId ? `${LEGACY_STORAGE_KEY}:${routerId}` : LEGACY_STORAGE_KEY;
|
|
28
|
+
}
|
|
19
29
|
|
|
20
30
|
// Module-level cache avoids hitting localStorage on every getRangoState() call.
|
|
21
31
|
// Initialized from localStorage on first access or by initRangoState().
|
|
22
32
|
let cachedState: string | null = null;
|
|
23
33
|
|
|
34
|
+
// The localStorage key this tab is currently bound to. Rebinds on
|
|
35
|
+
// initRangoState (document boot) and setRangoStateLocal (smooth app
|
|
36
|
+
// switch). The storage listener filters cross-tab events by this key so
|
|
37
|
+
// events from tabs in a different app are ignored.
|
|
38
|
+
let currentStorageKey: string = LEGACY_STORAGE_KEY;
|
|
39
|
+
|
|
24
40
|
// Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
|
|
25
41
|
// to localStorage, keeping cachedState fresh without polling.
|
|
26
42
|
let storageListenerAttached = false;
|
|
@@ -28,7 +44,10 @@ let storageListenerAttached = false;
|
|
|
28
44
|
function attachStorageListener(): void {
|
|
29
45
|
if (storageListenerAttached || typeof window === "undefined") return;
|
|
30
46
|
window.addEventListener("storage", (e) => {
|
|
31
|
-
|
|
47
|
+
// Only react to events for this tab's current app namespace. Events
|
|
48
|
+
// under other routerId-scoped keys belong to other apps and must not
|
|
49
|
+
// clobber this tab's state.
|
|
50
|
+
if (e.key !== currentStorageKey) return;
|
|
32
51
|
cachedState = e.newValue;
|
|
33
52
|
});
|
|
34
53
|
storageListenerAttached = true;
|
|
@@ -37,16 +56,22 @@ function attachStorageListener(): void {
|
|
|
37
56
|
/**
|
|
38
57
|
* Initialize the Rango state key in localStorage.
|
|
39
58
|
* Called once at app startup with the build version from the server.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
59
|
+
* The routerId scopes the storage key to this app; in multi-app setups
|
|
60
|
+
* each app owns its own `rango-state:{routerId}` key and cannot observe
|
|
61
|
+
* invalidations from sibling apps on the same origin.
|
|
62
|
+
*
|
|
63
|
+
* If localStorage already has a matching-version entry under the key,
|
|
64
|
+
* keeps it (preserves invalidation state across refresh). Otherwise
|
|
65
|
+
* writes a new value.
|
|
42
66
|
*/
|
|
43
|
-
export function initRangoState(version: string): void {
|
|
67
|
+
export function initRangoState(version: string, routerId?: string): void {
|
|
68
|
+
currentStorageKey = buildStorageKey(routerId);
|
|
44
69
|
if (typeof window === "undefined") return;
|
|
45
70
|
|
|
46
71
|
attachStorageListener();
|
|
47
72
|
|
|
48
73
|
try {
|
|
49
|
-
const existing = localStorage.getItem(
|
|
74
|
+
const existing = localStorage.getItem(currentStorageKey);
|
|
50
75
|
if (existing) {
|
|
51
76
|
const colonIdx = existing.indexOf(":");
|
|
52
77
|
if (colonIdx > 0) {
|
|
@@ -59,7 +84,7 @@ export function initRangoState(version: string): void {
|
|
|
59
84
|
}
|
|
60
85
|
// New version or first load
|
|
61
86
|
const newState = `${version}:${Date.now()}`;
|
|
62
|
-
localStorage.setItem(
|
|
87
|
+
localStorage.setItem(currentStorageKey, newState);
|
|
63
88
|
cachedState = newState;
|
|
64
89
|
} catch {
|
|
65
90
|
// localStorage may be unavailable (private browsing in some browsers)
|
|
@@ -77,7 +102,7 @@ export function getRangoState(): string {
|
|
|
77
102
|
if (typeof window === "undefined") return "0:0";
|
|
78
103
|
|
|
79
104
|
try {
|
|
80
|
-
const stored = localStorage.getItem(
|
|
105
|
+
const stored = localStorage.getItem(currentStorageKey);
|
|
81
106
|
if (stored) {
|
|
82
107
|
cachedState = stored;
|
|
83
108
|
return stored;
|
|
@@ -89,6 +114,21 @@ export function getRangoState(): string {
|
|
|
89
114
|
return "0:0";
|
|
90
115
|
}
|
|
91
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Update the in-memory rango-state to a new version WITHOUT writing
|
|
119
|
+
* localStorage. Intended for smooth cross-app transitions in this tab only:
|
|
120
|
+
* subsequent requests from this tab send the new token, but other tabs
|
|
121
|
+
* still in the previous app do not observe a storage event. Rebinds this
|
|
122
|
+
* tab's storage key to the target app's namespace (`rango-state:{routerId}`)
|
|
123
|
+
* so subsequent storage events only reflect the new app. On the next hard
|
|
124
|
+
* reload, initRangoState reconciles localStorage from the server's
|
|
125
|
+
* authoritative version.
|
|
126
|
+
*/
|
|
127
|
+
export function setRangoStateLocal(version: string, routerId?: string): void {
|
|
128
|
+
currentStorageKey = buildStorageKey(routerId);
|
|
129
|
+
cachedState = `${version}:${Date.now()}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
92
132
|
/**
|
|
93
133
|
* Invalidate the Rango state key. Called when server actions mutate data.
|
|
94
134
|
* Updates the timestamp portion while keeping the version prefix.
|
|
@@ -105,7 +145,7 @@ export function invalidateRangoState(): void {
|
|
|
105
145
|
if (typeof window === "undefined") return;
|
|
106
146
|
|
|
107
147
|
try {
|
|
108
|
-
localStorage.setItem(
|
|
148
|
+
localStorage.setItem(currentStorageKey, newState);
|
|
109
149
|
} catch {
|
|
110
150
|
// Silently handle localStorage errors
|
|
111
151
|
}
|
|
@@ -97,6 +97,31 @@ export interface LinkProps extends Omit<
|
|
|
97
97
|
* @default "none"
|
|
98
98
|
*/
|
|
99
99
|
prefetch?: PrefetchStrategy;
|
|
100
|
+
/**
|
|
101
|
+
* Opt-in override for the prefetch cache scope.
|
|
102
|
+
*
|
|
103
|
+
* The default cache is source-agnostic: one shared entry per target,
|
|
104
|
+
* keyed on Rango state + target URL. This is correct for routes whose
|
|
105
|
+
* response shape doesn't depend on where the user navigates from.
|
|
106
|
+
*
|
|
107
|
+
* Set `":source"` when this Link's response would legitimately differ
|
|
108
|
+
* based on the source page — typically when the target route (or one
|
|
109
|
+
* of its layouts) uses a custom `revalidate()` handler that reads
|
|
110
|
+
* `currentUrl` / `currentParams`, and the wildcard entry would
|
|
111
|
+
* therefore serve the wrong diff to a navigation from a different
|
|
112
|
+
* source.
|
|
113
|
+
*
|
|
114
|
+
* Intercept responses are auto-scoped to the source via a server-side
|
|
115
|
+
* tag, so `":source"` is only needed for custom revalidation logic.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```tsx
|
|
119
|
+
* // Route uses a `revalidate()` that branches on currentUrl — opt in
|
|
120
|
+
* // so prefetches don't bleed across source pages.
|
|
121
|
+
* <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
prefetchKey?: ":source";
|
|
100
125
|
/**
|
|
101
126
|
* State to pass to history.pushState/replaceState.
|
|
102
127
|
* Accessible via useLocationState() hook.
|
|
@@ -184,6 +209,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
184
209
|
reloadDocument = false,
|
|
185
210
|
revalidate,
|
|
186
211
|
prefetch = "none",
|
|
212
|
+
prefetchKey,
|
|
187
213
|
state,
|
|
188
214
|
children,
|
|
189
215
|
onClick,
|
|
@@ -320,9 +346,10 @@ export const Link: ForwardRefExoticComponent<
|
|
|
320
346
|
segmentState.currentSegmentIds,
|
|
321
347
|
getAppVersion(),
|
|
322
348
|
ctx.store.getRouterId?.(),
|
|
349
|
+
prefetchKey,
|
|
323
350
|
);
|
|
324
351
|
}
|
|
325
|
-
}, [resolvedStrategy, resolvedTo, isExternal, ctx]);
|
|
352
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
326
353
|
|
|
327
354
|
// Viewport/render prefetch: waits for idle before starting,
|
|
328
355
|
// uses concurrency-limited queue to avoid flooding.
|
|
@@ -344,6 +371,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
344
371
|
segmentState.currentSegmentIds,
|
|
345
372
|
getAppVersion(),
|
|
346
373
|
ctx.store.getRouterId?.(),
|
|
374
|
+
prefetchKey,
|
|
347
375
|
);
|
|
348
376
|
};
|
|
349
377
|
|
|
@@ -383,7 +411,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
383
411
|
unobserveForPrefetch(observedElement);
|
|
384
412
|
}
|
|
385
413
|
};
|
|
386
|
-
}, [resolvedStrategy, resolvedTo, isExternal, ctx]);
|
|
414
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
387
415
|
|
|
388
416
|
return (
|
|
389
417
|
<a
|
|
@@ -28,6 +28,7 @@ import { NonceContext } from "./nonce-context.js";
|
|
|
28
28
|
import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
|
|
29
29
|
import { cancelAllPrefetches } from "../prefetch/queue.js";
|
|
30
30
|
import { handleNavigationEnd } from "../scroll-restoration.js";
|
|
31
|
+
import type { AppShellRef } from "../app-shell.js";
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Process handles from an async generator, updating the event controller
|
|
@@ -133,15 +134,23 @@ export interface NavigationProviderProps {
|
|
|
133
134
|
warmupEnabled?: boolean;
|
|
134
135
|
|
|
135
136
|
/**
|
|
136
|
-
* App version from server payload
|
|
137
|
-
*
|
|
137
|
+
* App version from server payload.
|
|
138
|
+
* Used only as a fallback when `appShellRef` is not supplied.
|
|
138
139
|
*/
|
|
139
140
|
version?: string;
|
|
140
141
|
|
|
141
142
|
/**
|
|
142
143
|
* URL prefix for all routes (from createRouter({ basename })).
|
|
144
|
+
* Used only as a fallback when `appShellRef` is not supplied.
|
|
143
145
|
*/
|
|
144
146
|
basename?: string;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Live app-shell ref. When provided, the context's `basename` and `version`
|
|
150
|
+
* properties become live getters that track app-switch updates without
|
|
151
|
+
* invalidating the memoized context value.
|
|
152
|
+
*/
|
|
153
|
+
appShellRef?: AppShellRef;
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
/**
|
|
@@ -175,6 +184,7 @@ export function NavigationProvider({
|
|
|
175
184
|
warmupEnabled,
|
|
176
185
|
version,
|
|
177
186
|
basename,
|
|
187
|
+
appShellRef,
|
|
178
188
|
}: NavigationProviderProps): ReactNode {
|
|
179
189
|
// Track current payload for rendering (this triggers re-renders)
|
|
180
190
|
const [payload, setPayload] = useState(initialPayload);
|
|
@@ -196,18 +206,39 @@ export function NavigationProvider({
|
|
|
196
206
|
await bridge.refresh();
|
|
197
207
|
}, []);
|
|
198
208
|
|
|
199
|
-
// Context value is stable (store, eventController, navigate, refresh never
|
|
200
|
-
|
|
201
|
-
|
|
209
|
+
// Context value is stable (store, eventController, navigate, refresh never
|
|
210
|
+
// change). When an appShellRef is supplied, `basename` and `version` are
|
|
211
|
+
// installed as live getters so app-switch transitions (which update the ref)
|
|
212
|
+
// propagate to consumers without forcing a tree-wide rerender.
|
|
213
|
+
const contextValue = useMemo<NavigationStoreContextValue>(() => {
|
|
214
|
+
if (appShellRef) {
|
|
215
|
+
const value = {
|
|
216
|
+
store,
|
|
217
|
+
eventController,
|
|
218
|
+
navigate,
|
|
219
|
+
refresh,
|
|
220
|
+
} as NavigationStoreContextValue;
|
|
221
|
+
Object.defineProperty(value, "basename", {
|
|
222
|
+
configurable: true,
|
|
223
|
+
enumerable: true,
|
|
224
|
+
get: () => appShellRef.get().basename,
|
|
225
|
+
});
|
|
226
|
+
Object.defineProperty(value, "version", {
|
|
227
|
+
configurable: true,
|
|
228
|
+
enumerable: true,
|
|
229
|
+
get: () => appShellRef.get().version,
|
|
230
|
+
});
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
202
234
|
store,
|
|
203
235
|
eventController,
|
|
204
236
|
navigate,
|
|
205
237
|
refresh,
|
|
206
238
|
version,
|
|
207
239
|
basename,
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
);
|
|
240
|
+
};
|
|
241
|
+
}, []);
|
|
211
242
|
|
|
212
243
|
// Connection warmup: keep TLS alive after idle periods.
|
|
213
244
|
// After 60s of no user interaction, marks connection as "cold".
|
|
@@ -345,8 +376,12 @@ export function NavigationProvider({
|
|
|
345
376
|
metadata: update.metadata,
|
|
346
377
|
});
|
|
347
378
|
|
|
348
|
-
// Update route params
|
|
349
|
-
|
|
379
|
+
// Update route params. Only reset when the server actually sends a params
|
|
380
|
+
// map — an absent `params` field means "no change" (e.g., legacy action
|
|
381
|
+
// responses that omitted params). Explicit `{}` still clears correctly.
|
|
382
|
+
if (update.metadata.params !== undefined) {
|
|
383
|
+
eventController.setParams(update.metadata.params);
|
|
384
|
+
}
|
|
350
385
|
|
|
351
386
|
// Update handle data progressively as it streams in
|
|
352
387
|
if (update.metadata.handles) {
|
|
@@ -398,7 +433,11 @@ export function NavigationProvider({
|
|
|
398
433
|
// Build the content tree
|
|
399
434
|
let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
|
|
400
435
|
|
|
401
|
-
// Wrap with ThemeProvider when theme is enabled
|
|
436
|
+
// Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
|
|
437
|
+
// document-lifetime: its config comes from the initial load and does NOT
|
|
438
|
+
// swap on cross-app transitions, because the ThemeProvider sits above the
|
|
439
|
+
// segment tree and a smooth (no-reload) app switch cannot safely remount
|
|
440
|
+
// it. A new theme config only takes effect on a full document load.
|
|
402
441
|
if (themeConfig) {
|
|
403
442
|
content = (
|
|
404
443
|
<ThemeProvider config={themeConfig} initialTheme={initialTheme}>
|
|
@@ -53,6 +53,12 @@ export function useNavigation<T>(
|
|
|
53
53
|
});
|
|
54
54
|
const prevState = useRef(baseValue);
|
|
55
55
|
|
|
56
|
+
// Tracks whether the most recent setOptimisticValue call pinned the value
|
|
57
|
+
// to a non-idle state. Used to decide whether to emit a release update when
|
|
58
|
+
// returning to idle, so the optimistic store doesn't stay pinned if a
|
|
59
|
+
// parent transition (e.g. <Link> click) is still pending.
|
|
60
|
+
const optimisticPinnedRef = useRef(false);
|
|
61
|
+
|
|
56
62
|
// useOptimistic allows immediate updates during transitions/actions
|
|
57
63
|
const [value, setOptimisticValue] = useOptimistic(baseValue);
|
|
58
64
|
|
|
@@ -82,11 +88,25 @@ export function useNavigation<T>(
|
|
|
82
88
|
const hasInflightActions =
|
|
83
89
|
ctx.eventController.getInflightActions().size > 0;
|
|
84
90
|
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
const shouldPin = hasInflightActions || publicState.state !== "idle";
|
|
92
|
+
|
|
93
|
+
if (shouldPin) {
|
|
94
|
+
// Pin the optimistic store so the loading value shows immediately
|
|
95
|
+
// even if a parent transition (e.g. <Link> click) defers the
|
|
96
|
+
// urgent setBaseValue commit.
|
|
97
|
+
startTransition(() => {
|
|
98
|
+
setOptimisticValue(nextSelected);
|
|
99
|
+
});
|
|
100
|
+
optimisticPinnedRef.current = true;
|
|
101
|
+
} else if (optimisticPinnedRef.current) {
|
|
102
|
+
// Release a previously-pinned optimistic value. Without this,
|
|
103
|
+
// useOptimistic keeps returning the stale loading value while
|
|
104
|
+
// any parent transition is still pending, even after baseValue
|
|
105
|
+
// flipped to idle.
|
|
87
106
|
startTransition(() => {
|
|
88
107
|
setOptimisticValue(nextSelected);
|
|
89
108
|
});
|
|
109
|
+
optimisticPinnedRef.current = false;
|
|
90
110
|
}
|
|
91
111
|
|
|
92
112
|
// Always update base state so UI reflects current state
|
|
@@ -16,11 +16,21 @@ import { shallowEqual } from "./shallow-equal.js";
|
|
|
16
16
|
* const params = useParams();
|
|
17
17
|
* // { productId: "123" }
|
|
18
18
|
*
|
|
19
|
+
* // Annotate the expected shape via a generic
|
|
20
|
+
* const { productId } = useParams<{ productId: string }>();
|
|
21
|
+
*
|
|
19
22
|
* // With selector
|
|
20
23
|
* const productId = useParams(p => p.productId);
|
|
21
24
|
* ```
|
|
22
25
|
*/
|
|
23
|
-
|
|
26
|
+
// `T extends object` (not `Record<string, string | undefined>`) so that
|
|
27
|
+
// interface shapes pass the constraint — interfaces lack an implicit
|
|
28
|
+
// index signature and would otherwise be rejected. The generic is a
|
|
29
|
+
// shape annotation, not a runtime check; the body always returns the
|
|
30
|
+
// underlying params map unchanged.
|
|
31
|
+
export function useParams<
|
|
32
|
+
T extends object = Record<string, string>,
|
|
33
|
+
>(): Readonly<T>;
|
|
24
34
|
export function useParams<T>(
|
|
25
35
|
selector: (params: Record<string, string>) => T,
|
|
26
36
|
): T;
|