@rangojs/router 0.0.0-experimental.b02a2fec → 0.0.0-experimental.b30bbf02
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 +112 -17
- 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 +33 -20
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +90 -16
- package/skills/loader/SKILL.md +70 -3
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/parallel/SKILL.md +66 -0
- package/skills/rango/SKILL.md +25 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +24 -0
- package/skills/server-actions/SKILL.md +739 -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/event-controller.ts +44 -4
- 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 +70 -18
- package/src/browser/react/filter-segment-order.ts +51 -7
- 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/react/use-segments.ts +11 -8
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/types.ts +19 -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 +4 -3
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-result.ts +21 -2
- package/src/router/middleware-types.ts +2 -22
- package/src/router/middleware.ts +54 -7
- package/src/router/pattern-matching.ts +87 -17
- package/src/router/revalidation.ts +15 -1
- package/src/router/segment-resolution/fresh.ts +8 -0
- package/src/router/segment-resolution/revalidation.ts +128 -100
- package/src/router/trie-matching.ts +18 -13
- 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 +4 -0
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +10 -0
- package/src/rsc/server-action.ts +4 -0
- package/src/rsc/types.ts +6 -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/ssr/index.tsx +5 -1
- 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 +17 -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
|
|
@@ -46,10 +47,22 @@ async function processHandles(
|
|
|
46
47
|
store: NavigationStore;
|
|
47
48
|
matched?: string[];
|
|
48
49
|
isPartial?: boolean;
|
|
50
|
+
/** Server's `resolvedIds`: every segment re-resolved this request,
|
|
51
|
+
* including null-component ones excluded from `diff`/`segments`.
|
|
52
|
+
* Drives cleanup of stale handle buckets when a re-resolved segment
|
|
53
|
+
* pushed nothing. */
|
|
54
|
+
resolvedIds?: string[];
|
|
49
55
|
historyKey: string;
|
|
50
56
|
},
|
|
51
57
|
): Promise<void> {
|
|
52
|
-
const {
|
|
58
|
+
const {
|
|
59
|
+
eventController,
|
|
60
|
+
store,
|
|
61
|
+
matched,
|
|
62
|
+
isPartial,
|
|
63
|
+
resolvedIds,
|
|
64
|
+
historyKey,
|
|
65
|
+
} = opts;
|
|
53
66
|
|
|
54
67
|
let yieldCount = 0;
|
|
55
68
|
for await (const handleData of handlesGenerator) {
|
|
@@ -64,7 +77,7 @@ async function processHandles(
|
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
yieldCount++;
|
|
67
|
-
eventController.setHandleData(handleData, matched, isPartial);
|
|
80
|
+
eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
// Check again before final updates
|
|
@@ -72,12 +85,11 @@ async function processHandles(
|
|
|
72
85
|
return;
|
|
73
86
|
}
|
|
74
87
|
|
|
75
|
-
// For partial updates where the generator yielded nothing (
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// route might not push any breadcrumbs, but we still need to remove the old ones.
|
|
88
|
+
// For partial updates where the generator yielded nothing (every
|
|
89
|
+
// re-resolved handler pushed nothing), still call setHandleData so the
|
|
90
|
+
// cleanup pass can clear out stale buckets for those segments.
|
|
79
91
|
if (yieldCount === 0 && matched) {
|
|
80
|
-
eventController.setHandleData({}, matched, true);
|
|
92
|
+
eventController.setHandleData({}, matched, true, resolvedIds);
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
// After handles processing completes, update the cache's handleData.
|
|
@@ -133,15 +145,23 @@ export interface NavigationProviderProps {
|
|
|
133
145
|
warmupEnabled?: boolean;
|
|
134
146
|
|
|
135
147
|
/**
|
|
136
|
-
* App version from server payload
|
|
137
|
-
*
|
|
148
|
+
* App version from server payload.
|
|
149
|
+
* Used only as a fallback when `appShellRef` is not supplied.
|
|
138
150
|
*/
|
|
139
151
|
version?: string;
|
|
140
152
|
|
|
141
153
|
/**
|
|
142
154
|
* URL prefix for all routes (from createRouter({ basename })).
|
|
155
|
+
* Used only as a fallback when `appShellRef` is not supplied.
|
|
143
156
|
*/
|
|
144
157
|
basename?: string;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Live app-shell ref. When provided, the context's `basename` and `version`
|
|
161
|
+
* properties become live getters that track app-switch updates without
|
|
162
|
+
* invalidating the memoized context value.
|
|
163
|
+
*/
|
|
164
|
+
appShellRef?: AppShellRef;
|
|
145
165
|
}
|
|
146
166
|
|
|
147
167
|
/**
|
|
@@ -175,6 +195,7 @@ export function NavigationProvider({
|
|
|
175
195
|
warmupEnabled,
|
|
176
196
|
version,
|
|
177
197
|
basename,
|
|
198
|
+
appShellRef,
|
|
178
199
|
}: NavigationProviderProps): ReactNode {
|
|
179
200
|
// Track current payload for rendering (this triggers re-renders)
|
|
180
201
|
const [payload, setPayload] = useState(initialPayload);
|
|
@@ -196,18 +217,39 @@ export function NavigationProvider({
|
|
|
196
217
|
await bridge.refresh();
|
|
197
218
|
}, []);
|
|
198
219
|
|
|
199
|
-
// Context value is stable (store, eventController, navigate, refresh never
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
// Context value is stable (store, eventController, navigate, refresh never
|
|
221
|
+
// change). When an appShellRef is supplied, `basename` and `version` are
|
|
222
|
+
// installed as live getters so app-switch transitions (which update the ref)
|
|
223
|
+
// propagate to consumers without forcing a tree-wide rerender.
|
|
224
|
+
const contextValue = useMemo<NavigationStoreContextValue>(() => {
|
|
225
|
+
if (appShellRef) {
|
|
226
|
+
const value = {
|
|
227
|
+
store,
|
|
228
|
+
eventController,
|
|
229
|
+
navigate,
|
|
230
|
+
refresh,
|
|
231
|
+
} as NavigationStoreContextValue;
|
|
232
|
+
Object.defineProperty(value, "basename", {
|
|
233
|
+
configurable: true,
|
|
234
|
+
enumerable: true,
|
|
235
|
+
get: () => appShellRef.get().basename,
|
|
236
|
+
});
|
|
237
|
+
Object.defineProperty(value, "version", {
|
|
238
|
+
configurable: true,
|
|
239
|
+
enumerable: true,
|
|
240
|
+
get: () => appShellRef.get().version,
|
|
241
|
+
});
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
202
245
|
store,
|
|
203
246
|
eventController,
|
|
204
247
|
navigate,
|
|
205
248
|
refresh,
|
|
206
249
|
version,
|
|
207
250
|
basename,
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
);
|
|
251
|
+
};
|
|
252
|
+
}, []);
|
|
211
253
|
|
|
212
254
|
// Connection warmup: keep TLS alive after idle periods.
|
|
213
255
|
// After 60s of no user interaction, marks connection as "cold".
|
|
@@ -345,8 +387,12 @@ export function NavigationProvider({
|
|
|
345
387
|
metadata: update.metadata,
|
|
346
388
|
});
|
|
347
389
|
|
|
348
|
-
// Update route params
|
|
349
|
-
|
|
390
|
+
// Update route params. Only reset when the server actually sends a params
|
|
391
|
+
// map — an absent `params` field means "no change" (e.g., legacy action
|
|
392
|
+
// responses that omitted params). Explicit `{}` still clears correctly.
|
|
393
|
+
if (update.metadata.params !== undefined) {
|
|
394
|
+
eventController.setParams(update.metadata.params);
|
|
395
|
+
}
|
|
350
396
|
|
|
351
397
|
// Update handle data progressively as it streams in
|
|
352
398
|
if (update.metadata.handles) {
|
|
@@ -359,6 +405,7 @@ export function NavigationProvider({
|
|
|
359
405
|
store,
|
|
360
406
|
matched: update.metadata.matched,
|
|
361
407
|
isPartial: update.metadata.isPartial,
|
|
408
|
+
resolvedIds: update.metadata.resolvedIds,
|
|
362
409
|
historyKey,
|
|
363
410
|
}).catch((err) =>
|
|
364
411
|
console.error("[NavigationProvider] Error consuming handles:", err),
|
|
@@ -377,6 +424,7 @@ export function NavigationProvider({
|
|
|
377
424
|
{}, // Empty data - all existing data not in matched will be cleaned up
|
|
378
425
|
update.metadata.matched,
|
|
379
426
|
true, // partial update - will clean up segments not in matched
|
|
427
|
+
update.metadata.resolvedIds,
|
|
380
428
|
);
|
|
381
429
|
}
|
|
382
430
|
});
|
|
@@ -398,7 +446,11 @@ export function NavigationProvider({
|
|
|
398
446
|
// Build the content tree
|
|
399
447
|
let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
|
|
400
448
|
|
|
401
|
-
// Wrap with ThemeProvider when theme is enabled
|
|
449
|
+
// Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
|
|
450
|
+
// document-lifetime: its config comes from the initial load and does NOT
|
|
451
|
+
// swap on cross-app transitions, because the ThemeProvider sits above the
|
|
452
|
+
// segment tree and a smooth (no-reload) app switch cannot safely remount
|
|
453
|
+
// it. A new theme config only takes effect on a full document load.
|
|
402
454
|
if (themeConfig) {
|
|
403
455
|
content = (
|
|
404
456
|
<ThemeProvider config={themeConfig} initialTheme={initialTheme}>
|