@rangojs/router 0.0.0-experimental.fa8a383a → 0.0.0-experimental.fb4fdc18
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 +188 -35
- package/dist/bin/rango.js +130 -47
- package/dist/vite/index.js +1884 -537
- package/dist/vite/index.js.bak +5448 -0
- 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/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +8 -0
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/hooks/SKILL.md +33 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +93 -17
- package/skills/loader/SKILL.md +123 -46
- package/skills/middleware/SKILL.md +36 -3
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/parallel/SKILL.md +133 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +26 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +75 -0
- package/skills/router-setup/SKILL.md +87 -2
- package/skills/server-actions/SKILL.md +739 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +19 -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/event-controller.ts +44 -4
- package/src/browser/navigation-bridge.ts +95 -7
- package/src/browser/navigation-client.ts +128 -53
- package/src/browser/navigation-store.ts +68 -9
- package/src/browser/partial-update.ts +93 -12
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +156 -18
- package/src/browser/prefetch/queue.ts +92 -29
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +72 -8
- package/src/browser/react/NavigationProvider.tsx +82 -21
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/filter-segment-order.ts +51 -7
- 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 +17 -4
- package/src/browser/react/use-router.ts +29 -9
- package/src/browser/react/use-segments.ts +11 -8
- 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 -6
- package/src/browser/types.ts +46 -5
- package/src/build/generate-manifest.ts +6 -6
- package/src/build/generate-route-types.ts +3 -0
- package/src/build/route-trie.ts +52 -25
- 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/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +46 -5
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/cache/taint.ts +55 -0
- package/src/client.tsx +84 -230
- package/src/context-var.ts +72 -2
- 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 +28 -2
- package/src/route-definition/dsl-helpers.ts +210 -35
- package/src/route-definition/helpers-types.ts +73 -20
- 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 +102 -25
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/lazy-includes.ts +6 -6
- package/src/router/loader-resolution.ts +159 -21
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +128 -192
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-middleware/background-revalidation.ts +12 -1
- package/src/router/match-middleware/cache-lookup.ts +74 -14
- package/src/router/match-middleware/cache-store.ts +21 -4
- package/src/router/match-middleware/segment-resolution.ts +53 -0
- package/src/router/match-result.ts +112 -9
- package/src/router/metrics.ts +6 -1
- package/src/router/middleware-types.ts +20 -33
- package/src/router/middleware.ts +56 -12
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/pattern-matching.ts +101 -17
- 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/revalidation.ts +15 -1
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +1 -0
- package/src/router/router-interfaces.ts +36 -4
- package/src/router/router-options.ts +37 -11
- package/src/router/segment-resolution/fresh.ts +114 -18
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/revalidation.ts +257 -127
- package/src/router/trie-matching.ts +18 -13
- package/src/router/types.ts +1 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +55 -7
- package/src/rsc/handler.ts +478 -383
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +18 -2
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +20 -1
- package/src/rsc/server-action.ts +12 -0
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +15 -1
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +22 -62
- package/src/server/context.ts +76 -4
- package/src/server/handle-store.ts +19 -0
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +185 -57
- package/src/ssr/index.tsx +8 -1
- package/src/static-handler.ts +18 -6
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +145 -68
- package/src/types/loader-types.ts +41 -15
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +12 -1
- package/src/types/segments.ts +18 -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/debug.ts +184 -0
- package/src/vite/discovery/bundle-postprocess.ts +30 -33
- package/src/vite/discovery/discover-routers.ts +36 -4
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +175 -74
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- 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/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-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +563 -316
- package/src/vite/plugins/performance-tracks.ts +96 -0
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/plugins/use-cache-transform.ts +56 -43
- package/src/vite/plugins/version-injector.ts +37 -11
- package/src/vite/rango.ts +63 -11
- package/src/vite/router-discovery.ts +732 -86
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +38 -5
- package/src/vite/utils/shared-utils.ts +3 -2
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "./response-adapter.js";
|
|
20
20
|
import {
|
|
21
21
|
buildPrefetchKey,
|
|
22
|
+
buildSourceKey,
|
|
22
23
|
consumeInflightPrefetch,
|
|
23
24
|
consumePrefetch,
|
|
24
25
|
} from "./prefetch/cache.js";
|
|
@@ -30,8 +31,10 @@ import {
|
|
|
30
31
|
* deserializing the response using the RSC runtime.
|
|
31
32
|
*
|
|
32
33
|
* Checks the in-memory prefetch cache before making a network request.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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.
|
|
35
38
|
*
|
|
36
39
|
* @param deps - RSC browser dependencies (createFromFetch)
|
|
37
40
|
* @returns NavigationClient instance
|
|
@@ -61,6 +64,7 @@ export function createNavigationClient(
|
|
|
61
64
|
staleRevalidation,
|
|
62
65
|
interceptSourceUrl,
|
|
63
66
|
version,
|
|
67
|
+
routerId,
|
|
64
68
|
hmr,
|
|
65
69
|
} = options;
|
|
66
70
|
|
|
@@ -88,25 +92,100 @@ export function createNavigationClient(
|
|
|
88
92
|
if (version) {
|
|
89
93
|
fetchUrl.searchParams.set("_rsc_v", version);
|
|
90
94
|
}
|
|
95
|
+
if (routerId) {
|
|
96
|
+
fetchUrl.searchParams.set("_rsc_rid", routerId);
|
|
97
|
+
}
|
|
91
98
|
|
|
92
|
-
// Check completed in-memory prefetch cache before making a network
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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.
|
|
95
106
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
96
107
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
97
|
-
//
|
|
98
108
|
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
}
|
|
104
135
|
// Track when the stream completes
|
|
105
136
|
let resolveStreamComplete: () => void;
|
|
106
137
|
const streamComplete = new Promise<void>((resolve) => {
|
|
107
138
|
resolveStreamComplete = resolve;
|
|
108
139
|
});
|
|
109
140
|
|
|
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
|
+
};
|
|
188
|
+
|
|
110
189
|
/** Start a fresh navigation fetch (no cache / inflight hit). */
|
|
111
190
|
const doFreshFetch = (): Promise<Response> => {
|
|
112
191
|
if (tx) {
|
|
@@ -127,43 +206,11 @@ export function createNavigationClient(
|
|
|
127
206
|
},
|
|
128
207
|
signal,
|
|
129
208
|
}).then((response) => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (reload === "blocked") {
|
|
133
|
-
resolveStreamComplete();
|
|
134
|
-
return emptyResponse();
|
|
135
|
-
}
|
|
136
|
-
if (reload) {
|
|
137
|
-
if (tx) {
|
|
138
|
-
browserDebugLog(tx, "version mismatch, reloading", {
|
|
139
|
-
reloadUrl: reload.url,
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
window.location.href = reload.url;
|
|
143
|
-
return new Promise<Response>(() => {});
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Server-side redirect without state: the server returned 204 with
|
|
147
|
-
// X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
|
|
148
|
-
// to a URL rendering full HTML). Throw ServerRedirect so the
|
|
149
|
-
// navigation bridge catches it and re-navigates with _skipCache.
|
|
150
|
-
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
151
|
-
if (redirect === "blocked") {
|
|
152
|
-
resolveStreamComplete();
|
|
153
|
-
return emptyResponse();
|
|
154
|
-
}
|
|
155
|
-
if (redirect) {
|
|
156
|
-
if (tx) {
|
|
157
|
-
browserDebugLog(tx, "server redirect", {
|
|
158
|
-
redirectUrl: redirect.url,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
resolveStreamComplete();
|
|
162
|
-
throw new ServerRedirect(redirect.url, undefined);
|
|
163
|
-
}
|
|
209
|
+
const validated = validateRscHeaders(response, "fetch");
|
|
210
|
+
if (validated instanceof Promise) return validated;
|
|
164
211
|
|
|
165
212
|
return teeWithCompletion(
|
|
166
|
-
|
|
213
|
+
validated,
|
|
167
214
|
() => {
|
|
168
215
|
if (tx) browserDebugLog(tx, "stream complete");
|
|
169
216
|
resolveStreamComplete();
|
|
@@ -177,13 +224,17 @@ export function createNavigationClient(
|
|
|
177
224
|
|
|
178
225
|
if (cachedResponse) {
|
|
179
226
|
if (tx) {
|
|
180
|
-
browserDebugLog(tx, "prefetch cache hit", {
|
|
227
|
+
browserDebugLog(tx, "prefetch cache hit", {
|
|
228
|
+
key: hitKey,
|
|
229
|
+
wildcard: hitKey === wildcardKey,
|
|
230
|
+
});
|
|
181
231
|
}
|
|
182
|
-
// Cached response body is already fully buffered (arrayBuffer),
|
|
183
|
-
// so stream completion is immediate.
|
|
184
232
|
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
233
|
+
const validated = validateRscHeaders(response, "prefetch cache");
|
|
234
|
+
if (validated instanceof Promise) return validated;
|
|
235
|
+
|
|
185
236
|
return teeWithCompletion(
|
|
186
|
-
|
|
237
|
+
validated,
|
|
187
238
|
() => {
|
|
188
239
|
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
189
240
|
resolveStreamComplete();
|
|
@@ -193,8 +244,12 @@ export function createNavigationClient(
|
|
|
193
244
|
});
|
|
194
245
|
} else if (inflightResponsePromise) {
|
|
195
246
|
if (tx) {
|
|
196
|
-
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
247
|
+
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
248
|
+
key: hitKey,
|
|
249
|
+
wildcard: hitKey === wildcardKey,
|
|
250
|
+
});
|
|
197
251
|
}
|
|
252
|
+
const adoptedViaWildcard = hitKey === wildcardKey;
|
|
198
253
|
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
199
254
|
if (!response) {
|
|
200
255
|
if (tx) {
|
|
@@ -203,8 +258,28 @@ export function createNavigationClient(
|
|
|
203
258
|
return doFreshFetch();
|
|
204
259
|
}
|
|
205
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
|
+
|
|
206
281
|
return teeWithCompletion(
|
|
207
|
-
|
|
282
|
+
validated,
|
|
208
283
|
() => {
|
|
209
284
|
if (tx) {
|
|
210
285
|
browserDebugLog(tx, "stream complete (from inflight prefetch)");
|
|
@@ -219,8 +294,8 @@ export function createNavigationClient(
|
|
|
219
294
|
}
|
|
220
295
|
|
|
221
296
|
try {
|
|
222
|
-
// Deserialize RSC payload
|
|
223
297
|
const payload = await deps.createFromFetch<RscPayload>(responsePromise);
|
|
298
|
+
|
|
224
299
|
if (tx) {
|
|
225
300
|
browserDebugLog(tx, "response received", {
|
|
226
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
|
// ========================================================================
|
|
@@ -14,7 +14,10 @@ const addTransitionType: ((type: string) => void) | undefined =
|
|
|
14
14
|
import type { RenderSegmentsOptions } from "../segment-system.js";
|
|
15
15
|
import { reconcileSegments } from "./segment-reconciler.js";
|
|
16
16
|
import type { ReconcileActor } from "./segment-reconciler.js";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
hasActiveIntercept as hasActiveInterceptSlots,
|
|
19
|
+
isInterceptSegment,
|
|
20
|
+
} from "./intercept-utils.js";
|
|
18
21
|
import type { BoundTransaction } from "./navigation-transaction.js";
|
|
19
22
|
import { ServerRedirect } from "../errors.js";
|
|
20
23
|
import { debugLog } from "./logging.js";
|
|
@@ -28,6 +31,23 @@ function toScrollPayload(
|
|
|
28
31
|
return { enabled: scroll !== false ? scroll : false };
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Whether to wrap an update in startViewTransition.
|
|
36
|
+
*
|
|
37
|
+
* Intercept-driven updates only mutate the parallel slot — the main outlet
|
|
38
|
+
* shows the same content — so transitions on the underlying main segments
|
|
39
|
+
* shouldn't fire (otherwise their elements get hoisted above the modal).
|
|
40
|
+
*/
|
|
41
|
+
function shouldStartViewTransition(segments: ResolvedSegment[]): boolean {
|
|
42
|
+
let hasIntercept = false;
|
|
43
|
+
let hasTransition = false;
|
|
44
|
+
for (const s of segments) {
|
|
45
|
+
if (isInterceptSegment(s)) hasIntercept = true;
|
|
46
|
+
else if (s.transition) hasTransition = true;
|
|
47
|
+
}
|
|
48
|
+
return !hasIntercept && hasTransition;
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
/**
|
|
32
52
|
* Configuration for creating a partial updater
|
|
33
53
|
*/
|
|
@@ -39,8 +59,15 @@ export interface PartialUpdateConfig {
|
|
|
39
59
|
segments: ResolvedSegment[],
|
|
40
60
|
options?: RenderSegmentsOptions,
|
|
41
61
|
) => Promise<ReactNode> | ReactNode;
|
|
42
|
-
/** RSC version
|
|
43
|
-
|
|
62
|
+
/** RSC version getter — returns the current version (may change after HMR) */
|
|
63
|
+
getVersion?: () => string | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* Replace the active app-shell when a cross-app navigation is detected.
|
|
66
|
+
* Called before the full-update tree replacement renders, so the new
|
|
67
|
+
* payload's rootLayout, basename, and version are picked up. Theme,
|
|
68
|
+
* warmup, and prefetch TTL are not part of the shell — see AppShell.
|
|
69
|
+
*/
|
|
70
|
+
applyAppShell?: (next: import("./app-shell.js").AppShell) => void;
|
|
44
71
|
}
|
|
45
72
|
|
|
46
73
|
/**
|
|
@@ -104,7 +131,14 @@ export type PartialUpdater = (
|
|
|
104
131
|
export function createPartialUpdater(
|
|
105
132
|
config: PartialUpdateConfig,
|
|
106
133
|
): PartialUpdater {
|
|
107
|
-
const {
|
|
134
|
+
const {
|
|
135
|
+
store,
|
|
136
|
+
client,
|
|
137
|
+
onUpdate,
|
|
138
|
+
renderSegments,
|
|
139
|
+
getVersion = () => undefined,
|
|
140
|
+
applyAppShell,
|
|
141
|
+
} = config;
|
|
108
142
|
|
|
109
143
|
/**
|
|
110
144
|
* Get current page's cached segments as an array
|
|
@@ -161,9 +195,16 @@ export function createPartialUpdater(
|
|
|
161
195
|
segments = segmentIds ?? segmentState.currentSegmentIds;
|
|
162
196
|
}
|
|
163
197
|
|
|
164
|
-
// For intercept revalidation, use the intercept source URL as previousUrl
|
|
198
|
+
// For intercept revalidation, use the intercept source URL as previousUrl.
|
|
199
|
+
// For leave-intercept, tx.currentUrl captures window.location.href at tx
|
|
200
|
+
// creation, which on popstate is already the destination URL and would
|
|
201
|
+
// tell the server "from == to". segmentState.currentUrl still points at
|
|
202
|
+
// the URL the cached segments render (the intercept URL), which is the
|
|
203
|
+
// correct "from" for the server's diff computation.
|
|
165
204
|
const previousUrl =
|
|
166
|
-
|
|
205
|
+
mode.type === "leave-intercept"
|
|
206
|
+
? segmentState.currentUrl || tx.currentUrl
|
|
207
|
+
: interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
|
|
167
208
|
|
|
168
209
|
debugLog(`\n[Browser] >>> NAVIGATION`);
|
|
169
210
|
debugLog(`[Browser] From: ${previousUrl}`);
|
|
@@ -182,6 +223,11 @@ export function createPartialUpdater(
|
|
|
182
223
|
targetCache && targetCache.length > 0
|
|
183
224
|
? targetCache
|
|
184
225
|
: getCurrentCachedSegments();
|
|
226
|
+
const cachedSegsSource =
|
|
227
|
+
targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
|
|
228
|
+
debugLog(
|
|
229
|
+
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
230
|
+
);
|
|
185
231
|
|
|
186
232
|
// Fetch partial payload (no abort signal - RSC doesn't support it well)
|
|
187
233
|
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
@@ -193,7 +239,8 @@ export function createPartialUpdater(
|
|
|
193
239
|
// (action redirect sends empty segments for a fresh render).
|
|
194
240
|
staleRevalidation:
|
|
195
241
|
mode.type === "stale-revalidation" || segments.length === 0,
|
|
196
|
-
version,
|
|
242
|
+
version: getVersion(),
|
|
243
|
+
routerId: store.getRouterId?.(),
|
|
197
244
|
});
|
|
198
245
|
// Mark navigation as streaming (response received, now parsing RSC).
|
|
199
246
|
// Called after fetchPartial so pendingUrl stays set during the network wait,
|
|
@@ -206,6 +253,32 @@ export function createPartialUpdater(
|
|
|
206
253
|
streamingToken.end();
|
|
207
254
|
});
|
|
208
255
|
|
|
256
|
+
// Detect app switch: if routerId changed, the navigation crossed into
|
|
257
|
+
// a different router (e.g., via host router path mount). Downgrade
|
|
258
|
+
// partial to full so the entire tree is replaced without reconciliation
|
|
259
|
+
// against stale segments from the previous app, and replace the app
|
|
260
|
+
// shell (rootLayout, basename, version) so the target app's document
|
|
261
|
+
// and router config take effect instead of remaining captured from the
|
|
262
|
+
// initial load. Theme, warmup, and prefetch TTL are intentionally
|
|
263
|
+
// document-lifetime (see AppShell doc); a new document navigation
|
|
264
|
+
// applies them.
|
|
265
|
+
if (payload.metadata?.routerId) {
|
|
266
|
+
const prevRouterId = store.getRouterId?.();
|
|
267
|
+
if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
|
|
268
|
+
debugLog(
|
|
269
|
+
`[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
|
|
270
|
+
);
|
|
271
|
+
payload.metadata.isPartial = false;
|
|
272
|
+
applyAppShell?.({
|
|
273
|
+
routerId: payload.metadata.routerId,
|
|
274
|
+
rootLayout: payload.metadata.rootLayout,
|
|
275
|
+
basename: payload.metadata.basename,
|
|
276
|
+
version: payload.metadata.version,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
store.setRouterId?.(payload.metadata.routerId);
|
|
280
|
+
}
|
|
281
|
+
|
|
209
282
|
// Handle server-side redirect with state
|
|
210
283
|
if (payload.metadata?.redirect) {
|
|
211
284
|
if (signal?.aborted) {
|
|
@@ -259,6 +332,17 @@ export function createPartialUpdater(
|
|
|
259
332
|
existingSegments,
|
|
260
333
|
);
|
|
261
334
|
|
|
335
|
+
// tx.commit() cached the source page's handleData because
|
|
336
|
+
// eventController hasn't been updated yet. Overwrite with the
|
|
337
|
+
// correct cached handleData to prevent cache corruption on
|
|
338
|
+
// subsequent navigations to this same URL.
|
|
339
|
+
if (mode.targetCacheHandleData) {
|
|
340
|
+
store.updateCacheHandleData(
|
|
341
|
+
store.getHistoryKey(),
|
|
342
|
+
mode.targetCacheHandleData,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
262
346
|
// Include cachedHandleData in metadata so NavigationProvider can restore
|
|
263
347
|
// breadcrumbs and other handle data from cache.
|
|
264
348
|
// Remove `handles` from metadata to prevent NavigationProvider from
|
|
@@ -274,10 +358,7 @@ export function createPartialUpdater(
|
|
|
274
358
|
scroll: toScrollPayload(commitScroll),
|
|
275
359
|
};
|
|
276
360
|
|
|
277
|
-
|
|
278
|
-
(s) => s.transition,
|
|
279
|
-
);
|
|
280
|
-
if (cachedHasTransition) {
|
|
361
|
+
if (shouldStartViewTransition(existingSegments)) {
|
|
281
362
|
startTransition(() => {
|
|
282
363
|
if (addTransitionType) {
|
|
283
364
|
addTransitionType("navigation");
|
|
@@ -463,7 +544,7 @@ export function createPartialUpdater(
|
|
|
463
544
|
|
|
464
545
|
// Emit update to trigger React render.
|
|
465
546
|
// Scroll info is included so NavigationProvider applies it after React commits.
|
|
466
|
-
const hasTransition = reconciled.
|
|
547
|
+
const hasTransition = shouldStartViewTransition(reconciled.segments);
|
|
467
548
|
const scrollPayload = toScrollPayload(navScroll);
|
|
468
549
|
|
|
469
550
|
if (mode.type === "action" || mode.type === "stale-revalidation") {
|