@rangojs/router 0.0.0-experimental.77 → 0.0.0-experimental.77ed8945
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 +120 -25
- package/dist/bin/rango.js +147 -57
- package/dist/vite/index.js +2103 -861
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +13 -8
- package/skills/api-client/SKILL.md +211 -0
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +220 -30
- package/skills/caching/SKILL.md +116 -8
- package/skills/composability/SKILL.md +27 -2
- package/skills/css/SKILL.md +76 -0
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +3 -1
- package/skills/hooks/SKILL.md +229 -20
- package/skills/host-router/SKILL.md +66 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +26 -4
- package/skills/layout/SKILL.md +6 -7
- package/skills/links/SKILL.md +247 -17
- package/skills/loader/SKILL.md +219 -9
- package/skills/middleware/SKILL.md +47 -12
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/mime-routes/SKILL.md +27 -0
- package/skills/observability/SKILL.md +137 -0
- package/skills/parallel/SKILL.md +12 -6
- package/skills/prerender/SKILL.md +14 -33
- package/skills/rango/SKILL.md +238 -22
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +122 -47
- package/skills/route/SKILL.md +33 -4
- package/skills/router-setup/SKILL.md +3 -3
- package/skills/server-actions/SKILL.md +751 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/tailwind/SKILL.md +27 -3
- package/skills/typesafety/SKILL.md +319 -27
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +294 -0
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +116 -0
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/app-shell.ts +39 -0
- package/src/browser/event-controller.ts +86 -70
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/navigation-bridge.ts +29 -9
- package/src/browser/navigation-client.ts +99 -77
- package/src/browser/navigation-store.ts +7 -8
- package/src/browser/navigation-transaction.ts +10 -28
- package/src/browser/partial-update.ts +60 -40
- package/src/browser/prefetch/cache.ts +196 -49
- package/src/browser/prefetch/fetch.ts +203 -59
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +37 -13
- package/src/browser/react/Link.tsx +18 -13
- package/src/browser/react/NavigationProvider.tsx +75 -31
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/location-state-shared.ts +175 -4
- package/src/browser/react/location-state.ts +39 -13
- package/src/browser/react/use-handle.ts +17 -9
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +20 -8
- package/src/browser/react/use-reverse.ts +106 -0
- package/src/browser/react/use-router.ts +23 -2
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/response-adapter.ts +52 -1
- package/src/browser/rsc-router.tsx +71 -22
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/browser/segment-reconciler.ts +10 -14
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +44 -30
- package/src/browser/types.ts +12 -2
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +60 -35
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/index.ts +8 -1
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +45 -1
- package/src/build/route-types/codegen.ts +4 -4
- package/src/build/route-types/include-resolution.ts +1 -1
- package/src/build/route-types/per-module-writer.ts +7 -4
- package/src/build/route-types/router-processing.ts +55 -14
- package/src/build/route-types/scan-filter.ts +1 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-runtime.ts +17 -5
- package/src/cache/cache-scope.ts +51 -49
- package/src/cache/cf/cf-cache-store.ts +502 -32
- package/src/cache/cf/index.ts +3 -0
- package/src/cache/handle-snapshot.ts +103 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/memory-segment-store.ts +3 -2
- package/src/cache/types.ts +10 -6
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +96 -205
- package/src/context-var.ts +5 -5
- package/src/decode-loader-results.ts +36 -0
- package/src/errors.ts +30 -4
- package/src/handle.ts +4 -6
- package/src/host/index.ts +2 -2
- package/src/host/router.ts +129 -57
- package/src/host/types.ts +31 -2
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +140 -21
- package/src/index.rsc.ts +10 -6
- package/src/index.ts +17 -8
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +2 -5
- package/src/loader.ts +3 -10
- package/src/missing-id-error.ts +68 -0
- package/src/outlet-context.ts +1 -1
- package/src/prerender/store.ts +9 -7
- package/src/prerender.ts +4 -4
- package/src/response-utils.ts +37 -0
- package/src/reverse.ts +65 -39
- package/src/route-content-wrapper.tsx +6 -28
- package/src/route-definition/dsl-helpers.ts +253 -265
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +43 -15
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-types.ts +26 -41
- package/src/router/content-negotiation.ts +15 -2
- package/src/router/error-handling.ts +1 -1
- package/src/router/find-match.ts +54 -6
- package/src/router/handler-context.ts +21 -41
- package/src/router/intercept-resolution.ts +4 -18
- package/src/router/lazy-includes.ts +41 -22
- package/src/router/loader-resolution.ts +82 -36
- package/src/router/manifest.ts +41 -19
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-middleware/cache-lookup.ts +57 -95
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/match-result.ts +53 -32
- package/src/router/metrics.ts +1 -1
- package/src/router/middleware-types.ts +15 -26
- package/src/router/middleware.ts +99 -84
- package/src/router/pattern-matching.ts +116 -19
- package/src/router/prerender-match.ts +40 -15
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +40 -37
- package/src/router/revalidation.ts +58 -2
- package/src/router/router-interfaces.ts +51 -35
- package/src/router/router-options.ts +25 -1
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +27 -6
- package/src/router/segment-resolution/revalidation.ts +147 -106
- package/src/router/segment-resolution/static-store.ts +19 -5
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/trie-matching.ts +40 -16
- package/src/router/types.ts +8 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +37 -25
- package/src/rsc/handler-context.ts +2 -2
- package/src/rsc/handler.ts +58 -77
- package/src/rsc/helpers.ts +72 -43
- package/src/rsc/index.ts +1 -1
- package/src/rsc/manifest-init.ts +28 -41
- package/src/rsc/origin-guard.ts +30 -10
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/response-error.ts +79 -12
- package/src/rsc/response-route-handler.ts +76 -61
- package/src/rsc/rsc-rendering.ts +45 -51
- package/src/rsc/runtime-warnings.ts +9 -10
- package/src/rsc/server-action.ts +33 -39
- package/src/rsc/ssr-setup.ts +16 -0
- package/src/rsc/types.ts +8 -2
- package/src/search-params.ts +4 -4
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +132 -116
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +175 -53
- package/src/server/cookie-store.ts +28 -4
- package/src/server/request-context.ts +57 -51
- package/src/ssr/index.tsx +5 -1
- package/src/static-handler.ts +1 -1
- package/src/types/global-namespace.ts +39 -26
- package/src/types/handler-context.ts +68 -50
- package/src/types/index.ts +1 -0
- package/src/types/loader-types.ts +11 -9
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +35 -2
- package/src/urls/include-helper.ts +34 -67
- package/src/urls/index.ts +1 -5
- package/src/urls/path-helper-types.ts +17 -3
- package/src/urls/path-helper.ts +17 -52
- package/src/urls/pattern-types.ts +36 -19
- package/src/urls/response-types.ts +22 -29
- package/src/urls/type-extraction.ts +58 -139
- package/src/urls/urls-function.ts +1 -5
- package/src/use-loader.tsx +413 -42
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +6 -6
- package/src/vite/discovery/discover-routers.ts +106 -75
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +72 -31
- package/src/vite/discovery/route-types-writer.ts +40 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/discovery/state.ts +33 -0
- package/src/vite/discovery/virtual-module-codegen.ts +13 -23
- package/src/vite/index.ts +2 -0
- package/src/vite/plugin-types.ts +67 -0
- package/src/vite/plugins/cjs-to-esm.ts +8 -7
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +28 -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-action-id.ts +54 -30
- package/src/vite/plugins/expose-id-utils.ts +12 -8
- package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
- package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +496 -486
- package/src/vite/plugins/performance-tracks.ts +29 -25
- package/src/vite/plugins/use-cache-transform.ts +65 -50
- package/src/vite/plugins/version-injector.ts +39 -23
- package/src/vite/plugins/version-plugin.ts +59 -2
- package/src/vite/plugins/virtual-entries.ts +2 -2
- package/src/vite/rango.ts +116 -29
- package/src/vite/router-discovery.ts +753 -104
- package/src/vite/utils/ast-handler-extract.ts +15 -15
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/bundle-analysis.ts +4 -2
- package/src/vite/utils/client-chunks.ts +190 -0
- package/src/vite/utils/forward-user-plugins.ts +193 -0
- package/src/vite/utils/manifest-utils.ts +8 -59
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +5 -4
- package/src/vite/utils/shared-utils.ts +107 -26
- package/src/browser/action-response-classifier.ts +0 -99
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "./scroll-restoration.js";
|
|
12
12
|
import type { EventController, NavigationHandle } from "./event-controller.js";
|
|
13
13
|
import { debugLog } from "./logging.js";
|
|
14
|
-
import { buildHistoryState } from "./history-state.js";
|
|
14
|
+
import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
|
|
15
15
|
|
|
16
16
|
// Re-export for consumers that import from navigation-transaction
|
|
17
17
|
export { resolveNavigationState } from "./history-state.js";
|
|
@@ -186,12 +186,8 @@ export function createNavigationTransaction(
|
|
|
186
186
|
// Used to detect when location state is being cleared.
|
|
187
187
|
const oldState = window.history.state;
|
|
188
188
|
|
|
189
|
-
// Update browser URL
|
|
190
|
-
|
|
191
|
-
window.history.replaceState(historyState, "", url);
|
|
192
|
-
} else {
|
|
193
|
-
window.history.pushState(historyState, "", url);
|
|
194
|
-
}
|
|
189
|
+
// Update browser URL (stamps history.state.idx for back() first-entry detection)
|
|
190
|
+
pushHistoryWithIdx(historyState, url, replace ?? false);
|
|
195
191
|
// Ensure new history entry has a scroll restoration key
|
|
196
192
|
ensureHistoryKey();
|
|
197
193
|
|
|
@@ -240,30 +236,16 @@ export function createNavigationTransaction(
|
|
|
240
236
|
segments: ResolvedSegment[],
|
|
241
237
|
overrides?: BoundCommitOverrides,
|
|
242
238
|
) => {
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
// Allow overrides to force replace (e.g., for intercepts)
|
|
247
|
-
const finalReplace =
|
|
248
|
-
overrides?.replace !== undefined ? overrides.replace : opts.replace;
|
|
249
|
-
// Intercept info: overrides take precedence, fallback to opts
|
|
250
|
-
const intercept =
|
|
251
|
-
overrides?.intercept !== undefined
|
|
252
|
-
? overrides.intercept
|
|
253
|
-
: opts.intercept;
|
|
239
|
+
const finalScroll = overrides?.scroll ?? opts.scroll;
|
|
240
|
+
const finalReplace = overrides?.replace ?? opts.replace;
|
|
241
|
+
const intercept = overrides?.intercept ?? opts.intercept;
|
|
254
242
|
const interceptSourceUrl =
|
|
255
|
-
overrides?.interceptSourceUrl
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
const cacheOnly =
|
|
260
|
-
overrides?.cacheOnly !== undefined
|
|
261
|
-
? overrides.cacheOnly
|
|
262
|
-
: opts.cacheOnly;
|
|
263
|
-
// User state: overrides take precedence, fallback to opts
|
|
243
|
+
overrides?.interceptSourceUrl ?? opts.interceptSourceUrl;
|
|
244
|
+
const cacheOnly = overrides?.cacheOnly ?? opts.cacheOnly;
|
|
245
|
+
// state is `unknown` (null is meaningful) so `??` would wrongly drop a
|
|
246
|
+
// null override; serverState always comes from overrides, never opts.
|
|
264
247
|
const state =
|
|
265
248
|
overrides?.state !== undefined ? overrides.state : opts.state;
|
|
266
|
-
// Server-set location state: only from overrides (set by partial-update)
|
|
267
249
|
const serverState = overrides?.serverState;
|
|
268
250
|
return commit({
|
|
269
251
|
...opts,
|
|
@@ -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
|
*/
|
|
@@ -76,7 +96,7 @@ export type UpdateMode =
|
|
|
76
96
|
/** Source URL for intercept restore (popstate cache miss) */
|
|
77
97
|
interceptSourceUrl?: string;
|
|
78
98
|
}
|
|
79
|
-
| { type: "leave-intercept" }
|
|
99
|
+
| { type: "leave-intercept"; interceptSourceUrl?: string }
|
|
80
100
|
| { type: "stale-revalidation"; interceptSourceUrl?: string }
|
|
81
101
|
| { type: "action"; interceptSourceUrl?: string };
|
|
82
102
|
|
|
@@ -141,13 +161,7 @@ export function createPartialUpdater(
|
|
|
141
161
|
// Capture history key at start for stale revalidation consistency check
|
|
142
162
|
const historyKeyAtStart = store.getHistoryKey();
|
|
143
163
|
|
|
144
|
-
|
|
145
|
-
const interceptSourceUrl =
|
|
146
|
-
mode.type === "stale-revalidation" ||
|
|
147
|
-
mode.type === "action" ||
|
|
148
|
-
mode.type === "navigate"
|
|
149
|
-
? mode.interceptSourceUrl
|
|
150
|
-
: undefined;
|
|
164
|
+
const interceptSourceUrl = mode.interceptSourceUrl;
|
|
151
165
|
|
|
152
166
|
// When leaving intercept, filter out intercept-specific segments
|
|
153
167
|
let segments: string[];
|
|
@@ -167,9 +181,16 @@ export function createPartialUpdater(
|
|
|
167
181
|
segments = segmentIds ?? segmentState.currentSegmentIds;
|
|
168
182
|
}
|
|
169
183
|
|
|
170
|
-
// For intercept revalidation, use the intercept source URL as previousUrl
|
|
184
|
+
// For intercept revalidation, use the intercept source URL as previousUrl.
|
|
185
|
+
// For leave-intercept, tx.currentUrl captures window.location.href at tx
|
|
186
|
+
// creation, which on popstate is already the destination URL and would
|
|
187
|
+
// tell the server "from == to". segmentState.currentUrl still points at
|
|
188
|
+
// the URL the cached segments render (the intercept URL), which is the
|
|
189
|
+
// correct "from" for the server's diff computation.
|
|
171
190
|
const previousUrl =
|
|
172
|
-
|
|
191
|
+
mode.type === "leave-intercept"
|
|
192
|
+
? segmentState.currentUrl || tx.currentUrl
|
|
193
|
+
: interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
|
|
173
194
|
|
|
174
195
|
debugLog(`\n[Browser] >>> NAVIGATION`);
|
|
175
196
|
debugLog(`[Browser] From: ${previousUrl}`);
|
|
@@ -183,13 +204,11 @@ export function createPartialUpdater(
|
|
|
183
204
|
// When navigating with targetCacheSegments, use those for consistency.
|
|
184
205
|
// Otherwise fall back to current page's segments (for same-route revalidation).
|
|
185
206
|
const targetCache =
|
|
186
|
-
mode.type === "navigate"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const cachedSegsSource =
|
|
192
|
-
targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
|
|
207
|
+
mode.type === "navigate" && mode.targetCacheSegments?.length
|
|
208
|
+
? mode.targetCacheSegments
|
|
209
|
+
: undefined;
|
|
210
|
+
const cachedSegs = targetCache ?? getCurrentCachedSegments();
|
|
211
|
+
const cachedSegsSource = targetCache ? "history-cache" : "current-page";
|
|
193
212
|
debugLog(
|
|
194
213
|
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
195
214
|
);
|
|
@@ -218,19 +237,25 @@ export function createPartialUpdater(
|
|
|
218
237
|
streamingToken.end();
|
|
219
238
|
});
|
|
220
239
|
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
240
|
+
// Integrity guard (defense in depth). The server redirects on a cross-app
|
|
241
|
+
// routerId mismatch (X-RSC-Reload), so a partial payload's routerId must
|
|
242
|
+
// match this client's. If it doesn't — a stale/edge cache keyed without the
|
|
243
|
+
// routerId, a proxy mixing app responses, or a server classification bug —
|
|
244
|
+
// do NOT splice a foreign app's segments and client references into this
|
|
245
|
+
// document. Force a full reload so the server re-establishes the
|
|
246
|
+
// authoritative document for this URL.
|
|
247
|
+
const currentRouterId = store.getRouterId?.();
|
|
248
|
+
if (
|
|
249
|
+
payload.metadata?.routerId &&
|
|
250
|
+
currentRouterId &&
|
|
251
|
+
payload.metadata.routerId !== currentRouterId
|
|
252
|
+
) {
|
|
253
|
+
console.error(
|
|
254
|
+
`[rango] Partial response router id "${payload.metadata.routerId}" does not ` +
|
|
255
|
+
`match this client ("${currentRouterId}"); discarding it and reloading to re-sync.`,
|
|
256
|
+
);
|
|
257
|
+
window.location.href = url;
|
|
258
|
+
return;
|
|
234
259
|
}
|
|
235
260
|
|
|
236
261
|
// Handle server-side redirect with state
|
|
@@ -272,7 +297,7 @@ export function createPartialUpdater(
|
|
|
272
297
|
.filter(Boolean) as ResolvedSegment[];
|
|
273
298
|
|
|
274
299
|
// When navigating with cached segments to a different route, render them.
|
|
275
|
-
if (mode.type === "navigate" && targetCache
|
|
300
|
+
if (mode.type === "navigate" && targetCache) {
|
|
276
301
|
debugLog(
|
|
277
302
|
"[Browser] No diff but navigating with cached segments - rendering target route",
|
|
278
303
|
);
|
|
@@ -312,10 +337,7 @@ export function createPartialUpdater(
|
|
|
312
337
|
scroll: toScrollPayload(commitScroll),
|
|
313
338
|
};
|
|
314
339
|
|
|
315
|
-
|
|
316
|
-
(s) => s.transition,
|
|
317
|
-
);
|
|
318
|
-
if (cachedHasTransition) {
|
|
340
|
+
if (shouldStartViewTransition(existingSegments)) {
|
|
319
341
|
startTransition(() => {
|
|
320
342
|
if (addTransitionType) {
|
|
321
343
|
addTransitionType("navigation");
|
|
@@ -501,7 +523,7 @@ export function createPartialUpdater(
|
|
|
501
523
|
|
|
502
524
|
// Emit update to trigger React render.
|
|
503
525
|
// Scroll info is included so NavigationProvider applies it after React commits.
|
|
504
|
-
const hasTransition = reconciled.
|
|
526
|
+
const hasTransition = shouldStartViewTransition(reconciled.segments);
|
|
505
527
|
const scrollPayload = toScrollPayload(navScroll);
|
|
506
528
|
|
|
507
529
|
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
@@ -563,9 +585,7 @@ export function createPartialUpdater(
|
|
|
563
585
|
})
|
|
564
586
|
: tx.commit(segmentIds, segments);
|
|
565
587
|
|
|
566
|
-
const fullHasTransition = segments
|
|
567
|
-
(s: ResolvedSegment) => s.transition,
|
|
568
|
-
);
|
|
588
|
+
const fullHasTransition = shouldStartViewTransition(segments);
|
|
569
589
|
const fullScrollPayload = toScrollPayload(fullScroll);
|
|
570
590
|
|
|
571
591
|
if (mode.type === "stale-revalidation") {
|
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Prefetch Cache
|
|
3
3
|
*
|
|
4
|
-
* In-memory cache storing
|
|
5
|
-
* on subsequent navigation.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* In-memory cache storing eagerly-decoded prefetch payloads for instant,
|
|
5
|
+
* already-warm cache hits on subsequent navigation. A prefetch fetches the
|
|
6
|
+
* RSC partial AND decodes it (createFromFetch) up front — decoding the Flight
|
|
7
|
+
* stream resolves the route's client references, so the route's JS chunks are
|
|
8
|
+
* imported during prefetch rather than on click. The decoded payload is reused
|
|
9
|
+
* verbatim by navigation, so a prefetched click loads no new code. Two key
|
|
10
|
+
* scopes are in play:
|
|
11
|
+
* - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
|
|
12
|
+
* shape `rangoState\0/target?...`. Shared across all source pages and
|
|
13
|
+
* invalidated automatically when Rango state bumps (deploy or
|
|
14
|
+
* server-action invalidation).
|
|
15
|
+
* - Source-scoped: built by `buildSourceKey(rangoState, sourceHref, target)`
|
|
16
|
+
* — shape `rangoState\0sourceHref\0/target?...`. Embeds the Rango state
|
|
17
|
+
* (so rotation invalidates source-scoped entries too) plus the source
|
|
18
|
+
* href (so each originating page gets its own slot). Populated when the
|
|
19
|
+
* server tags a response with `X-RSC-Prefetch-Scope: source` (intercept
|
|
20
|
+
* modals etc.), OR when a Link opts in with `prefetchKey=":source"` — in
|
|
21
|
+
* both cases so source-sensitive responses cannot bleed into navigations
|
|
22
|
+
* from other pages.
|
|
8
23
|
*
|
|
9
24
|
* Also tracks in-flight prefetch promises. Each promise resolves to the
|
|
10
|
-
*
|
|
11
|
-
* still-downloading prefetch without
|
|
25
|
+
* decoded prefetch entry (or null), letting navigation adopt a
|
|
26
|
+
* still-downloading prefetch without issuing a duplicate request. A
|
|
27
|
+
* single promise can be registered under multiple alias keys (see
|
|
28
|
+
* `setInflightPromiseWithAliases`) so same-source navigations adopt via
|
|
29
|
+
* their source key while cross-source ones fall through to the wildcard
|
|
30
|
+
* alias — with consume/clear atomically removing every alias.
|
|
12
31
|
*
|
|
13
32
|
* Replaces the previous browser HTTP cache approach which was unreliable
|
|
14
33
|
* due to response draining race conditions and browser inconsistencies.
|
|
@@ -16,6 +35,31 @@
|
|
|
16
35
|
|
|
17
36
|
import { abortAllPrefetches } from "./queue.js";
|
|
18
37
|
import { invalidateRangoState } from "../rango-state.js";
|
|
38
|
+
import type { RscPayload } from "../types.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A prefetch that has been fetched AND eagerly decoded. Storing the decoded
|
|
42
|
+
* payload (not the raw Response) is what makes a prefetched navigation "warm":
|
|
43
|
+
* decoding the Flight stream during prefetch pulls the route's client chunks,
|
|
44
|
+
* so the click reuses ready elements and loads no new JS.
|
|
45
|
+
*/
|
|
46
|
+
export interface DecodedPrefetch {
|
|
47
|
+
/** The eagerly-decoded RSC payload. Reused verbatim by navigation. */
|
|
48
|
+
payload: Promise<RscPayload>;
|
|
49
|
+
/**
|
|
50
|
+
* Resolves when the underlying RSC stream finishes draining. Navigation
|
|
51
|
+
* forwards this as its streamComplete so scroll/revalidation gating is
|
|
52
|
+
* unchanged from the fresh-fetch path.
|
|
53
|
+
*/
|
|
54
|
+
streamComplete: Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Prefetch scope as tagged by the server via `X-RSC-Prefetch-Scope`.
|
|
57
|
+
* `"source"` means the response is source-page-sensitive and must not be
|
|
58
|
+
* reused by a navigation from a different page — navigation enforces this
|
|
59
|
+
* when it adopted an inflight entry through the wildcard key.
|
|
60
|
+
*/
|
|
61
|
+
scope: "source" | "wildcard";
|
|
62
|
+
}
|
|
19
63
|
|
|
20
64
|
// Default TTL: 5 minutes. Overridden by initPrefetchCache() with
|
|
21
65
|
// the server-configured prefetchCacheTTL from router options.
|
|
@@ -41,7 +85,7 @@ export function isPrefetchCacheDisabled(): boolean {
|
|
|
41
85
|
const MAX_PREFETCH_CACHE_SIZE = 50;
|
|
42
86
|
|
|
43
87
|
interface PrefetchCacheEntry {
|
|
44
|
-
|
|
88
|
+
entry: DecodedPrefetch;
|
|
45
89
|
timestamp: number;
|
|
46
90
|
}
|
|
47
91
|
|
|
@@ -49,11 +93,36 @@ const cache = new Map<string, PrefetchCacheEntry>();
|
|
|
49
93
|
const inflight = new Set<string>();
|
|
50
94
|
|
|
51
95
|
/**
|
|
52
|
-
* In-flight promise map. When a prefetch fetch is in progress, its
|
|
53
|
-
* Promise<
|
|
54
|
-
*
|
|
96
|
+
* In-flight promise map. When a prefetch fetch+decode is in progress, its
|
|
97
|
+
* Promise<DecodedPrefetch | null> is stored here so navigation can await it
|
|
98
|
+
* instead of starting a duplicate request. Resolves to null when the prefetch
|
|
99
|
+
* failed, was aborted, or carried a control header (reload/redirect) that the
|
|
100
|
+
* navigation must re-fetch to act on.
|
|
101
|
+
*/
|
|
102
|
+
const inflightPromises = new Map<string, Promise<DecodedPrefetch | null>>();
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Alias map for in-flight promises registered under multiple keys (see
|
|
106
|
+
* dual inflight in prefetch/fetch.ts). Records each key's sibling set so
|
|
107
|
+
* that consuming or clearing any one key atomically removes every alias —
|
|
108
|
+
* guaranteeing a single consumer for the shared decode.
|
|
55
109
|
*/
|
|
56
|
-
const
|
|
110
|
+
const inflightAliases = new Map<string, string[]>();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Keys whose in-flight prefetch promise was adopted by a navigation (via
|
|
114
|
+
* `consumeInflightPrefetch`). A `DecodedPrefetch` carries a single-use
|
|
115
|
+
* `metadata.handles` async generator; the adopter drains it. The same entry is
|
|
116
|
+
* also published to the `cache` map by `storePrefetch` when the fetch resolves
|
|
117
|
+
* — which runs AFTER adoption (adoption only succeeds while the fetch is still
|
|
118
|
+
* in flight, so the entry is not yet cached). Without this guard the adopted,
|
|
119
|
+
* now-drained entry would be left in the cache and served to a later navigation
|
|
120
|
+
* whose handle generator yields nothing, silently dropping that route's
|
|
121
|
+
* breadcrumbs. Recording the adopted keys lets `storePrefetch` skip publishing
|
|
122
|
+
* them, keeping the existing one-time-consumption contract (a consumed prefetch
|
|
123
|
+
* is gone; the next navigation re-fetches).
|
|
124
|
+
*/
|
|
125
|
+
const adoptedKeys = new Set<string>();
|
|
57
126
|
|
|
58
127
|
// Generation counter incremented on each clearPrefetchCache(). Fetches that
|
|
59
128
|
// started before a clear carry a stale generation and must not store their
|
|
@@ -61,23 +130,57 @@ const inflightPromises = new Map<string, Promise<Response | null>>();
|
|
|
61
130
|
let generation = 0;
|
|
62
131
|
|
|
63
132
|
/**
|
|
64
|
-
* Build a cache key
|
|
133
|
+
* Build a cache key by combining a scope prefix with the target URL.
|
|
134
|
+
*
|
|
135
|
+
* Low-level primitive — callers that want a specific scope should use
|
|
136
|
+
* one of:
|
|
137
|
+
* - Wildcard (source-agnostic): prefix is the Rango state value from
|
|
138
|
+
* `getRangoState()`. Shared across all source pages. Invalidated
|
|
139
|
+
* automatically when Rango state bumps (deploy or server-action).
|
|
140
|
+
* Key shape: `rangoState\0/target?...`.
|
|
141
|
+
* - Source-scoped: use `buildSourceKey()`. Key shape:
|
|
142
|
+
* `rangoState\0sourceHref\0/target?...` — embeds the Rango state so
|
|
143
|
+
* rotation invalidates source-scoped entries alongside wildcard ones,
|
|
144
|
+
* plus the source page href so the key is unique per originating page.
|
|
145
|
+
* Populated either when the server tags a response with
|
|
146
|
+
* `X-RSC-Prefetch-Scope: source` (intercept modals, etc.) or when a
|
|
147
|
+
* Link opts in via `prefetchKey=":source"`.
|
|
65
148
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
149
|
+
* The `_rsc_segments` query param that travels in the target URL means
|
|
150
|
+
* clients with different mounted segment trees naturally get different
|
|
151
|
+
* keys — so segment-level diffs remain consistent across both scopes.
|
|
152
|
+
*/
|
|
153
|
+
export function buildPrefetchKey(prefix: string, targetUrl: URL): string {
|
|
154
|
+
return prefix + "\0" + targetUrl.pathname + targetUrl.search;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build a source-scoped cache key. Key shape:
|
|
159
|
+
* `rangoState\0sourceHref\0/target?...`.
|
|
69
160
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
161
|
+
* - `rangoState` is included so state rotation invalidates source-scoped
|
|
162
|
+
* entries alongside wildcard ones.
|
|
163
|
+
* - `sourceHref` makes the key unique per originating page.
|
|
73
164
|
*/
|
|
74
|
-
export function
|
|
165
|
+
export function buildSourceKey(
|
|
166
|
+
rangoState: string,
|
|
75
167
|
sourceHref: string,
|
|
76
168
|
targetUrl: URL,
|
|
77
|
-
prefetchKey?: string | ((from: string) => string),
|
|
78
169
|
): string {
|
|
79
|
-
|
|
80
|
-
|
|
170
|
+
return buildPrefetchKey(rangoState + "\0" + sourceHref, targetUrl);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Walk an inflight key plus any sibling aliases registered via
|
|
175
|
+
* `setInflightPromiseWithAliases`, invoking `fn` for each.
|
|
176
|
+
*/
|
|
177
|
+
function forEachAlias(key: string, fn: (k: string) => void): void {
|
|
178
|
+
const aliases = inflightAliases.get(key);
|
|
179
|
+
if (aliases) {
|
|
180
|
+
for (const k of aliases) fn(k);
|
|
181
|
+
} else {
|
|
182
|
+
fn(key);
|
|
183
|
+
}
|
|
81
184
|
}
|
|
82
185
|
|
|
83
186
|
/**
|
|
@@ -96,14 +199,14 @@ export function hasPrefetch(key: string): boolean {
|
|
|
96
199
|
}
|
|
97
200
|
|
|
98
201
|
/**
|
|
99
|
-
* Consume a cached prefetch
|
|
100
|
-
* One-time consumption: the entry is deleted after retrieval.
|
|
202
|
+
* Consume a cached, eagerly-decoded prefetch. Returns null if not found or
|
|
203
|
+
* expired. One-time consumption: the entry is deleted after retrieval.
|
|
101
204
|
* Returns null when caching is disabled (TTL <= 0).
|
|
102
205
|
*
|
|
103
206
|
* Does NOT check in-flight prefetches — use consumeInflightPrefetch()
|
|
104
|
-
* for that (returns a Promise instead of a
|
|
207
|
+
* for that (returns a Promise instead of a resolved entry).
|
|
105
208
|
*/
|
|
106
|
-
export function consumePrefetch(key: string):
|
|
209
|
+
export function consumePrefetch(key: string): DecodedPrefetch | null {
|
|
107
210
|
if (cacheTTL <= 0) return null;
|
|
108
211
|
const entry = cache.get(key);
|
|
109
212
|
if (!entry) return null;
|
|
@@ -112,52 +215,72 @@ export function consumePrefetch(key: string): Response | null {
|
|
|
112
215
|
return null;
|
|
113
216
|
}
|
|
114
217
|
cache.delete(key);
|
|
115
|
-
return entry.
|
|
218
|
+
return entry.entry;
|
|
116
219
|
}
|
|
117
220
|
|
|
118
221
|
/**
|
|
119
222
|
* Consume an in-flight prefetch promise. Returns null if no prefetch is
|
|
120
|
-
* in-flight for this key. The returned Promise resolves to the
|
|
121
|
-
*
|
|
223
|
+
* in-flight for this key. The returned Promise resolves to the decoded
|
|
224
|
+
* prefetch entry (or null if the fetch failed/was aborted, or carried a
|
|
225
|
+
* control header the navigation must re-fetch to honor).
|
|
122
226
|
*
|
|
123
|
-
* One-time consumption: the promise entry is removed
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
227
|
+
* One-time consumption: the promise entry is removed (along with any
|
|
228
|
+
* sibling aliases registered via `setInflightPromiseWithAliases`) so a
|
|
229
|
+
* second call on any alias returns null — only one caller can adopt the
|
|
230
|
+
* shared Response stream. The `inflight` set entry is intentionally
|
|
231
|
+
* kept so that `hasPrefetch()` continues to return true while the
|
|
232
|
+
* underlying fetch is still downloading — this prevents
|
|
233
|
+
* `prefetchDirect()` or other callers from starting a duplicate request
|
|
234
|
+
* during the handoff window. The inflight flag is cleaned up naturally
|
|
235
|
+
* by `clearPrefetchInflight()` in the fetch's `.finally()`.
|
|
130
236
|
*/
|
|
131
237
|
export function consumeInflightPrefetch(
|
|
132
238
|
key: string,
|
|
133
|
-
): Promise<
|
|
239
|
+
): Promise<DecodedPrefetch | null> | null {
|
|
134
240
|
const promise = inflightPromises.get(key);
|
|
135
241
|
if (!promise) return null;
|
|
136
|
-
// Remove the promise
|
|
137
|
-
|
|
242
|
+
// Remove the promise under every alias so a second consumer cannot
|
|
243
|
+
// adopt the same stream and race on the body, and mark every alias as
|
|
244
|
+
// adopted so the pending `storePrefetch` (which resolves later, after this
|
|
245
|
+
// adoption) does not leave the now-owned, single-use entry in the cache map.
|
|
246
|
+
// `inflightAliases` is intentionally preserved — `clearPrefetchInflight()` in
|
|
247
|
+
// the fetch's `.finally()` still needs it to clear every inflight flag and
|
|
248
|
+
// adopted marker; deleting here would strand the sibling's flag forever.
|
|
249
|
+
forEachAlias(key, (k) => {
|
|
250
|
+
inflightPromises.delete(k);
|
|
251
|
+
adoptedKeys.add(k);
|
|
252
|
+
});
|
|
138
253
|
return promise;
|
|
139
254
|
}
|
|
140
255
|
|
|
141
256
|
/**
|
|
142
|
-
* Store
|
|
143
|
-
* The response should be a clone() of the original so the caller can
|
|
144
|
-
* still consume the body. The clone's body streams independently.
|
|
257
|
+
* Store an eagerly-decoded prefetch in the in-memory cache.
|
|
145
258
|
*
|
|
146
259
|
* Skips storage if the generation has changed since the fetch started
|
|
147
260
|
* (a server action invalidated the cache mid-flight).
|
|
148
261
|
*/
|
|
149
262
|
export function storePrefetch(
|
|
150
263
|
key: string,
|
|
151
|
-
|
|
264
|
+
entry: DecodedPrefetch,
|
|
152
265
|
fetchGeneration: number,
|
|
153
266
|
): void {
|
|
154
267
|
if (cacheTTL <= 0) return;
|
|
155
268
|
if (fetchGeneration !== generation) return;
|
|
156
269
|
|
|
270
|
+
// If a navigation already adopted this prefetch's in-flight promise, it owns
|
|
271
|
+
// the single-use entry (and has drained its handle generator). Do NOT also
|
|
272
|
+
// publish it to the cache map, or a later navigation would be served the
|
|
273
|
+
// exhausted entry and lose that route's handles. Clear the marker (under all
|
|
274
|
+
// aliases) now that the decision is made.
|
|
275
|
+
if (adoptedKeys.has(key)) {
|
|
276
|
+
forEachAlias(key, (k) => adoptedKeys.delete(k));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
157
280
|
// Evict expired entries
|
|
158
281
|
const now = Date.now();
|
|
159
|
-
for (const [k,
|
|
160
|
-
if (now -
|
|
282
|
+
for (const [k, cached] of cache) {
|
|
283
|
+
if (now - cached.timestamp > cacheTTL) {
|
|
161
284
|
cache.delete(k);
|
|
162
285
|
}
|
|
163
286
|
}
|
|
@@ -168,7 +291,7 @@ export function storePrefetch(
|
|
|
168
291
|
if (oldest) cache.delete(oldest);
|
|
169
292
|
}
|
|
170
293
|
|
|
171
|
-
cache.set(key, {
|
|
294
|
+
cache.set(key, { entry, timestamp: now });
|
|
172
295
|
}
|
|
173
296
|
|
|
174
297
|
/**
|
|
@@ -188,14 +311,36 @@ export function markPrefetchInflight(key: string): void {
|
|
|
188
311
|
*/
|
|
189
312
|
export function setInflightPromise(
|
|
190
313
|
key: string,
|
|
191
|
-
promise: Promise<
|
|
314
|
+
promise: Promise<DecodedPrefetch | null>,
|
|
192
315
|
): void {
|
|
193
316
|
inflightPromises.set(key, promise);
|
|
194
317
|
}
|
|
195
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Store the same in-flight Promise under multiple keys, recording them
|
|
321
|
+
* as sibling aliases. Consuming or clearing any one alias atomically
|
|
322
|
+
* removes every entry, guaranteeing the shared Response stream has a
|
|
323
|
+
* single consumer even when navigation looks up either key.
|
|
324
|
+
*/
|
|
325
|
+
export function setInflightPromiseWithAliases(
|
|
326
|
+
keys: string[],
|
|
327
|
+
promise: Promise<DecodedPrefetch | null>,
|
|
328
|
+
): void {
|
|
329
|
+
for (const k of keys) {
|
|
330
|
+
inflightPromises.set(k, promise);
|
|
331
|
+
inflightAliases.set(k, keys);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
196
335
|
export function clearPrefetchInflight(key: string): void {
|
|
197
|
-
|
|
198
|
-
|
|
336
|
+
forEachAlias(key, (k) => {
|
|
337
|
+
inflight.delete(k);
|
|
338
|
+
inflightPromises.delete(k);
|
|
339
|
+
inflightAliases.delete(k);
|
|
340
|
+
// Clear any adopted marker too, so a fetch that failed before storePrefetch
|
|
341
|
+
// (the marker's normal consumer) does not strand it across the next prefetch.
|
|
342
|
+
adoptedKeys.delete(k);
|
|
343
|
+
});
|
|
199
344
|
}
|
|
200
345
|
|
|
201
346
|
/**
|
|
@@ -210,6 +355,8 @@ export function clearPrefetchCache(): void {
|
|
|
210
355
|
generation++;
|
|
211
356
|
inflight.clear();
|
|
212
357
|
inflightPromises.clear();
|
|
358
|
+
inflightAliases.clear();
|
|
359
|
+
adoptedKeys.clear();
|
|
213
360
|
cache.clear();
|
|
214
361
|
abortAllPrefetches();
|
|
215
362
|
invalidateRangoState();
|