@rangojs/router 0.0.0-experimental.8678bb02 → 0.0.0-experimental.87
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 +126 -38
- package/dist/bin/rango.js +130 -47
- package/dist/vite/index.js +847 -384
- package/dist/vite/index.js.bak +5448 -0
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +5 -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 +91 -17
- package/skills/loader/SKILL.md +35 -2
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +765 -0
- package/skills/parallel/SKILL.md +59 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +24 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +24 -0
- package/skills/router-setup/SKILL.md +35 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +3 -1
- package/src/__internal.ts +1 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/navigation-bridge.ts +87 -6
- package/src/browser/navigation-client.ts +128 -77
- package/src/browser/navigation-store.ts +68 -9
- package/src/browser/partial-update.ts +60 -7
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +156 -18
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +72 -8
- package/src/browser/react/NavigationProvider.tsx +57 -11
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/use-handle.ts +9 -58
- 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 +29 -9
- package/src/browser/rsc-router.tsx +60 -9
- package/src/browser/scroll-restoration.ts +10 -8
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/server-action-bridge.ts +8 -18
- package/src/browser/types.ts +33 -5
- package/src/build/generate-manifest.ts +6 -6
- package/src/build/generate-route-types.ts +3 -0
- package/src/build/route-trie.ts +50 -24
- package/src/build/route-types/include-resolution.ts +8 -1
- package/src/build/route-types/router-processing.ts +211 -72
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.tsx +84 -230
- package/src/deps/browser.ts +0 -1
- package/src/handle.ts +40 -0
- package/src/index.rsc.ts +6 -1
- package/src/index.ts +49 -6
- package/src/outlet-context.ts +1 -1
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +27 -2
- package/src/route-definition/dsl-helpers.ts +210 -35
- package/src/route-definition/helpers-types.ts +61 -14
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/redirect.ts +9 -1
- package/src/route-definition/resolve-handler-use.ts +155 -0
- package/src/route-types.ts +18 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/handler-context.ts +70 -17
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/lazy-includes.ts +6 -6
- package/src/router/loader-resolution.ts +153 -21
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +127 -192
- package/src/router/match-middleware/cache-lookup.ts +28 -8
- package/src/router/match-middleware/segment-resolution.ts +53 -0
- package/src/router/match-result.ts +82 -4
- package/src/router/middleware-types.ts +2 -28
- package/src/router/middleware.ts +32 -7
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/pattern-matching.ts +60 -9
- package/src/router/prerender-match.ts +110 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-interfaces.ts +36 -4
- package/src/router/router-options.ts +37 -11
- package/src/router/segment-resolution/fresh.ts +70 -5
- package/src/router/segment-resolution/revalidation.ts +87 -9
- package/src/router/trie-matching.ts +10 -4
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +54 -7
- package/src/rsc/handler.ts +478 -399
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/loader-fetch.ts +18 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +14 -3
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +15 -2
- package/src/rsc/server-action.ts +10 -2
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +6 -4
- 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 +65 -5
- package/src/server/handle-store.ts +19 -0
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +142 -55
- package/src/ssr/index.tsx +3 -0
- package/src/static-handler.ts +18 -6
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +17 -43
- package/src/types/loader-types.ts +37 -11
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +12 -1
- package/src/types/segments.ts +1 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +39 -6
- package/src/urls/path-helper.ts +47 -12
- package/src/urls/pattern-types.ts +12 -0
- package/src/urls/response-types.ts +18 -16
- package/src/use-loader.tsx +77 -5
- package/src/vite/discovery/bundle-postprocess.ts +30 -33
- package/src/vite/discovery/discover-routers.ts +5 -1
- package/src/vite/discovery/prerender-collection.ts +128 -74
- package/src/vite/discovery/state.ts +13 -4
- package/src/vite/index.ts +4 -0
- package/src/vite/plugin-types.ts +60 -5
- 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-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +257 -40
- package/src/vite/plugins/performance-tracks.ts +64 -206
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/rango.ts +40 -18
- package/src/vite/router-discovery.ts +237 -37
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +1 -1
- package/src/vite/utils/prerender-utils.ts +37 -5
- package/src/vite/utils/shared-utils.ts +3 -2
- package/src/browser/debug-channel.ts +0 -93
|
@@ -12,8 +12,6 @@ import {
|
|
|
12
12
|
startBrowserTransaction,
|
|
13
13
|
} from "./logging.js";
|
|
14
14
|
import { getRangoState } from "./rango-state.js";
|
|
15
|
-
import { createClientDebugChannel, DEBUG_ID_HEADER } from "./debug-channel.js";
|
|
16
|
-
import { findSourceMapURL } from "../deps/browser.js";
|
|
17
15
|
import {
|
|
18
16
|
extractRscHeaderUrl,
|
|
19
17
|
emptyResponse,
|
|
@@ -21,6 +19,7 @@ import {
|
|
|
21
19
|
} from "./response-adapter.js";
|
|
22
20
|
import {
|
|
23
21
|
buildPrefetchKey,
|
|
22
|
+
buildSourceKey,
|
|
24
23
|
consumeInflightPrefetch,
|
|
25
24
|
consumePrefetch,
|
|
26
25
|
} from "./prefetch/cache.js";
|
|
@@ -32,8 +31,10 @@ import {
|
|
|
32
31
|
* deserializing the response using the RSC runtime.
|
|
33
32
|
*
|
|
34
33
|
* Checks the in-memory prefetch cache before making a network request.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* Tries the source-scoped key first (populated when the server tagged
|
|
35
|
+
* the response as source-sensitive via `X-RSC-Prefetch-Scope: source`)
|
|
36
|
+
* and falls back to the Rango-state-keyed wildcard slot used for the
|
|
37
|
+
* common source-agnostic case.
|
|
37
38
|
*
|
|
38
39
|
* @param deps - RSC browser dependencies (createFromFetch)
|
|
39
40
|
* @returns NavigationClient instance
|
|
@@ -63,6 +64,7 @@ export function createNavigationClient(
|
|
|
63
64
|
staleRevalidation,
|
|
64
65
|
interceptSourceUrl,
|
|
65
66
|
version,
|
|
67
|
+
routerId,
|
|
66
68
|
hmr,
|
|
67
69
|
} = options;
|
|
68
70
|
|
|
@@ -90,40 +92,99 @@ export function createNavigationClient(
|
|
|
90
92
|
if (version) {
|
|
91
93
|
fetchUrl.searchParams.set("_rsc_v", version);
|
|
92
94
|
}
|
|
95
|
+
if (routerId) {
|
|
96
|
+
fetchUrl.searchParams.set("_rsc_rid", routerId);
|
|
97
|
+
}
|
|
93
98
|
|
|
94
|
-
// Check completed in-memory prefetch cache before making a network
|
|
95
|
-
//
|
|
96
|
-
//
|
|
99
|
+
// Check completed in-memory prefetch cache before making a network
|
|
100
|
+
// request. Try the source-scoped key first (populated when the server
|
|
101
|
+
// tagged the prefetch response as source-sensitive, e.g. intercepts,
|
|
102
|
+
// or when a Link opted in with `prefetchKey=":source"`), then fall
|
|
103
|
+
// back to the wildcard slot shared across source pages.
|
|
104
|
+
// Both keys embed the Rango state, so state rotation (deploy or
|
|
105
|
+
// server-action invalidation) auto-invalidates both scopes.
|
|
97
106
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
98
107
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
99
|
-
//
|
|
100
108
|
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
const rangoState = getRangoState();
|
|
110
|
+
const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
|
|
111
|
+
const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
|
|
112
|
+
|
|
113
|
+
let cachedResponse: Response | null = null;
|
|
114
|
+
let hitKey: string | null = null;
|
|
115
|
+
if (canUsePrefetch) {
|
|
116
|
+
cachedResponse = consumePrefetch(cacheKey);
|
|
117
|
+
if (cachedResponse) {
|
|
118
|
+
hitKey = cacheKey;
|
|
119
|
+
} else {
|
|
120
|
+
cachedResponse = consumePrefetch(wildcardKey);
|
|
121
|
+
if (cachedResponse) hitKey = wildcardKey;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let inflightResponsePromise: Promise<Response | null> | null = null;
|
|
126
|
+
if (canUsePrefetch && !cachedResponse) {
|
|
127
|
+
inflightResponsePromise = consumeInflightPrefetch(cacheKey);
|
|
128
|
+
if (inflightResponsePromise) {
|
|
129
|
+
hitKey = cacheKey;
|
|
130
|
+
} else {
|
|
131
|
+
inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
|
|
132
|
+
if (inflightResponsePromise) hitKey = wildcardKey;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
106
135
|
// Track when the stream completes
|
|
107
136
|
let resolveStreamComplete: () => void;
|
|
108
137
|
const streamComplete = new Promise<void>((resolve) => {
|
|
109
138
|
resolveStreamComplete = resolve;
|
|
110
139
|
});
|
|
111
140
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Validate RSC control headers on any response (fresh, cached, or
|
|
143
|
+
* in-flight). Handles version-mismatch reloads and server redirects.
|
|
144
|
+
* Returns the response unchanged when no control header is present.
|
|
145
|
+
*/
|
|
146
|
+
const validateRscHeaders = (
|
|
147
|
+
response: Response,
|
|
148
|
+
source: string,
|
|
149
|
+
): Response | Promise<Response> => {
|
|
150
|
+
// Version mismatch — server wants a full page reload
|
|
151
|
+
const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
|
|
152
|
+
if (reload === "blocked") {
|
|
153
|
+
resolveStreamComplete();
|
|
154
|
+
return emptyResponse();
|
|
155
|
+
}
|
|
156
|
+
if (reload) {
|
|
157
|
+
if (tx) {
|
|
158
|
+
browserDebugLog(tx, `version mismatch, reloading (${source})`, {
|
|
159
|
+
reloadUrl: reload.url,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
window.location.href = reload.url;
|
|
163
|
+
// Block further processing — page is reloading
|
|
164
|
+
return new Promise<Response>(() => {});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Server-side redirect without state: the server returned 204 with
|
|
168
|
+
// X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
|
|
169
|
+
// to a URL rendering full HTML). Throw ServerRedirect so the
|
|
170
|
+
// navigation bridge catches it and re-navigates with _skipCache.
|
|
171
|
+
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
172
|
+
if (redirect === "blocked") {
|
|
173
|
+
resolveStreamComplete();
|
|
174
|
+
return emptyResponse();
|
|
175
|
+
}
|
|
176
|
+
if (redirect) {
|
|
177
|
+
if (tx) {
|
|
178
|
+
browserDebugLog(tx, `server redirect (${source})`, {
|
|
179
|
+
redirectUrl: redirect.url,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
resolveStreamComplete();
|
|
183
|
+
throw new ServerRedirect(redirect.url, undefined);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return response;
|
|
187
|
+
};
|
|
127
188
|
|
|
128
189
|
/** Start a fresh navigation fetch (no cache / inflight hit). */
|
|
129
190
|
const doFreshFetch = (): Promise<Response> => {
|
|
@@ -142,47 +203,14 @@ export function createNavigationClient(
|
|
|
142
203
|
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
143
204
|
}),
|
|
144
205
|
...(hmr && { "X-RSC-HMR": "1" }),
|
|
145
|
-
...(debugId && { [DEBUG_ID_HEADER]: debugId }),
|
|
146
206
|
},
|
|
147
207
|
signal,
|
|
148
208
|
}).then((response) => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (reload === "blocked") {
|
|
152
|
-
resolveStreamComplete();
|
|
153
|
-
return emptyResponse();
|
|
154
|
-
}
|
|
155
|
-
if (reload) {
|
|
156
|
-
if (tx) {
|
|
157
|
-
browserDebugLog(tx, "version mismatch, reloading", {
|
|
158
|
-
reloadUrl: reload.url,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
window.location.href = reload.url;
|
|
162
|
-
return new Promise<Response>(() => {});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Server-side redirect without state: the server returned 204 with
|
|
166
|
-
// X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
|
|
167
|
-
// to a URL rendering full HTML). Throw ServerRedirect so the
|
|
168
|
-
// navigation bridge catches it and re-navigates with _skipCache.
|
|
169
|
-
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
170
|
-
if (redirect === "blocked") {
|
|
171
|
-
resolveStreamComplete();
|
|
172
|
-
return emptyResponse();
|
|
173
|
-
}
|
|
174
|
-
if (redirect) {
|
|
175
|
-
if (tx) {
|
|
176
|
-
browserDebugLog(tx, "server redirect", {
|
|
177
|
-
redirectUrl: redirect.url,
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
resolveStreamComplete();
|
|
181
|
-
throw new ServerRedirect(redirect.url, undefined);
|
|
182
|
-
}
|
|
209
|
+
const validated = validateRscHeaders(response, "fetch");
|
|
210
|
+
if (validated instanceof Promise) return validated;
|
|
183
211
|
|
|
184
212
|
return teeWithCompletion(
|
|
185
|
-
|
|
213
|
+
validated,
|
|
186
214
|
() => {
|
|
187
215
|
if (tx) browserDebugLog(tx, "stream complete");
|
|
188
216
|
resolveStreamComplete();
|
|
@@ -196,13 +224,17 @@ export function createNavigationClient(
|
|
|
196
224
|
|
|
197
225
|
if (cachedResponse) {
|
|
198
226
|
if (tx) {
|
|
199
|
-
browserDebugLog(tx, "prefetch cache hit", {
|
|
227
|
+
browserDebugLog(tx, "prefetch cache hit", {
|
|
228
|
+
key: hitKey,
|
|
229
|
+
wildcard: hitKey === wildcardKey,
|
|
230
|
+
});
|
|
200
231
|
}
|
|
201
|
-
// Cached response body is already fully buffered (arrayBuffer),
|
|
202
|
-
// so stream completion is immediate.
|
|
203
232
|
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
233
|
+
const validated = validateRscHeaders(response, "prefetch cache");
|
|
234
|
+
if (validated instanceof Promise) return validated;
|
|
235
|
+
|
|
204
236
|
return teeWithCompletion(
|
|
205
|
-
|
|
237
|
+
validated,
|
|
206
238
|
() => {
|
|
207
239
|
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
208
240
|
resolveStreamComplete();
|
|
@@ -212,8 +244,12 @@ export function createNavigationClient(
|
|
|
212
244
|
});
|
|
213
245
|
} else if (inflightResponsePromise) {
|
|
214
246
|
if (tx) {
|
|
215
|
-
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
247
|
+
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
248
|
+
key: hitKey,
|
|
249
|
+
wildcard: hitKey === wildcardKey,
|
|
250
|
+
});
|
|
216
251
|
}
|
|
252
|
+
const adoptedViaWildcard = hitKey === wildcardKey;
|
|
217
253
|
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
218
254
|
if (!response) {
|
|
219
255
|
if (tx) {
|
|
@@ -222,8 +258,28 @@ export function createNavigationClient(
|
|
|
222
258
|
return doFreshFetch();
|
|
223
259
|
}
|
|
224
260
|
|
|
261
|
+
// Cross-source safety: an inflight promise adopted via the
|
|
262
|
+
// wildcard key may turn out to be source-scoped (server emitted
|
|
263
|
+
// `X-RSC-Prefetch-Scope: source`), which means it was built for
|
|
264
|
+
// a different source page. Discard and refetch.
|
|
265
|
+
if (
|
|
266
|
+
adoptedViaWildcard &&
|
|
267
|
+
response.headers.get("x-rsc-prefetch-scope") === "source"
|
|
268
|
+
) {
|
|
269
|
+
if (tx) {
|
|
270
|
+
browserDebugLog(
|
|
271
|
+
tx,
|
|
272
|
+
"wildcard inflight turned out source-scoped, refetching",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return doFreshFetch();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const validated = validateRscHeaders(response, "inflight prefetch");
|
|
279
|
+
if (validated instanceof Promise) return validated;
|
|
280
|
+
|
|
225
281
|
return teeWithCompletion(
|
|
226
|
-
|
|
282
|
+
validated,
|
|
227
283
|
() => {
|
|
228
284
|
if (tx) {
|
|
229
285
|
browserDebugLog(tx, "stream complete (from inflight prefetch)");
|
|
@@ -238,13 +294,8 @@ export function createNavigationClient(
|
|
|
238
294
|
}
|
|
239
295
|
|
|
240
296
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
responsePromise,
|
|
244
|
-
{
|
|
245
|
-
...(debugChannel && { debugChannel, findSourceMapURL }),
|
|
246
|
-
},
|
|
247
|
-
);
|
|
297
|
+
const payload = await deps.createFromFetch<RscPayload>(responsePromise);
|
|
298
|
+
|
|
248
299
|
if (tx) {
|
|
249
300
|
browserDebugLog(tx, "response received", {
|
|
250
301
|
isPartial: payload.metadata?.isPartial,
|
|
@@ -12,7 +12,10 @@ import type {
|
|
|
12
12
|
ActionStateListener,
|
|
13
13
|
HandleData,
|
|
14
14
|
} from "./types.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
clearPrefetchCache,
|
|
17
|
+
clearPrefetchCacheLocal,
|
|
18
|
+
} from "./prefetch/cache.js";
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Default action state (idle with no payload)
|
|
@@ -28,9 +31,15 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
|
|
|
28
31
|
// Maximum number of history entries to cache (URLs visited)
|
|
29
32
|
const HISTORY_CACHE_SIZE = 20;
|
|
30
33
|
|
|
31
|
-
// Cache entry: [url-key, segments, stale, handleData?]
|
|
34
|
+
// Cache entry: [url-key, segments, stale, handleData?, routerId?]
|
|
32
35
|
// stale=true means the data may be outdated and should be revalidated on access
|
|
33
|
-
type HistoryCacheEntry = [
|
|
36
|
+
type HistoryCacheEntry = [
|
|
37
|
+
string,
|
|
38
|
+
ResolvedSegment[],
|
|
39
|
+
boolean,
|
|
40
|
+
HandleData?,
|
|
41
|
+
string?,
|
|
42
|
+
];
|
|
34
43
|
|
|
35
44
|
/**
|
|
36
45
|
* Shallow clone handleData to avoid reference sharing between cache entries.
|
|
@@ -258,6 +267,11 @@ export function createNavigationStore(
|
|
|
258
267
|
// Used to maintain intercept context during action revalidation
|
|
259
268
|
let interceptSourceUrl: string | null = null;
|
|
260
269
|
|
|
270
|
+
// Router identity - tracks which router is currently active.
|
|
271
|
+
// When this changes on a partial response, the client forces a full
|
|
272
|
+
// tree replacement instead of reconciling with stale segments.
|
|
273
|
+
let currentRouterId: string | undefined;
|
|
274
|
+
|
|
261
275
|
// Action state tracking (for useAction hook)
|
|
262
276
|
// Maps action function ID to its tracked state
|
|
263
277
|
const actionStates = new Map<string, TrackedActionState>();
|
|
@@ -324,6 +338,18 @@ export function createNavigationStore(
|
|
|
324
338
|
clearPrefetchCache();
|
|
325
339
|
}
|
|
326
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Drop this tab's navigation + prefetch caches without broadcasting or
|
|
343
|
+
* rotating shared state. Used when the local session changes in a way that
|
|
344
|
+
* doesn't affect other tabs — e.g. this tab crosses into a different app
|
|
345
|
+
* via a cross-router navigation. Other tabs in the old app keep their
|
|
346
|
+
* caches and their X-Rango-State token.
|
|
347
|
+
*/
|
|
348
|
+
function clearCacheInternalLocal(): void {
|
|
349
|
+
historyCache.length = 0;
|
|
350
|
+
clearPrefetchCacheLocal();
|
|
351
|
+
}
|
|
352
|
+
|
|
327
353
|
/**
|
|
328
354
|
* Mark all cache entries as stale (internal - does not broadcast)
|
|
329
355
|
*/
|
|
@@ -571,10 +597,17 @@ export function createNavigationStore(
|
|
|
571
597
|
segments,
|
|
572
598
|
false,
|
|
573
599
|
clonedHandleData,
|
|
600
|
+
currentRouterId,
|
|
574
601
|
];
|
|
575
602
|
} else {
|
|
576
603
|
// Add new entry at the end (not stale)
|
|
577
|
-
historyCache.push([
|
|
604
|
+
historyCache.push([
|
|
605
|
+
historyKey,
|
|
606
|
+
segments,
|
|
607
|
+
false,
|
|
608
|
+
clonedHandleData,
|
|
609
|
+
currentRouterId,
|
|
610
|
+
]);
|
|
578
611
|
// Remove oldest entries if over limit
|
|
579
612
|
while (historyCache.length > cacheSize) {
|
|
580
613
|
historyCache.shift();
|
|
@@ -586,14 +619,22 @@ export function createNavigationStore(
|
|
|
586
619
|
* Get cached segments for a history entry
|
|
587
620
|
* Returns { segments, stale, handleData } or undefined if not cached
|
|
588
621
|
*/
|
|
589
|
-
getCachedSegments(
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
622
|
+
getCachedSegments(historyKey: string):
|
|
623
|
+
| {
|
|
624
|
+
segments: ResolvedSegment[];
|
|
625
|
+
stale: boolean;
|
|
626
|
+
handleData?: HandleData;
|
|
627
|
+
routerId?: string;
|
|
628
|
+
}
|
|
593
629
|
| undefined {
|
|
594
630
|
const entry = historyCache.find(([key]) => key === historyKey);
|
|
595
631
|
if (!entry) return undefined;
|
|
596
|
-
return {
|
|
632
|
+
return {
|
|
633
|
+
segments: entry[1],
|
|
634
|
+
stale: entry[2],
|
|
635
|
+
handleData: entry[3],
|
|
636
|
+
routerId: entry[4],
|
|
637
|
+
};
|
|
597
638
|
},
|
|
598
639
|
|
|
599
640
|
/**
|
|
@@ -621,6 +662,7 @@ export function createNavigationStore(
|
|
|
621
662
|
entry[1],
|
|
622
663
|
entry[2],
|
|
623
664
|
clonedHandleData,
|
|
665
|
+
entry[4], // preserve routerId
|
|
624
666
|
];
|
|
625
667
|
}
|
|
626
668
|
},
|
|
@@ -641,6 +683,15 @@ export function createNavigationStore(
|
|
|
641
683
|
clearCacheAndBroadcast();
|
|
642
684
|
},
|
|
643
685
|
|
|
686
|
+
/**
|
|
687
|
+
* Drop this tab's navigation + prefetch caches locally without
|
|
688
|
+
* broadcasting or rotating shared state. Intended for cross-app
|
|
689
|
+
* transitions where the session state diverges for this tab only.
|
|
690
|
+
*/
|
|
691
|
+
clearHistoryCacheLocal(): void {
|
|
692
|
+
clearCacheInternalLocal();
|
|
693
|
+
},
|
|
694
|
+
|
|
644
695
|
/**
|
|
645
696
|
* Mark cache as stale and broadcast to other tabs
|
|
646
697
|
* Called after server actions - allows SWR pattern for popstate
|
|
@@ -687,6 +738,14 @@ export function createNavigationStore(
|
|
|
687
738
|
interceptSourceUrl = url;
|
|
688
739
|
},
|
|
689
740
|
|
|
741
|
+
getRouterId(): string | undefined {
|
|
742
|
+
return currentRouterId;
|
|
743
|
+
},
|
|
744
|
+
|
|
745
|
+
setRouterId(id: string): void {
|
|
746
|
+
currentRouterId = id;
|
|
747
|
+
},
|
|
748
|
+
|
|
690
749
|
// ========================================================================
|
|
691
750
|
// UI Update Notifications
|
|
692
751
|
// ========================================================================
|
|
@@ -39,8 +39,15 @@ export interface PartialUpdateConfig {
|
|
|
39
39
|
segments: ResolvedSegment[],
|
|
40
40
|
options?: RenderSegmentsOptions,
|
|
41
41
|
) => Promise<ReactNode> | ReactNode;
|
|
42
|
-
/** RSC version
|
|
43
|
-
|
|
42
|
+
/** RSC version getter — returns the current version (may change after HMR) */
|
|
43
|
+
getVersion?: () => string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Replace the active app-shell when a cross-app navigation is detected.
|
|
46
|
+
* Called before the full-update tree replacement renders, so the new
|
|
47
|
+
* payload's rootLayout, basename, and version are picked up. Theme,
|
|
48
|
+
* warmup, and prefetch TTL are not part of the shell — see AppShell.
|
|
49
|
+
*/
|
|
50
|
+
applyAppShell?: (next: import("./app-shell.js").AppShell) => void;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
/**
|
|
@@ -104,7 +111,14 @@ export type PartialUpdater = (
|
|
|
104
111
|
export function createPartialUpdater(
|
|
105
112
|
config: PartialUpdateConfig,
|
|
106
113
|
): PartialUpdater {
|
|
107
|
-
const {
|
|
114
|
+
const {
|
|
115
|
+
store,
|
|
116
|
+
client,
|
|
117
|
+
onUpdate,
|
|
118
|
+
renderSegments,
|
|
119
|
+
getVersion = () => undefined,
|
|
120
|
+
applyAppShell,
|
|
121
|
+
} = config;
|
|
108
122
|
|
|
109
123
|
/**
|
|
110
124
|
* Get current page's cached segments as an array
|
|
@@ -161,9 +175,16 @@ export function createPartialUpdater(
|
|
|
161
175
|
segments = segmentIds ?? segmentState.currentSegmentIds;
|
|
162
176
|
}
|
|
163
177
|
|
|
164
|
-
// For intercept revalidation, use the intercept source URL as previousUrl
|
|
178
|
+
// For intercept revalidation, use the intercept source URL as previousUrl.
|
|
179
|
+
// For leave-intercept, tx.currentUrl captures window.location.href at tx
|
|
180
|
+
// creation, which on popstate is already the destination URL and would
|
|
181
|
+
// tell the server "from == to". segmentState.currentUrl still points at
|
|
182
|
+
// the URL the cached segments render (the intercept URL), which is the
|
|
183
|
+
// correct "from" for the server's diff computation.
|
|
165
184
|
const previousUrl =
|
|
166
|
-
|
|
185
|
+
mode.type === "leave-intercept"
|
|
186
|
+
? segmentState.currentUrl || tx.currentUrl
|
|
187
|
+
: interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
|
|
167
188
|
|
|
168
189
|
debugLog(`\n[Browser] >>> NAVIGATION`);
|
|
169
190
|
debugLog(`[Browser] From: ${previousUrl}`);
|
|
@@ -182,6 +203,11 @@ export function createPartialUpdater(
|
|
|
182
203
|
targetCache && targetCache.length > 0
|
|
183
204
|
? targetCache
|
|
184
205
|
: getCurrentCachedSegments();
|
|
206
|
+
const cachedSegsSource =
|
|
207
|
+
targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
|
|
208
|
+
debugLog(
|
|
209
|
+
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
210
|
+
);
|
|
185
211
|
|
|
186
212
|
// Fetch partial payload (no abort signal - RSC doesn't support it well)
|
|
187
213
|
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
@@ -193,7 +219,8 @@ export function createPartialUpdater(
|
|
|
193
219
|
// (action redirect sends empty segments for a fresh render).
|
|
194
220
|
staleRevalidation:
|
|
195
221
|
mode.type === "stale-revalidation" || segments.length === 0,
|
|
196
|
-
version,
|
|
222
|
+
version: getVersion(),
|
|
223
|
+
routerId: store.getRouterId?.(),
|
|
197
224
|
});
|
|
198
225
|
// Mark navigation as streaming (response received, now parsing RSC).
|
|
199
226
|
// Called after fetchPartial so pendingUrl stays set during the network wait,
|
|
@@ -206,6 +233,32 @@ export function createPartialUpdater(
|
|
|
206
233
|
streamingToken.end();
|
|
207
234
|
});
|
|
208
235
|
|
|
236
|
+
// Detect app switch: if routerId changed, the navigation crossed into
|
|
237
|
+
// a different router (e.g., via host router path mount). Downgrade
|
|
238
|
+
// partial to full so the entire tree is replaced without reconciliation
|
|
239
|
+
// against stale segments from the previous app, and replace the app
|
|
240
|
+
// shell (rootLayout, basename, version) so the target app's document
|
|
241
|
+
// and router config take effect instead of remaining captured from the
|
|
242
|
+
// initial load. Theme, warmup, and prefetch TTL are intentionally
|
|
243
|
+
// document-lifetime (see AppShell doc); a new document navigation
|
|
244
|
+
// applies them.
|
|
245
|
+
if (payload.metadata?.routerId) {
|
|
246
|
+
const prevRouterId = store.getRouterId?.();
|
|
247
|
+
if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
|
|
248
|
+
debugLog(
|
|
249
|
+
`[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
|
|
250
|
+
);
|
|
251
|
+
payload.metadata.isPartial = false;
|
|
252
|
+
applyAppShell?.({
|
|
253
|
+
routerId: payload.metadata.routerId,
|
|
254
|
+
rootLayout: payload.metadata.rootLayout,
|
|
255
|
+
basename: payload.metadata.basename,
|
|
256
|
+
version: payload.metadata.version,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
store.setRouterId?.(payload.metadata.routerId);
|
|
260
|
+
}
|
|
261
|
+
|
|
209
262
|
// Handle server-side redirect with state
|
|
210
263
|
if (payload.metadata?.redirect) {
|
|
211
264
|
if (signal?.aborted) {
|
|
@@ -259,7 +312,7 @@ export function createPartialUpdater(
|
|
|
259
312
|
existingSegments,
|
|
260
313
|
);
|
|
261
314
|
|
|
262
|
-
//
|
|
315
|
+
// tx.commit() cached the source page's handleData because
|
|
263
316
|
// eventController hasn't been updated yet. Overwrite with the
|
|
264
317
|
// correct cached handleData to prevent cache corruption on
|
|
265
318
|
// subsequent navigations to this same URL.
|