@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
|
@@ -3,32 +3,60 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Fetch-based prefetch logic used by Link (hover/viewport/render strategies)
|
|
5
5
|
* and useRouter().prefetch(). Sends the same headers and segment IDs as a
|
|
6
|
-
* real navigation so the server returns a proper diff. The
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* real navigation so the server returns a proper diff. The response is fetched
|
|
7
|
+
* AND eagerly decoded (createFromFetch) up front: decoding the Flight stream
|
|
8
|
+
* resolves the route's client references, so the route's JS chunks are imported
|
|
9
|
+
* during prefetch rather than on click. The decoded payload is stored in an
|
|
10
|
+
* in-memory cache and reused verbatim by navigation, so a prefetched click
|
|
11
|
+
* loads no new code.
|
|
9
12
|
*
|
|
10
13
|
* In-flight promises are tracked in the cache so that navigation can reuse
|
|
11
|
-
* a prefetch that is still downloading instead of starting a
|
|
14
|
+
* a prefetch that is still downloading/decoding instead of starting a
|
|
15
|
+
* duplicate request.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import {
|
|
15
19
|
buildPrefetchKey,
|
|
20
|
+
buildSourceKey,
|
|
16
21
|
hasPrefetch,
|
|
17
22
|
markPrefetchInflight,
|
|
18
|
-
|
|
23
|
+
setInflightPromiseWithAliases,
|
|
19
24
|
storePrefetch,
|
|
20
25
|
clearPrefetchInflight,
|
|
21
26
|
currentGeneration,
|
|
27
|
+
type DecodedPrefetch,
|
|
22
28
|
} from "./cache.js";
|
|
23
29
|
import { getRangoState } from "../rango-state.js";
|
|
24
30
|
import { enqueuePrefetch } from "./queue.js";
|
|
25
31
|
import { shouldPrefetch } from "./policy.js";
|
|
26
32
|
import { debugLog } from "../logging.js";
|
|
33
|
+
import { teeWithCompletion, isForeignRouterId } from "../response-adapter.js";
|
|
34
|
+
import type { RscPayload } from "../types.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decoder injected at app startup (see setPrefetchDecoder). This is
|
|
38
|
+
* `deps.createFromFetch` — decoupled from the RSC runtime exactly like the
|
|
39
|
+
* navigation client. Prefetch decodes through it so the route's client chunks
|
|
40
|
+
* are pulled during the prefetch, not on click.
|
|
41
|
+
*/
|
|
42
|
+
type PrefetchDecoder = (response: Promise<Response>) => Promise<RscPayload>;
|
|
43
|
+
|
|
44
|
+
let decoder: PrefetchDecoder | null = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Wire the RSC decoder used to eagerly decode prefetched responses. Called
|
|
48
|
+
* once from initBrowserApp with the same createFromFetch the navigation client
|
|
49
|
+
* uses. Until set, prefetch warming is inert (prefetches are skipped) — the
|
|
50
|
+
* browser app always sets it before any Link can fire.
|
|
51
|
+
*/
|
|
52
|
+
export function setPrefetchDecoder(fn: PrefetchDecoder): void {
|
|
53
|
+
decoder = fn;
|
|
54
|
+
}
|
|
27
55
|
|
|
28
56
|
/**
|
|
29
57
|
* Check if a URL resolves to the current page (same pathname + search).
|
|
30
|
-
* Used to prevent same-page prefetching
|
|
31
|
-
*
|
|
58
|
+
* Used to prevent same-page prefetching, which produces a trivial diff
|
|
59
|
+
* that would corrupt the (default wildcard) prefetch cache entry.
|
|
32
60
|
*/
|
|
33
61
|
function isSamePage(url: string): boolean {
|
|
34
62
|
try {
|
|
@@ -77,20 +105,50 @@ function buildPrefetchUrl(
|
|
|
77
105
|
}
|
|
78
106
|
|
|
79
107
|
/**
|
|
80
|
-
* Core prefetch fetch logic. Fetches the response,
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* reuse an in-flight prefetch via
|
|
108
|
+
* Core prefetch fetch logic. Fetches the response, eagerly decodes it, and
|
|
109
|
+
* stores the decoded payload in the in-memory cache. The returned Promise
|
|
110
|
+
* resolves to the decoded entry (or null on failure / control header) so
|
|
111
|
+
* navigation can safely reuse an in-flight prefetch via
|
|
112
|
+
* consumeInflightPrefetch().
|
|
113
|
+
*
|
|
114
|
+
* Eager decode is the warming step: createFromFetch parses the Flight stream,
|
|
115
|
+
* which resolves the route's client references and imports its JS chunks. The
|
|
116
|
+
* stored payload is reused as-is by navigation, so the click loads no new code.
|
|
117
|
+
*
|
|
118
|
+
* Control headers are NOT acted on here. A speculative prefetch must never
|
|
119
|
+
* reload the page or throw a redirect — if the response carries X-RSC-Reload
|
|
120
|
+
* or X-RSC-Redirect, we drop it (resolve null) and let the real navigation
|
|
121
|
+
* re-fetch and honor it.
|
|
122
|
+
*
|
|
123
|
+
* Inflight + storage key selection:
|
|
124
|
+
*
|
|
125
|
+
* - `forceSourceScope` (Link opted in with `prefetchKey=":source"`): single
|
|
126
|
+
* inflight registration under `sourceKey`; entry stored under `sourceKey`.
|
|
127
|
+
* No wildcard leak is possible.
|
|
128
|
+
*
|
|
129
|
+
* - Otherwise: dual inflight registration under both `wildcardKey` and
|
|
130
|
+
* `sourceKey` so same-source navigations adopt directly via their own
|
|
131
|
+
* source key. Storage key is chosen at response time from the
|
|
132
|
+
* `X-RSC-Prefetch-Scope` header — `"source"` → `sourceKey` (intercept
|
|
133
|
+
* modals etc.), anything else → `wildcardKey`. The entry records its scope
|
|
134
|
+
* so cross-source navigations that adopted via `wildcardKey` can bail out
|
|
135
|
+
* in `navigation-client.ts` when the adopted entry turns out source-scoped.
|
|
84
136
|
*/
|
|
85
137
|
function executePrefetchFetch(
|
|
86
|
-
|
|
138
|
+
wildcardKey: string,
|
|
139
|
+
sourceKey: string,
|
|
87
140
|
fetchUrl: string,
|
|
141
|
+
forceSourceScope: boolean,
|
|
142
|
+
expectedRouterId?: string,
|
|
88
143
|
signal?: AbortSignal,
|
|
89
|
-
): Promise<
|
|
144
|
+
): Promise<DecodedPrefetch | null> {
|
|
90
145
|
const gen = currentGeneration();
|
|
91
|
-
|
|
146
|
+
const inflightKeys = forceSourceScope
|
|
147
|
+
? [sourceKey]
|
|
148
|
+
: [wildcardKey, sourceKey];
|
|
149
|
+
for (const k of inflightKeys) markPrefetchInflight(k);
|
|
92
150
|
|
|
93
|
-
const promise: Promise<
|
|
151
|
+
const promise: Promise<DecodedPrefetch | null> = fetch(fetchUrl, {
|
|
94
152
|
priority: "low" as RequestPriority,
|
|
95
153
|
signal,
|
|
96
154
|
headers: {
|
|
@@ -100,107 +158,193 @@ function executePrefetchFetch(
|
|
|
100
158
|
},
|
|
101
159
|
})
|
|
102
160
|
.then((response) => {
|
|
103
|
-
if (!response.ok) return null;
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
161
|
+
if (!response.ok || !decoder) return null;
|
|
162
|
+
// Control headers mean this response is stale (reload) or redirecting.
|
|
163
|
+
// Don't warm it — drop so navigation re-fetches and acts on the header.
|
|
164
|
+
if (
|
|
165
|
+
response.headers.has("X-RSC-Reload") ||
|
|
166
|
+
response.headers.has("X-RSC-Redirect")
|
|
167
|
+
) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
// Integrity check: never warm (or decode/import the chunks of) a foreign
|
|
171
|
+
// app's payload. A speculative prefetch must never reload — just drop it;
|
|
172
|
+
// navigation re-fetches and the server steers it.
|
|
173
|
+
if (isForeignRouterId(response, expectedRouterId)) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const scope: "source" | "wildcard" =
|
|
178
|
+
forceSourceScope ||
|
|
179
|
+
response.headers.get("x-rsc-prefetch-scope") === "source"
|
|
180
|
+
? "source"
|
|
181
|
+
: "wildcard";
|
|
182
|
+
const storageKey = scope === "source" ? sourceKey : wildcardKey;
|
|
183
|
+
|
|
184
|
+
// Track stream completion off a tee so navigation's scroll/revalidation
|
|
185
|
+
// gating matches the fresh-fetch path; decode the other branch.
|
|
186
|
+
let resolveStreamComplete!: () => void;
|
|
187
|
+
const streamComplete = new Promise<void>((resolve) => {
|
|
188
|
+
resolveStreamComplete = resolve;
|
|
189
|
+
});
|
|
190
|
+
const tracked = teeWithCompletion(
|
|
191
|
+
response,
|
|
192
|
+
() => resolveStreamComplete(),
|
|
193
|
+
signal,
|
|
194
|
+
// Speculative prefetch: a never-consumed/aborted stream error is benign.
|
|
195
|
+
true,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Eager decode: parsing the Flight stream imports the route's client
|
|
199
|
+
// chunks now, not on click.
|
|
200
|
+
const payload = decoder(Promise.resolve(tracked));
|
|
201
|
+
// Mark handled so an unconsumed prefetch decode error stays quiet; the
|
|
202
|
+
// error is still surfaced to navigation if it consumes the entry.
|
|
203
|
+
payload.catch(() => {});
|
|
204
|
+
|
|
205
|
+
const entry: DecodedPrefetch = { payload, streamComplete, scope };
|
|
206
|
+
storePrefetch(storageKey, entry, gen);
|
|
207
|
+
return entry;
|
|
115
208
|
})
|
|
116
209
|
.catch(() => null)
|
|
117
210
|
.finally(() => {
|
|
118
|
-
clearPrefetchInflight(
|
|
211
|
+
clearPrefetchInflight(inflightKeys[0]!);
|
|
119
212
|
});
|
|
120
213
|
|
|
121
|
-
|
|
214
|
+
setInflightPromiseWithAliases(inflightKeys, promise);
|
|
122
215
|
return promise;
|
|
123
216
|
}
|
|
124
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Dedup check for prefetch entry presence.
|
|
220
|
+
*
|
|
221
|
+
* Forced `:source` must NOT dedupe against a pre-existing wildcard entry —
|
|
222
|
+
* otherwise the source slot would stay unpopulated and navigation from
|
|
223
|
+
* this source would fall through to the (potentially wrong) wildcard
|
|
224
|
+
* response, defeating the opt-out.
|
|
225
|
+
*/
|
|
226
|
+
function hasPrefetchHit(
|
|
227
|
+
forceSourceScope: boolean,
|
|
228
|
+
wildcardKey: string,
|
|
229
|
+
sourceKey: string,
|
|
230
|
+
): boolean {
|
|
231
|
+
return forceSourceScope
|
|
232
|
+
? hasPrefetch(sourceKey)
|
|
233
|
+
: hasPrefetch(wildcardKey) || hasPrefetch(sourceKey);
|
|
234
|
+
}
|
|
235
|
+
|
|
125
236
|
/**
|
|
126
237
|
* Prefetch (direct): fetch with low priority and store in in-memory cache.
|
|
127
238
|
* Used by hover strategy -- fires immediately without queueing.
|
|
239
|
+
*
|
|
240
|
+
* By default the wildcard key (Rango-state-keyed) is used for inflight
|
|
241
|
+
* dedup and for responses that are not source-sensitive; source-scoped
|
|
242
|
+
* storage is automatic when the server emits `X-RSC-Prefetch-Scope: source`.
|
|
243
|
+
*
|
|
244
|
+
* Pass `prefetchKey=":source"` to force source-scoped inflight + storage
|
|
245
|
+
* (e.g. when the target uses a custom `revalidate()` that reads
|
|
246
|
+
* `currentUrl` and the wildcard slot would serve the wrong diff).
|
|
128
247
|
*/
|
|
129
248
|
export function prefetchDirect(
|
|
130
249
|
url: string,
|
|
131
250
|
segmentIds: string[],
|
|
132
251
|
version?: string,
|
|
133
252
|
routerId?: string,
|
|
134
|
-
prefetchKey?:
|
|
253
|
+
prefetchKey?: ":source",
|
|
135
254
|
): void {
|
|
136
255
|
if (!shouldPrefetch()) return;
|
|
137
256
|
|
|
138
257
|
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
139
258
|
if (!targetUrl) return;
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
259
|
+
const forceSourceScope = prefetchKey === ":source";
|
|
260
|
+
// Skip same-page prefetch — a same-page diff is trivial and would corrupt
|
|
261
|
+
// the wildcard cache entry used for cross-page navigation.
|
|
262
|
+
// When `:source` is forced the entry is source-scoped (single-aliased to
|
|
263
|
+
// itself), so it cannot poison any shared slot — allow it.
|
|
264
|
+
if (!forceSourceScope && isSamePage(url)) {
|
|
143
265
|
return;
|
|
144
266
|
}
|
|
145
|
-
const
|
|
146
|
-
|
|
267
|
+
const sourceHref = window.location.href;
|
|
268
|
+
const rangoState = getRangoState();
|
|
269
|
+
const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
|
|
270
|
+
const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
|
|
271
|
+
if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
|
|
147
272
|
debugLog("[prefetch] direct dedup (key already exists)", {
|
|
148
273
|
url,
|
|
149
|
-
|
|
150
|
-
|
|
274
|
+
wildcardKey,
|
|
275
|
+
sourceKey,
|
|
276
|
+
forceSourceScope,
|
|
151
277
|
});
|
|
152
278
|
return;
|
|
153
279
|
}
|
|
154
280
|
debugLog("[prefetch] direct fetch", {
|
|
155
281
|
url,
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
282
|
+
wildcardKey,
|
|
283
|
+
sourceKey,
|
|
284
|
+
source: sourceHref,
|
|
285
|
+
forceSourceScope,
|
|
159
286
|
});
|
|
160
|
-
executePrefetchFetch(
|
|
287
|
+
executePrefetchFetch(
|
|
288
|
+
wildcardKey,
|
|
289
|
+
sourceKey,
|
|
290
|
+
targetUrl.toString(),
|
|
291
|
+
forceSourceScope,
|
|
292
|
+
routerId,
|
|
293
|
+
);
|
|
161
294
|
}
|
|
162
295
|
|
|
163
296
|
/**
|
|
164
297
|
* Prefetch (queued): goes through the concurrency-limited queue.
|
|
165
298
|
* Used by viewport/render strategies to avoid flooding the server.
|
|
166
|
-
* Returns the
|
|
299
|
+
* Returns the inflight key (wildcard by default, source-scoped when
|
|
300
|
+
* `prefetchKey=":source"` is passed).
|
|
167
301
|
*/
|
|
168
302
|
export function prefetchQueued(
|
|
169
303
|
url: string,
|
|
170
304
|
segmentIds: string[],
|
|
171
305
|
version?: string,
|
|
172
306
|
routerId?: string,
|
|
173
|
-
prefetchKey?:
|
|
307
|
+
prefetchKey?: ":source",
|
|
174
308
|
): string {
|
|
175
309
|
if (!shouldPrefetch()) return "";
|
|
176
310
|
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
177
311
|
if (!targetUrl) return "";
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (prefetchKey != null && isSamePage(url)) {
|
|
312
|
+
const forceSourceScope = prefetchKey === ":source";
|
|
313
|
+
if (!forceSourceScope && isSamePage(url)) {
|
|
181
314
|
return "";
|
|
182
315
|
}
|
|
183
|
-
const
|
|
184
|
-
|
|
316
|
+
const sourceHref = window.location.href;
|
|
317
|
+
const rangoState = getRangoState();
|
|
318
|
+
const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
|
|
319
|
+
const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
|
|
320
|
+
const queueKey = forceSourceScope ? sourceKey : wildcardKey;
|
|
321
|
+
if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
|
|
185
322
|
debugLog("[prefetch] queued dedup (key already exists)", {
|
|
186
323
|
url,
|
|
187
|
-
|
|
188
|
-
|
|
324
|
+
wildcardKey,
|
|
325
|
+
sourceKey,
|
|
326
|
+
forceSourceScope,
|
|
189
327
|
});
|
|
190
|
-
return
|
|
328
|
+
return queueKey;
|
|
191
329
|
}
|
|
192
330
|
const fetchUrlStr = targetUrl.toString();
|
|
193
|
-
enqueuePrefetch(
|
|
331
|
+
enqueuePrefetch(queueKey, (signal) => {
|
|
194
332
|
// Re-check at execution time: a hover-triggered prefetchDirect may
|
|
195
333
|
// have started or completed this key while the item sat in the queue.
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (prefetchKey != null && isSamePage(url)) {
|
|
334
|
+
if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
|
|
335
|
+
return Promise.resolve();
|
|
336
|
+
}
|
|
337
|
+
if (!forceSourceScope && isSamePage(url)) {
|
|
201
338
|
return Promise.resolve();
|
|
202
339
|
}
|
|
203
|
-
return executePrefetchFetch(
|
|
340
|
+
return executePrefetchFetch(
|
|
341
|
+
wildcardKey,
|
|
342
|
+
sourceKey,
|
|
343
|
+
fetchUrlStr,
|
|
344
|
+
forceSourceScope,
|
|
345
|
+
routerId,
|
|
346
|
+
signal,
|
|
347
|
+
).then(() => {});
|
|
204
348
|
});
|
|
205
|
-
return
|
|
349
|
+
return queueKey;
|
|
206
350
|
}
|
|
@@ -108,10 +108,29 @@ export function enqueuePrefetch(
|
|
|
108
108
|
scheduleDrain();
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Normalize a URL-like string for keep-alive matching: parse against a
|
|
113
|
+
* placeholder origin and strip internal `_rsc_*` query params. Returns
|
|
114
|
+
* `pathname + search` so comparisons ignore hash and the internal params
|
|
115
|
+
* that prefetch appends to targets (`_rsc_partial`, `_rsc_segments`,
|
|
116
|
+
* `_rsc_v`, `_rsc_rid`, `_rsc_stale`).
|
|
117
|
+
*/
|
|
118
|
+
function normalizeForMatch(urlish: string): string {
|
|
119
|
+
try {
|
|
120
|
+
const u = new URL(urlish, "http://placeholder");
|
|
121
|
+
for (const k of [...u.searchParams.keys()]) {
|
|
122
|
+
if (k.startsWith("_rsc_")) u.searchParams.delete(k);
|
|
123
|
+
}
|
|
124
|
+
return u.pathname + u.search;
|
|
125
|
+
} catch {
|
|
126
|
+
return urlish;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
111
130
|
/**
|
|
112
131
|
* Cancel queued prefetches and abort in-flight ones that don't match
|
|
113
132
|
* the current navigation target. If `keepUrl` is provided, the
|
|
114
|
-
* executing prefetch whose key
|
|
133
|
+
* executing prefetch whose key targets that URL is kept alive so
|
|
115
134
|
* navigation can reuse its response via consumeInflightPrefetch.
|
|
116
135
|
*
|
|
117
136
|
* Called when a navigation starts via the NavigationProvider's
|
|
@@ -124,11 +143,23 @@ export function cancelAllPrefetches(keepUrl?: string | null): void {
|
|
|
124
143
|
drainGeneration++;
|
|
125
144
|
|
|
126
145
|
// Abort in-flight prefetches that aren't for the navigation target.
|
|
127
|
-
//
|
|
128
|
-
//
|
|
146
|
+
// Key shapes (see prefetch/cache.ts buildPrefetchKey):
|
|
147
|
+
// wildcard: "rangoState\0/target?..."
|
|
148
|
+
// source-scoped: "rangoState\0sourceHref\0/target?..."
|
|
149
|
+
// The target portion is always the final \0-delimited segment and
|
|
150
|
+
// includes internal `_rsc_*` params (from buildPrefetchUrl); keepUrl
|
|
151
|
+
// comes from NavigationProvider's pendingUrl which is the bare
|
|
152
|
+
// navigation target. Normalize both sides before comparing.
|
|
153
|
+
const normalizedKeep = keepUrl ? normalizeForMatch(keepUrl) : null;
|
|
129
154
|
for (const [key, ac] of abortControllers) {
|
|
130
|
-
const
|
|
131
|
-
|
|
155
|
+
const lastNul = key.lastIndexOf("\0");
|
|
156
|
+
const target = lastNul >= 0 ? key.substring(lastNul + 1) : "";
|
|
157
|
+
if (
|
|
158
|
+
normalizedKeep &&
|
|
159
|
+
target &&
|
|
160
|
+
normalizeForMatch(target) === normalizedKeep
|
|
161
|
+
)
|
|
162
|
+
continue;
|
|
132
163
|
ac.abort();
|
|
133
164
|
abortControllers.delete(key);
|
|
134
165
|
if (executing.delete(key)) {
|
|
@@ -6,21 +6,36 @@
|
|
|
6
6
|
* navigation requests. The server responds with `Vary: X-Rango-State`,
|
|
7
7
|
* so the browser HTTP cache keys responses by (URL, X-Rango-State value).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Value format: `{buildVersion}:{invalidationTimestamp}`
|
|
10
10
|
* - Build version changes on deploy, busting all cached prefetches.
|
|
11
11
|
* - Timestamp changes on server action invalidation.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Storage key is namespaced per routerId (`rango-state:{routerId}`) so
|
|
14
|
+
* tabs in different apps on the same origin do not collide. Two tabs in
|
|
15
|
+
* the same app share a key → one tab's invalidation is picked up by the
|
|
16
|
+
* other via the `storage` event. The key is bound once at document init; a
|
|
17
|
+
* cross-app navigation is a full document load (X-RSC-Reload), so the target
|
|
18
|
+
* app's document binds its own key on load (tabs in the old app keep theirs).
|
|
19
|
+
*
|
|
20
|
+
* If no routerId is supplied, falls back to a single legacy key for
|
|
21
|
+
* backward compatibility (single-app deployments unaffected).
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
|
-
const
|
|
24
|
+
const LEGACY_STORAGE_KEY = "rango-state";
|
|
25
|
+
|
|
26
|
+
function buildStorageKey(routerId: string | undefined): string {
|
|
27
|
+
return routerId ? `${LEGACY_STORAGE_KEY}:${routerId}` : LEGACY_STORAGE_KEY;
|
|
28
|
+
}
|
|
19
29
|
|
|
20
30
|
// Module-level cache avoids hitting localStorage on every getRangoState() call.
|
|
21
31
|
// Initialized from localStorage on first access or by initRangoState().
|
|
22
32
|
let cachedState: string | null = null;
|
|
23
33
|
|
|
34
|
+
// The localStorage key this tab is currently bound to. Bound on
|
|
35
|
+
// initRangoState (document boot). The storage listener filters cross-tab
|
|
36
|
+
// events by this key so events from tabs in a different app are ignored.
|
|
37
|
+
let currentStorageKey: string = LEGACY_STORAGE_KEY;
|
|
38
|
+
|
|
24
39
|
// Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
|
|
25
40
|
// to localStorage, keeping cachedState fresh without polling.
|
|
26
41
|
let storageListenerAttached = false;
|
|
@@ -28,7 +43,10 @@ let storageListenerAttached = false;
|
|
|
28
43
|
function attachStorageListener(): void {
|
|
29
44
|
if (storageListenerAttached || typeof window === "undefined") return;
|
|
30
45
|
window.addEventListener("storage", (e) => {
|
|
31
|
-
|
|
46
|
+
// Only react to events for this tab's current app namespace. Events
|
|
47
|
+
// under other routerId-scoped keys belong to other apps and must not
|
|
48
|
+
// clobber this tab's state.
|
|
49
|
+
if (e.key !== currentStorageKey) return;
|
|
32
50
|
cachedState = e.newValue;
|
|
33
51
|
});
|
|
34
52
|
storageListenerAttached = true;
|
|
@@ -37,16 +55,22 @@ function attachStorageListener(): void {
|
|
|
37
55
|
/**
|
|
38
56
|
* Initialize the Rango state key in localStorage.
|
|
39
57
|
* Called once at app startup with the build version from the server.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
58
|
+
* The routerId scopes the storage key to this app; in multi-app setups
|
|
59
|
+
* each app owns its own `rango-state:{routerId}` key and cannot observe
|
|
60
|
+
* invalidations from sibling apps on the same origin.
|
|
61
|
+
*
|
|
62
|
+
* If localStorage already has a matching-version entry under the key,
|
|
63
|
+
* keeps it (preserves invalidation state across refresh). Otherwise
|
|
64
|
+
* writes a new value.
|
|
42
65
|
*/
|
|
43
|
-
export function initRangoState(version: string): void {
|
|
66
|
+
export function initRangoState(version: string, routerId?: string): void {
|
|
67
|
+
currentStorageKey = buildStorageKey(routerId);
|
|
44
68
|
if (typeof window === "undefined") return;
|
|
45
69
|
|
|
46
70
|
attachStorageListener();
|
|
47
71
|
|
|
48
72
|
try {
|
|
49
|
-
const existing = localStorage.getItem(
|
|
73
|
+
const existing = localStorage.getItem(currentStorageKey);
|
|
50
74
|
if (existing) {
|
|
51
75
|
const colonIdx = existing.indexOf(":");
|
|
52
76
|
if (colonIdx > 0) {
|
|
@@ -59,7 +83,7 @@ export function initRangoState(version: string): void {
|
|
|
59
83
|
}
|
|
60
84
|
// New version or first load
|
|
61
85
|
const newState = `${version}:${Date.now()}`;
|
|
62
|
-
localStorage.setItem(
|
|
86
|
+
localStorage.setItem(currentStorageKey, newState);
|
|
63
87
|
cachedState = newState;
|
|
64
88
|
} catch {
|
|
65
89
|
// localStorage may be unavailable (private browsing in some browsers)
|
|
@@ -77,7 +101,7 @@ export function getRangoState(): string {
|
|
|
77
101
|
if (typeof window === "undefined") return "0:0";
|
|
78
102
|
|
|
79
103
|
try {
|
|
80
|
-
const stored = localStorage.getItem(
|
|
104
|
+
const stored = localStorage.getItem(currentStorageKey);
|
|
81
105
|
if (stored) {
|
|
82
106
|
cachedState = stored;
|
|
83
107
|
return stored;
|
|
@@ -105,7 +129,7 @@ export function invalidateRangoState(): void {
|
|
|
105
129
|
if (typeof window === "undefined") return;
|
|
106
130
|
|
|
107
131
|
try {
|
|
108
|
-
localStorage.setItem(
|
|
132
|
+
localStorage.setItem(currentStorageKey, newState);
|
|
109
133
|
} catch {
|
|
110
134
|
// Silently handle localStorage errors
|
|
111
135
|
}
|
|
@@ -98,25 +98,30 @@ export interface LinkProps extends Omit<
|
|
|
98
98
|
*/
|
|
99
99
|
prefetch?: PrefetchStrategy;
|
|
100
100
|
/**
|
|
101
|
-
*
|
|
102
|
-
* When set, prefetch responses are cached independently of the current
|
|
103
|
-
* page URL, so navigating to the same target from different source pages
|
|
104
|
-
* reuses the cached prefetch.
|
|
101
|
+
* Opt-in override for the prefetch cache scope.
|
|
105
102
|
*
|
|
106
|
-
* -
|
|
107
|
-
*
|
|
108
|
-
*
|
|
103
|
+
* The default cache is source-agnostic: one shared entry per target,
|
|
104
|
+
* keyed on Rango state + target URL. This is correct for routes whose
|
|
105
|
+
* response shape doesn't depend on where the user navigates from.
|
|
106
|
+
*
|
|
107
|
+
* Set `":source"` when this Link's response would legitimately differ
|
|
108
|
+
* based on the source page — typically when the target route (or one
|
|
109
|
+
* of its layouts) uses a custom `revalidate()` handler that reads
|
|
110
|
+
* `currentUrl` / `currentParams`, and the wildcard entry would
|
|
111
|
+
* therefore serve the wrong diff to a navigation from a different
|
|
112
|
+
* source.
|
|
113
|
+
*
|
|
114
|
+
* Intercept responses are auto-scoped to the source via a server-side
|
|
115
|
+
* tag, so `":source"` is only needed for custom revalidation logic.
|
|
109
116
|
*
|
|
110
117
|
* @example
|
|
111
118
|
* ```tsx
|
|
112
|
-
* //
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* // Normalize — strip trailing page number from source URL
|
|
116
|
-
* <Link to="/page/3" prefetch="hover" prefetchKey={(from) => from.replace(/\/\d+$/, '')} />
|
|
119
|
+
* // Route uses a `revalidate()` that branches on currentUrl — opt in
|
|
120
|
+
* // so prefetches don't bleed across source pages.
|
|
121
|
+
* <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
|
|
117
122
|
* ```
|
|
118
123
|
*/
|
|
119
|
-
prefetchKey?:
|
|
124
|
+
prefetchKey?: ":source";
|
|
120
125
|
/**
|
|
121
126
|
* State to pass to history.pushState/replaceState.
|
|
122
127
|
* Accessible via useLocationState() hook.
|