@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26
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 +294 -28
- package/dist/bin/rango.js +355 -47
- package/dist/vite/index.js +1658 -1239
- package/package.json +3 -3
- package/skills/cache-guide/SKILL.md +9 -5
- package/skills/caching/SKILL.md +4 -4
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/hooks/SKILL.md +40 -29
- package/skills/host-router/SKILL.md +218 -0
- package/skills/intercept/SKILL.md +79 -0
- package/skills/layout/SKILL.md +62 -2
- package/skills/loader/SKILL.md +229 -15
- package/skills/middleware/SKILL.md +109 -30
- package/skills/parallel/SKILL.md +57 -2
- package/skills/prerender/SKILL.md +189 -19
- package/skills/rango/SKILL.md +1 -2
- package/skills/response-routes/SKILL.md +3 -3
- package/skills/route/SKILL.md +44 -3
- package/skills/router-setup/SKILL.md +80 -3
- package/skills/theme/SKILL.md +5 -4
- package/skills/typesafety/SKILL.md +59 -16
- package/skills/use-cache/SKILL.md +16 -2
- package/src/__internal.ts +1 -1
- package/src/bin/rango.ts +56 -19
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/event-controller.ts +29 -48
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +1 -1
- package/src/browser/link-interceptor.ts +19 -3
- package/src/browser/merge-segment-loaders.ts +9 -2
- package/src/browser/navigation-bridge.ts +66 -443
- package/src/browser/navigation-client.ts +34 -62
- package/src/browser/navigation-store.ts +4 -33
- package/src/browser/navigation-transaction.ts +295 -0
- package/src/browser/partial-update.ts +103 -151
- package/src/browser/prefetch/cache.ts +67 -0
- package/src/browser/prefetch/fetch.ts +137 -0
- package/src/browser/prefetch/observer.ts +65 -0
- package/src/browser/prefetch/policy.ts +42 -0
- package/src/browser/prefetch/queue.ts +88 -0
- package/src/browser/rango-state.ts +112 -0
- package/src/browser/react/Link.tsx +154 -44
- package/src/browser/react/NavigationProvider.tsx +32 -0
- package/src/browser/react/context.ts +6 -0
- package/src/browser/react/filter-segment-order.ts +11 -0
- package/src/browser/react/index.ts +2 -6
- package/src/browser/react/location-state-shared.ts +29 -11
- package/src/browser/react/location-state.ts +6 -4
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/shallow-equal.ts +27 -0
- package/src/browser/react/use-action.ts +23 -45
- package/src/browser/react/use-client-cache.ts +5 -3
- package/src/browser/react/use-handle.ts +21 -64
- package/src/browser/react/use-navigation.ts +7 -32
- package/src/browser/react/use-params.ts +5 -34
- package/src/browser/react/use-pathname.ts +2 -3
- package/src/browser/react/use-router.ts +3 -6
- package/src/browser/react/use-search-params.ts +2 -1
- package/src/browser/react/use-segments.ts +75 -114
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +46 -22
- package/src/browser/scroll-restoration.ts +10 -7
- package/src/browser/server-action-bridge.ts +458 -405
- package/src/browser/types.ts +21 -35
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +38 -13
- package/src/build/generate-route-types.ts +4 -0
- package/src/build/index.ts +1 -0
- package/src/build/route-trie.ts +19 -3
- package/src/build/route-types/codegen.ts +13 -4
- package/src/build/route-types/include-resolution.ts +13 -0
- package/src/build/route-types/per-module-writer.ts +15 -3
- package/src/build/route-types/router-processing.ts +170 -18
- package/src/build/runtime-discovery.ts +13 -1
- package/src/cache/background-task.ts +34 -0
- package/src/cache/cache-key-utils.ts +44 -0
- package/src/cache/cache-policy.ts +125 -0
- package/src/cache/cache-runtime.ts +136 -123
- package/src/cache/cache-scope.ts +76 -83
- package/src/cache/cf/cf-cache-store.ts +12 -7
- package/src/cache/document-cache.ts +93 -69
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/index.ts +0 -15
- package/src/cache/memory-segment-store.ts +43 -69
- package/src/cache/profile-registry.ts +43 -8
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +140 -117
- package/src/cache/taint.ts +30 -3
- package/src/cache/types.ts +1 -115
- package/src/client.rsc.tsx +0 -1
- package/src/client.tsx +53 -76
- package/src/errors.ts +6 -1
- package/src/handle.ts +1 -1
- package/src/handles/MetaTags.tsx +5 -2
- package/src/host/cookie-handler.ts +8 -3
- package/src/host/index.ts +0 -3
- package/src/host/router.ts +14 -1
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +53 -10
- package/src/index.ts +73 -43
- package/src/loader.rsc.ts +12 -4
- package/src/loader.ts +8 -0
- package/src/prerender/store.ts +60 -18
- package/src/prerender.ts +76 -18
- package/src/reverse.ts +11 -7
- package/src/root-error-boundary.tsx +30 -26
- package/src/route-definition/dsl-helpers.ts +9 -6
- package/src/route-definition/index.ts +0 -3
- package/src/route-definition/redirect.ts +15 -3
- package/src/route-map-builder.ts +38 -2
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +7 -0
- package/src/router/content-negotiation.ts +1 -1
- package/src/router/debug-manifest.ts +16 -3
- package/src/router/handler-context.ts +96 -17
- package/src/router/intercept-resolution.ts +6 -4
- package/src/router/lazy-includes.ts +4 -0
- package/src/router/loader-resolution.ts +6 -11
- package/src/router/logging.ts +100 -3
- package/src/router/manifest.ts +32 -3
- package/src/router/match-api.ts +62 -54
- package/src/router/match-context.ts +3 -0
- package/src/router/match-handlers.ts +185 -11
- package/src/router/match-middleware/background-revalidation.ts +65 -85
- package/src/router/match-middleware/cache-lookup.ts +78 -10
- package/src/router/match-middleware/cache-store.ts +2 -0
- package/src/router/match-pipelines.ts +8 -43
- package/src/router/match-result.ts +0 -9
- package/src/router/metrics.ts +233 -13
- package/src/router/middleware-types.ts +34 -39
- package/src/router/middleware.ts +290 -130
- package/src/router/pattern-matching.ts +61 -10
- package/src/router/prerender-match.ts +36 -6
- package/src/router/preview-match.ts +7 -1
- package/src/router/revalidation.ts +61 -2
- package/src/router/router-context.ts +15 -0
- package/src/router/router-interfaces.ts +158 -40
- package/src/router/router-options.ts +223 -1
- package/src/router/router-registry.ts +5 -2
- package/src/router/segment-resolution/fresh.ts +165 -242
- package/src/router/segment-resolution/helpers.ts +263 -0
- package/src/router/segment-resolution/loader-cache.ts +102 -98
- package/src/router/segment-resolution/revalidation.ts +394 -272
- package/src/router/segment-resolution/static-store.ts +2 -2
- package/src/router/segment-resolution.ts +1 -3
- package/src/router/segment-wrappers.ts +3 -0
- package/src/router/telemetry-otel.ts +299 -0
- package/src/router/telemetry.ts +300 -0
- package/src/router/timeout.ts +148 -0
- package/src/router/trie-matching.ts +20 -2
- package/src/router/types.ts +7 -1
- package/src/router.ts +203 -18
- package/src/rsc/handler-context.ts +13 -2
- package/src/rsc/handler.ts +489 -438
- package/src/rsc/helpers.ts +125 -5
- package/src/rsc/index.ts +0 -20
- package/src/rsc/loader-fetch.ts +84 -42
- package/src/rsc/manifest-init.ts +3 -2
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +245 -19
- package/src/rsc/response-route-handler.ts +347 -0
- package/src/rsc/rsc-rendering.ts +47 -43
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +166 -66
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +20 -2
- package/src/search-params.ts +38 -23
- package/src/server/context.ts +61 -7
- package/src/server/cookie-store.ts +190 -0
- package/src/server/fetchable-loader-store.ts +11 -6
- package/src/server/handle-store.ts +84 -12
- package/src/server/loader-registry.ts +11 -46
- package/src/server/request-context.ts +275 -49
- package/src/server.ts +6 -0
- package/src/ssr/index.tsx +67 -28
- package/src/static-handler.ts +7 -0
- package/src/theme/ThemeProvider.tsx +6 -1
- package/src/theme/index.ts +4 -18
- package/src/theme/theme-context.ts +1 -28
- package/src/theme/theme-script.ts +2 -1
- package/src/types/cache-types.ts +6 -1
- package/src/types/error-types.ts +3 -0
- package/src/types/global-namespace.ts +22 -0
- package/src/types/handler-context.ts +103 -16
- package/src/types/index.ts +1 -1
- package/src/types/loader-types.ts +9 -6
- package/src/types/route-config.ts +17 -26
- package/src/types/route-entry.ts +28 -0
- package/src/types/segments.ts +0 -5
- package/src/urls/include-helper.ts +49 -8
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper-types.ts +30 -12
- package/src/urls/path-helper.ts +17 -2
- package/src/urls/pattern-types.ts +21 -1
- package/src/urls/response-types.ts +29 -7
- package/src/urls/type-extraction.ts +23 -15
- package/src/use-loader.tsx +27 -9
- package/src/vite/discovery/bundle-postprocess.ts +32 -52
- package/src/vite/discovery/discover-routers.ts +52 -26
- package/src/vite/discovery/prerender-collection.ts +58 -41
- package/src/vite/discovery/route-types-writer.ts +7 -7
- package/src/vite/discovery/state.ts +7 -7
- package/src/vite/discovery/virtual-module-codegen.ts +5 -2
- package/src/vite/index.ts +10 -51
- package/src/vite/plugins/client-ref-dedup.ts +115 -0
- package/src/vite/plugins/client-ref-hashing.ts +3 -3
- package/src/vite/plugins/expose-internal-ids.ts +4 -3
- package/src/vite/plugins/refresh-cmd.ts +65 -0
- package/src/vite/plugins/use-cache-transform.ts +91 -3
- package/src/vite/plugins/version-plugin.ts +188 -18
- package/src/vite/rango.ts +61 -36
- package/src/vite/router-discovery.ts +173 -100
- package/src/vite/utils/prerender-utils.ts +81 -0
- package/src/vite/utils/shared-utils.ts +19 -9
- package/skills/testing/SKILL.md +0 -226
- package/src/browser/lru-cache.ts +0 -61
- package/src/browser/react/prefetch.ts +0 -27
- package/src/browser/request-controller.ts +0 -164
- package/src/cache/memory-store.ts +0 -253
- package/src/href-context.ts +0 -33
- package/src/route-definition/route-function.ts +0 -119
- package/src/router.gen.ts +0 -6
- package/src/static-handler.gen.ts +0 -5
- package/src/urls.gen.ts +0 -8
- /package/{CLAUDE.md → AGENTS.md} +0 -0
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
isBrowserDebugEnabled,
|
|
12
12
|
startBrowserTransaction,
|
|
13
13
|
} from "./logging.js";
|
|
14
|
+
import { getRangoState } from "./rango-state.js";
|
|
15
|
+
import {
|
|
16
|
+
extractRscHeaderUrl,
|
|
17
|
+
emptyResponse,
|
|
18
|
+
teeWithCompletion,
|
|
19
|
+
} from "./response-adapter.js";
|
|
14
20
|
|
|
15
21
|
/**
|
|
16
22
|
* Create a navigation client for fetching RSC payloads
|
|
@@ -84,7 +90,6 @@ export function createNavigationClient(
|
|
|
84
90
|
if (version) {
|
|
85
91
|
fetchUrl.searchParams.set("_rsc_v", version);
|
|
86
92
|
}
|
|
87
|
-
|
|
88
93
|
if (tx) {
|
|
89
94
|
browserDebugLog(tx, "fetching", {
|
|
90
95
|
path: `${fetchUrl.pathname}${fetchUrl.search}`,
|
|
@@ -101,6 +106,7 @@ export function createNavigationClient(
|
|
|
101
106
|
const responsePromise = fetch(fetchUrl, {
|
|
102
107
|
headers: {
|
|
103
108
|
"X-RSC-Router-Client-Path": previousUrl,
|
|
109
|
+
"X-Rango-State": getRangoState(),
|
|
104
110
|
...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
|
|
105
111
|
...(interceptSourceUrl && {
|
|
106
112
|
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
@@ -110,25 +116,18 @@ export function createNavigationClient(
|
|
|
110
116
|
signal,
|
|
111
117
|
}).then((response) => {
|
|
112
118
|
// Check for version mismatch - server wants us to reload
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
throw new Error(
|
|
120
|
-
`X-RSC-Reload blocked: origin mismatch (${target.origin})`,
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
} catch (e) {
|
|
124
|
-
console.error("[rango]", e);
|
|
125
|
-
return response;
|
|
126
|
-
}
|
|
119
|
+
const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
|
|
120
|
+
if (reload === "blocked") {
|
|
121
|
+
resolveStreamComplete();
|
|
122
|
+
return emptyResponse();
|
|
123
|
+
}
|
|
124
|
+
if (reload) {
|
|
127
125
|
if (tx) {
|
|
128
|
-
browserDebugLog(tx, "version mismatch, reloading", {
|
|
126
|
+
browserDebugLog(tx, "version mismatch, reloading", {
|
|
127
|
+
reloadUrl: reload.url,
|
|
128
|
+
});
|
|
129
129
|
}
|
|
130
|
-
window.location.href =
|
|
131
|
-
// Return a never-resolving promise to prevent further processing
|
|
130
|
+
window.location.href = reload.url;
|
|
132
131
|
return new Promise<Response>(() => {});
|
|
133
132
|
}
|
|
134
133
|
|
|
@@ -136,56 +135,29 @@ export function createNavigationClient(
|
|
|
136
135
|
// X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
|
|
137
136
|
// to a URL rendering full HTML). Throw ServerRedirect so the
|
|
138
137
|
// navigation bridge catches it and re-navigates with _skipCache.
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
if (tx) {
|
|
142
|
-
browserDebugLog(tx, "server redirect", { redirectUrl });
|
|
143
|
-
}
|
|
138
|
+
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
139
|
+
if (redirect === "blocked") {
|
|
144
140
|
resolveStreamComplete();
|
|
145
|
-
|
|
141
|
+
return emptyResponse();
|
|
146
142
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
143
|
+
if (redirect) {
|
|
144
|
+
if (tx) {
|
|
145
|
+
browserDebugLog(tx, "server redirect", {
|
|
146
|
+
redirectUrl: redirect.url,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
150
149
|
resolveStreamComplete();
|
|
151
|
-
|
|
150
|
+
throw new ServerRedirect(redirect.url, undefined);
|
|
152
151
|
}
|
|
153
152
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
(async () => {
|
|
159
|
-
const reader = trackingStream.getReader();
|
|
160
|
-
|
|
161
|
-
// Cancel tracking if navigation is aborted
|
|
162
|
-
const onAbort = reader.cancel.bind(reader);
|
|
163
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
while (true) {
|
|
167
|
-
const { done } = await reader.read();
|
|
168
|
-
if (done) break;
|
|
169
|
-
}
|
|
170
|
-
} finally {
|
|
171
|
-
signal?.removeEventListener("abort", onAbort);
|
|
172
|
-
reader.releaseLock();
|
|
173
|
-
if (tx) {
|
|
174
|
-
browserDebugLog(tx, "stream complete");
|
|
175
|
-
}
|
|
153
|
+
return teeWithCompletion(
|
|
154
|
+
response,
|
|
155
|
+
() => {
|
|
156
|
+
if (tx) browserDebugLog(tx, "stream complete");
|
|
176
157
|
resolveStreamComplete();
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
resolveStreamComplete();
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// Return response with the RSC stream
|
|
184
|
-
return new Response(rscStream, {
|
|
185
|
-
headers: response.headers,
|
|
186
|
-
status: response.status,
|
|
187
|
-
statusText: response.statusText,
|
|
188
|
-
});
|
|
158
|
+
},
|
|
159
|
+
signal,
|
|
160
|
+
);
|
|
189
161
|
});
|
|
190
162
|
|
|
191
163
|
try {
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
ActionStateListener,
|
|
13
13
|
HandleData,
|
|
14
14
|
} from "./types.js";
|
|
15
|
+
import { clearPrefetchCache } from "./prefetch/cache.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Default action state (idle with no payload)
|
|
@@ -320,6 +321,7 @@ export function createNavigationStore(
|
|
|
320
321
|
*/
|
|
321
322
|
function clearCacheInternal(): void {
|
|
322
323
|
historyCache.length = 0;
|
|
324
|
+
clearPrefetchCache();
|
|
323
325
|
}
|
|
324
326
|
|
|
325
327
|
/**
|
|
@@ -329,13 +331,13 @@ export function createNavigationStore(
|
|
|
329
331
|
for (let i = 0; i < historyCache.length; i++) {
|
|
330
332
|
historyCache[i][2] = true;
|
|
331
333
|
}
|
|
334
|
+
clearPrefetchCache();
|
|
332
335
|
}
|
|
333
336
|
|
|
334
337
|
/**
|
|
335
338
|
* Clear the history cache and broadcast to other tabs
|
|
336
339
|
*/
|
|
337
340
|
function clearCacheAndBroadcast(): void {
|
|
338
|
-
console.log("[Browser] Clearing cache and broadcasting to other tabs");
|
|
339
341
|
clearCacheInternal();
|
|
340
342
|
broadcastInvalidation();
|
|
341
343
|
}
|
|
@@ -344,9 +346,6 @@ export function createNavigationStore(
|
|
|
344
346
|
* Mark cache as stale and broadcast to other tabs
|
|
345
347
|
*/
|
|
346
348
|
function markStaleAndBroadcast(): void {
|
|
347
|
-
console.log(
|
|
348
|
-
"[Browser] Marking cache as stale and broadcasting to other tabs",
|
|
349
|
-
);
|
|
350
349
|
markCacheAsStaleInternal();
|
|
351
350
|
broadcastInvalidation();
|
|
352
351
|
}
|
|
@@ -369,14 +368,6 @@ export function createNavigationStore(
|
|
|
369
368
|
path: currentPath,
|
|
370
369
|
segmentIds: currentSegmentIds,
|
|
371
370
|
});
|
|
372
|
-
console.log(
|
|
373
|
-
"[Browser] Broadcast sent for path:",
|
|
374
|
-
currentPath,
|
|
375
|
-
"segments:",
|
|
376
|
-
currentSegmentIds.join(", "),
|
|
377
|
-
);
|
|
378
|
-
} else {
|
|
379
|
-
console.warn("[Browser] No BroadcastChannel available");
|
|
380
371
|
}
|
|
381
372
|
}
|
|
382
373
|
|
|
@@ -401,34 +392,21 @@ export function createNavigationStore(
|
|
|
401
392
|
return;
|
|
402
393
|
}
|
|
403
394
|
|
|
404
|
-
console.log(
|
|
405
|
-
"[Browser] Cache marked stale by another tab, shared segments:",
|
|
406
|
-
mutatedSegmentIds
|
|
407
|
-
.filter((id) => currentSegmentIds.includes(id))
|
|
408
|
-
.join(", "),
|
|
409
|
-
);
|
|
410
395
|
markCacheAsStaleInternal();
|
|
411
396
|
|
|
412
397
|
// Auto-refresh if enabled and callback is registered
|
|
413
398
|
if (crossTabAutoRefresh && crossTabRefreshCallback) {
|
|
414
399
|
// If idle, refresh immediately. If loading, wait for idle then refresh.
|
|
415
400
|
if (navState.state === "idle") {
|
|
416
|
-
console.log("[Browser] Cross-tab refresh triggered (idle)");
|
|
417
401
|
crossTabRefreshCallback();
|
|
418
402
|
} else if (!pendingCrossTabRefresh) {
|
|
419
403
|
// Only queue one refresh, ignore subsequent events while loading
|
|
420
404
|
pendingCrossTabRefresh = true;
|
|
421
|
-
console.log(
|
|
422
|
-
"[Browser] Navigation in progress, deferring cross-tab refresh",
|
|
423
|
-
);
|
|
424
405
|
// Subscribe to state changes, refresh when idle
|
|
425
406
|
const listener: StateListener = () => {
|
|
426
407
|
if (navState.state === "idle") {
|
|
427
408
|
stateListeners.delete(listener);
|
|
428
409
|
pendingCrossTabRefresh = false;
|
|
429
|
-
console.log(
|
|
430
|
-
"[Browser] Cross-tab refresh triggered (deferred)",
|
|
431
|
-
);
|
|
432
410
|
crossTabRefreshCallback?.();
|
|
433
411
|
}
|
|
434
412
|
};
|
|
@@ -652,14 +630,7 @@ export function createNavigationStore(
|
|
|
652
630
|
* Called after server actions to indicate data may be outdated
|
|
653
631
|
*/
|
|
654
632
|
markCacheAsStale(): void {
|
|
655
|
-
|
|
656
|
-
historyCache[i][2] = true;
|
|
657
|
-
}
|
|
658
|
-
console.log(
|
|
659
|
-
"[Browser] Marked",
|
|
660
|
-
historyCache.length,
|
|
661
|
-
"cache entries as stale",
|
|
662
|
-
);
|
|
633
|
+
markCacheAsStaleInternal();
|
|
663
634
|
},
|
|
664
635
|
|
|
665
636
|
/**
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NavigateOptions,
|
|
3
|
+
NavigationStore,
|
|
4
|
+
ResolvedSegment,
|
|
5
|
+
StreamingToken,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
import { generateHistoryKey } from "./navigation-store.js";
|
|
8
|
+
import {
|
|
9
|
+
handleNavigationStart,
|
|
10
|
+
handleNavigationEnd,
|
|
11
|
+
ensureHistoryKey,
|
|
12
|
+
} from "./scroll-restoration.js";
|
|
13
|
+
import type { EventController, NavigationHandle } from "./event-controller.js";
|
|
14
|
+
import { debugLog } from "./logging.js";
|
|
15
|
+
import { buildHistoryState } from "./history-state.js";
|
|
16
|
+
|
|
17
|
+
// Re-export for consumers that import from navigation-transaction
|
|
18
|
+
export { resolveNavigationState } from "./history-state.js";
|
|
19
|
+
|
|
20
|
+
/** Check if a history state object contains location state keys. */
|
|
21
|
+
function hasLocationState(state: unknown): boolean {
|
|
22
|
+
if (!state || typeof state !== "object") return false;
|
|
23
|
+
return (
|
|
24
|
+
"state" in state ||
|
|
25
|
+
Object.keys(state).some((k) => k.startsWith("__rsc_ls_"))
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Polyfill Symbol.dispose for Safari and older browsers
|
|
30
|
+
if (typeof Symbol.dispose === "undefined") {
|
|
31
|
+
(Symbol as any).dispose = Symbol("Symbol.dispose");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Options for committing a navigation transaction
|
|
36
|
+
*/
|
|
37
|
+
interface CommitOptions {
|
|
38
|
+
url: string;
|
|
39
|
+
segmentIds: string[];
|
|
40
|
+
segments: ResolvedSegment[];
|
|
41
|
+
replace?: boolean;
|
|
42
|
+
scroll?: boolean;
|
|
43
|
+
/** User-provided state to store in history.state */
|
|
44
|
+
state?: unknown;
|
|
45
|
+
/** If true, only update store without changing URL/history (for server actions) */
|
|
46
|
+
storeOnly?: boolean;
|
|
47
|
+
/** If true, this is an intercept route - store in history.state for popstate handling */
|
|
48
|
+
intercept?: boolean;
|
|
49
|
+
/** Source URL where the intercept was triggered from (stored in history.state) */
|
|
50
|
+
interceptSourceUrl?: string;
|
|
51
|
+
/** If true, only update cache without touching store or history (for background stale revalidation) */
|
|
52
|
+
cacheOnly?: boolean;
|
|
53
|
+
/** Server-set location state to merge into history.pushState */
|
|
54
|
+
serverState?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Options that can override the pre-configured commit settings
|
|
59
|
+
*/
|
|
60
|
+
interface BoundCommitOverrides {
|
|
61
|
+
/** Override scroll behavior (e.g., disable for intercepts) */
|
|
62
|
+
scroll?: boolean;
|
|
63
|
+
/** Override replace behavior (e.g., force replace for intercepts) */
|
|
64
|
+
replace?: boolean;
|
|
65
|
+
/** Override user-provided state */
|
|
66
|
+
state?: unknown;
|
|
67
|
+
/** Mark this as an intercept route */
|
|
68
|
+
intercept?: boolean;
|
|
69
|
+
/** Source URL where intercept was triggered from */
|
|
70
|
+
interceptSourceUrl?: string;
|
|
71
|
+
/** If true, only update cache (for stale revalidation) */
|
|
72
|
+
cacheOnly?: boolean;
|
|
73
|
+
/** Server-set location state to merge into history.pushState */
|
|
74
|
+
serverState?: Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Bound transaction with pre-configured commit options (without segmentIds/segments)
|
|
79
|
+
*/
|
|
80
|
+
export interface BoundTransaction {
|
|
81
|
+
readonly currentUrl: string;
|
|
82
|
+
/** Start streaming and get a token to end it when the stream completes */
|
|
83
|
+
startStreaming(): StreamingToken;
|
|
84
|
+
commit(
|
|
85
|
+
segmentIds: string[],
|
|
86
|
+
segments: ResolvedSegment[],
|
|
87
|
+
overrides?: BoundCommitOverrides,
|
|
88
|
+
): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Navigation transaction for managing state during navigation
|
|
93
|
+
* Uses the event controller handle for lifecycle management
|
|
94
|
+
*/
|
|
95
|
+
interface NavigationTransaction extends Disposable {
|
|
96
|
+
commit(options: CommitOptions): void;
|
|
97
|
+
with(
|
|
98
|
+
options: Omit<CommitOptions, "segmentIds" | "segments">,
|
|
99
|
+
): BoundTransaction;
|
|
100
|
+
/** The navigation handle from the event controller */
|
|
101
|
+
handle: NavigationHandle;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Creates a navigation transaction that coordinates with the event controller.
|
|
106
|
+
* Handles loading state transitions and cleanup on completion/abort.
|
|
107
|
+
*/
|
|
108
|
+
export function createNavigationTransaction(
|
|
109
|
+
store: NavigationStore,
|
|
110
|
+
eventController: EventController,
|
|
111
|
+
url: string,
|
|
112
|
+
options?: NavigateOptions & { skipLoadingState?: boolean },
|
|
113
|
+
): NavigationTransaction {
|
|
114
|
+
let committed = false;
|
|
115
|
+
const currentUrl = window.location.href;
|
|
116
|
+
|
|
117
|
+
// Start navigation in event controller (this sets loading state)
|
|
118
|
+
const handle = eventController.startNavigation(url, options);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Commit the navigation - updates store and URL atomically
|
|
122
|
+
*/
|
|
123
|
+
function commit(opts: CommitOptions): void {
|
|
124
|
+
committed = true;
|
|
125
|
+
|
|
126
|
+
const {
|
|
127
|
+
url,
|
|
128
|
+
segmentIds,
|
|
129
|
+
segments,
|
|
130
|
+
replace,
|
|
131
|
+
scroll,
|
|
132
|
+
storeOnly,
|
|
133
|
+
intercept,
|
|
134
|
+
interceptSourceUrl,
|
|
135
|
+
cacheOnly,
|
|
136
|
+
serverState,
|
|
137
|
+
} = opts;
|
|
138
|
+
|
|
139
|
+
const parsedUrl = new URL(url, window.location.origin);
|
|
140
|
+
|
|
141
|
+
// Generate history key from URL (with intercept suffix for separate caching)
|
|
142
|
+
const historyKey = generateHistoryKey(url, { intercept });
|
|
143
|
+
|
|
144
|
+
// For cache-only commits (stale revalidation), only update cache and return
|
|
145
|
+
// Don't touch store state or history - user may have navigated elsewhere
|
|
146
|
+
if (cacheOnly) {
|
|
147
|
+
const currentHandleData = eventController.getHandleState().data;
|
|
148
|
+
store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
|
|
149
|
+
// Complete the navigation handle so currentNavigation is cleared.
|
|
150
|
+
// Without this, the entry lingers and weakens state-machine invariants.
|
|
151
|
+
handle.complete(parsedUrl);
|
|
152
|
+
debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Save current scroll position before navigating
|
|
157
|
+
handleNavigationStart();
|
|
158
|
+
|
|
159
|
+
// Update segment state atomically
|
|
160
|
+
store.setSegmentIds(segmentIds);
|
|
161
|
+
store.setCurrentUrl(url);
|
|
162
|
+
store.setPath(parsedUrl.pathname);
|
|
163
|
+
|
|
164
|
+
store.setHistoryKey(historyKey);
|
|
165
|
+
|
|
166
|
+
// Cache segments with current handleData for this history entry
|
|
167
|
+
const currentHandleData = eventController.getHandleState().data;
|
|
168
|
+
store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
|
|
169
|
+
|
|
170
|
+
// For server actions, skip URL/history updates but still complete navigation
|
|
171
|
+
if (storeOnly) {
|
|
172
|
+
debugLog("[Browser] Store updated (action)");
|
|
173
|
+
// Complete navigation to clear loading state
|
|
174
|
+
handle.complete(parsedUrl);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build history state - include user state, intercept info, and server-set state
|
|
179
|
+
const historyState = buildHistoryState(
|
|
180
|
+
opts.state,
|
|
181
|
+
{ intercept, sourceUrl: interceptSourceUrl },
|
|
182
|
+
serverState,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Snapshot old state before pushState/replaceState overwrites it.
|
|
186
|
+
// Used to detect when location state is being cleared.
|
|
187
|
+
const oldState = window.history.state;
|
|
188
|
+
|
|
189
|
+
// Update browser URL
|
|
190
|
+
if (replace) {
|
|
191
|
+
window.history.replaceState(historyState, "", url);
|
|
192
|
+
} else {
|
|
193
|
+
window.history.pushState(historyState, "", url);
|
|
194
|
+
}
|
|
195
|
+
// Ensure new history entry has a scroll restoration key
|
|
196
|
+
ensureHistoryKey();
|
|
197
|
+
|
|
198
|
+
// Notify location state hooks when either old or new state carries
|
|
199
|
+
// location state. This covers both "set new state" and "clear old state"
|
|
200
|
+
// for same-page navigations where components don't remount.
|
|
201
|
+
if (hasLocationState(oldState) || hasLocationState(historyState)) {
|
|
202
|
+
window.dispatchEvent(new Event("__rsc_locationstate"));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Complete the navigation in event controller (sets idle state, updates location)
|
|
206
|
+
handle.complete(parsedUrl);
|
|
207
|
+
|
|
208
|
+
// Handle scroll after navigation
|
|
209
|
+
handleNavigationEnd({ scroll });
|
|
210
|
+
|
|
211
|
+
debugLog(
|
|
212
|
+
"[Browser] Navigation committed, historyKey:",
|
|
213
|
+
historyKey,
|
|
214
|
+
intercept ? "(intercept)" : "",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
handle,
|
|
220
|
+
commit,
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Create a bound transaction with pre-configured URL options
|
|
224
|
+
* segmentIds and segments provided at commit time (after they're resolved)
|
|
225
|
+
*/
|
|
226
|
+
with(
|
|
227
|
+
opts: Omit<CommitOptions, "segmentIds" | "segments">,
|
|
228
|
+
): BoundTransaction {
|
|
229
|
+
return {
|
|
230
|
+
get currentUrl() {
|
|
231
|
+
return currentUrl;
|
|
232
|
+
},
|
|
233
|
+
startStreaming() {
|
|
234
|
+
return handle.startStreaming();
|
|
235
|
+
},
|
|
236
|
+
commit: (
|
|
237
|
+
segmentIds: string[],
|
|
238
|
+
segments: ResolvedSegment[],
|
|
239
|
+
overrides?: BoundCommitOverrides,
|
|
240
|
+
) => {
|
|
241
|
+
// Allow overrides to disable scroll (e.g., for intercepts)
|
|
242
|
+
const finalScroll =
|
|
243
|
+
overrides?.scroll !== undefined ? overrides.scroll : opts.scroll;
|
|
244
|
+
// Allow overrides to force replace (e.g., for intercepts)
|
|
245
|
+
const finalReplace =
|
|
246
|
+
overrides?.replace !== undefined ? overrides.replace : opts.replace;
|
|
247
|
+
// Intercept info: overrides take precedence, fallback to opts
|
|
248
|
+
const intercept =
|
|
249
|
+
overrides?.intercept !== undefined
|
|
250
|
+
? overrides.intercept
|
|
251
|
+
: opts.intercept;
|
|
252
|
+
const interceptSourceUrl =
|
|
253
|
+
overrides?.interceptSourceUrl !== undefined
|
|
254
|
+
? overrides.interceptSourceUrl
|
|
255
|
+
: opts.interceptSourceUrl;
|
|
256
|
+
// Cache-only mode: overrides take precedence, fallback to opts
|
|
257
|
+
const cacheOnly =
|
|
258
|
+
overrides?.cacheOnly !== undefined
|
|
259
|
+
? overrides.cacheOnly
|
|
260
|
+
: opts.cacheOnly;
|
|
261
|
+
// User state: overrides take precedence, fallback to opts
|
|
262
|
+
const state =
|
|
263
|
+
overrides?.state !== undefined ? overrides.state : opts.state;
|
|
264
|
+
// Server-set location state: only from overrides (set by partial-update)
|
|
265
|
+
const serverState = overrides?.serverState;
|
|
266
|
+
commit({
|
|
267
|
+
...opts,
|
|
268
|
+
segmentIds,
|
|
269
|
+
segments,
|
|
270
|
+
scroll: finalScroll,
|
|
271
|
+
replace: finalReplace,
|
|
272
|
+
state,
|
|
273
|
+
intercept,
|
|
274
|
+
interceptSourceUrl,
|
|
275
|
+
cacheOnly,
|
|
276
|
+
serverState,
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
[Symbol.dispose]() {
|
|
283
|
+
// Superseded: another navigation took over.
|
|
284
|
+
if (handle.signal.aborted) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Failed (not committed): keep the target URL -- the error UI owns it.
|
|
289
|
+
// Just reset the event controller to idle.
|
|
290
|
+
if (!committed) {
|
|
291
|
+
handle[Symbol.dispose]();
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|