@rangojs/router 0.0.0-experimental.8 → 0.0.0-experimental.81
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/AGENTS.md +9 -0
- package/README.md +942 -4
- package/dist/bin/rango.js +1689 -0
- package/dist/vite/index.js +5091 -941
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +61 -52
- package/skills/breadcrumbs/SKILL.md +250 -0
- package/skills/cache-guide/SKILL.md +294 -0
- package/skills/caching/SKILL.md +93 -23
- package/skills/composability/SKILL.md +172 -0
- package/skills/debug-manifest/SKILL.md +12 -8
- package/skills/document-cache/SKILL.md +18 -16
- package/skills/fonts/SKILL.md +167 -0
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/hooks/SKILL.md +340 -72
- package/skills/host-router/SKILL.md +218 -0
- package/skills/intercept/SKILL.md +151 -8
- package/skills/layout/SKILL.md +122 -3
- package/skills/links/SKILL.md +92 -31
- package/skills/loader/SKILL.md +404 -44
- package/skills/middleware/SKILL.md +205 -37
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +765 -0
- package/skills/mime-routes/SKILL.md +128 -0
- package/skills/parallel/SKILL.md +263 -1
- package/skills/prerender/SKILL.md +685 -0
- package/skills/rango/SKILL.md +87 -16
- package/skills/response-routes/SKILL.md +411 -0
- package/skills/route/SKILL.md +281 -14
- package/skills/router-setup/SKILL.md +210 -32
- package/skills/tailwind/SKILL.md +129 -0
- package/skills/theme/SKILL.md +9 -8
- package/skills/typesafety/SKILL.md +328 -89
- package/skills/use-cache/SKILL.md +324 -0
- package/src/__internal.ts +102 -4
- package/src/bin/rango.ts +321 -0
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/action-response-classifier.ts +99 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +92 -64
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +52 -0
- package/src/browser/link-interceptor.ts +24 -4
- package/src/browser/logging.ts +55 -0
- package/src/browser/merge-segment-loaders.ts +20 -12
- package/src/browser/navigation-bridge.ts +317 -560
- package/src/browser/navigation-client.ts +206 -68
- package/src/browser/navigation-store.ts +73 -55
- package/src/browser/navigation-transaction.ts +297 -0
- package/src/browser/network-error-handler.ts +61 -0
- package/src/browser/partial-update.ts +343 -316
- package/src/browser/prefetch/cache.ts +216 -0
- package/src/browser/prefetch/fetch.ts +206 -0
- package/src/browser/prefetch/observer.ts +65 -0
- package/src/browser/prefetch/policy.ts +48 -0
- package/src/browser/prefetch/queue.ts +160 -0
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +112 -0
- package/src/browser/react/Link.tsx +253 -74
- package/src/browser/react/NavigationProvider.tsx +91 -11
- package/src/browser/react/context.ts +11 -0
- package/src/browser/react/filter-segment-order.ts +11 -0
- package/src/browser/react/index.ts +12 -12
- package/src/browser/react/location-state-shared.ts +95 -53
- package/src/browser/react/location-state.ts +60 -15
- package/src/browser/react/mount-context.ts +6 -1
- 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 +29 -51
- package/src/browser/react/use-client-cache.ts +5 -3
- package/src/browser/react/use-handle.ts +30 -126
- package/src/browser/react/use-href.tsx +2 -2
- package/src/browser/react/use-link-status.ts +6 -5
- package/src/browser/react/use-navigation.ts +44 -65
- package/src/browser/react/use-params.ts +75 -0
- package/src/browser/react/use-pathname.ts +47 -0
- package/src/browser/react/use-router.ts +76 -0
- package/src/browser/react/use-search-params.ts +56 -0
- package/src/browser/react/use-segments.ts +80 -97
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +214 -58
- package/src/browser/scroll-restoration.ts +127 -52
- package/src/browser/segment-reconciler.ts +243 -0
- package/src/browser/segment-structure-assert.ts +16 -0
- package/src/browser/server-action-bridge.ts +510 -603
- package/src/browser/shallow.ts +6 -1
- package/src/browser/types.ts +141 -48
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +235 -24
- package/src/build/generate-route-types.ts +39 -0
- package/src/build/index.ts +13 -0
- package/src/build/route-trie.ts +291 -0
- package/src/build/route-types/ast-helpers.ts +25 -0
- package/src/build/route-types/ast-route-extraction.ts +98 -0
- package/src/build/route-types/codegen.ts +102 -0
- package/src/build/route-types/include-resolution.ts +418 -0
- package/src/build/route-types/param-extraction.ts +48 -0
- package/src/build/route-types/per-module-writer.ts +128 -0
- package/src/build/route-types/router-processing.ts +618 -0
- package/src/build/route-types/scan-filter.ts +85 -0
- package/src/build/runtime-discovery.ts +231 -0
- 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 +342 -0
- package/src/cache/cache-scope.ts +167 -309
- package/src/cache/cf/cf-cache-store.ts +571 -17
- package/src/cache/cf/index.ts +13 -3
- package/src/cache/document-cache.ts +116 -77
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/handle-snapshot.ts +41 -0
- package/src/cache/index.ts +1 -15
- package/src/cache/memory-segment-store.ts +191 -13
- package/src/cache/profile-registry.ts +73 -0
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +256 -0
- package/src/cache/taint.ts +153 -0
- package/src/cache/types.ts +72 -122
- package/src/client.rsc.tsx +3 -1
- package/src/client.tsx +135 -301
- package/src/component-utils.ts +4 -4
- package/src/components/DefaultDocument.tsx +5 -1
- package/src/context-var.ts +156 -0
- package/src/debug.ts +19 -9
- package/src/errors.ts +108 -2
- package/src/handle.ts +55 -29
- package/src/handles/MetaTags.tsx +73 -20
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +1 -0
- package/src/handles/meta.ts +30 -13
- package/src/host/cookie-handler.ts +21 -15
- package/src/host/errors.ts +8 -8
- package/src/host/index.ts +4 -7
- package/src/host/pattern-matcher.ts +27 -27
- package/src/host/router.ts +61 -39
- package/src/host/testing.ts +8 -8
- package/src/host/types.ts +15 -7
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +119 -29
- package/src/index.rsc.ts +155 -19
- package/src/index.ts +251 -30
- package/src/internal-debug.ts +11 -0
- package/src/loader.rsc.ts +26 -157
- package/src/loader.ts +27 -10
- package/src/network-error-thrower.tsx +3 -1
- package/src/outlet-provider.tsx +45 -0
- package/src/prerender/param-hash.ts +37 -0
- package/src/prerender/store.ts +186 -0
- package/src/prerender.ts +524 -0
- package/src/reverse.ts +354 -0
- package/src/root-error-boundary.tsx +41 -29
- package/src/route-content-wrapper.tsx +7 -4
- package/src/route-definition/dsl-helpers.ts +1121 -0
- package/src/route-definition/helper-factories.ts +200 -0
- package/src/route-definition/helpers-types.ts +478 -0
- package/src/route-definition/index.ts +55 -0
- package/src/route-definition/redirect.ts +101 -0
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-definition.ts +1 -1428
- package/src/route-map-builder.ts +217 -123
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +77 -8
- package/src/router/content-negotiation.ts +215 -0
- package/src/router/debug-manifest.ts +72 -0
- package/src/router/error-handling.ts +9 -9
- package/src/router/find-match.ts +160 -0
- package/src/router/handler-context.ts +438 -86
- package/src/router/intercept-resolution.ts +402 -0
- package/src/router/lazy-includes.ts +237 -0
- package/src/router/loader-resolution.ts +356 -128
- package/src/router/logging.ts +251 -0
- package/src/router/manifest.ts +163 -35
- package/src/router/match-api.ts +555 -0
- package/src/router/match-context.ts +5 -3
- package/src/router/match-handlers.ts +440 -0
- package/src/router/match-middleware/background-revalidation.ts +108 -93
- package/src/router/match-middleware/cache-lookup.ts +460 -10
- package/src/router/match-middleware/cache-store.ts +98 -26
- package/src/router/match-middleware/intercept-resolution.ts +57 -17
- package/src/router/match-middleware/segment-resolution.ts +80 -6
- package/src/router/match-pipelines.ts +10 -45
- package/src/router/match-result.ts +135 -35
- package/src/router/metrics.ts +240 -15
- package/src/router/middleware-cookies.ts +55 -0
- package/src/router/middleware-types.ts +220 -0
- package/src/router/middleware.ts +324 -369
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/pattern-matching.ts +211 -43
- package/src/router/prerender-match.ts +502 -0
- package/src/router/preview-match.ts +98 -0
- package/src/router/request-classification.ts +310 -0
- package/src/router/revalidation.ts +137 -38
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +41 -21
- package/src/router/router-interfaces.ts +484 -0
- package/src/router/router-options.ts +618 -0
- package/src/router/router-registry.ts +24 -0
- package/src/router/segment-resolution/fresh.ts +748 -0
- package/src/router/segment-resolution/helpers.ts +268 -0
- package/src/router/segment-resolution/loader-cache.ts +199 -0
- package/src/router/segment-resolution/revalidation.ts +1379 -0
- package/src/router/segment-resolution/static-store.ts +67 -0
- package/src/router/segment-resolution.ts +21 -0
- package/src/router/segment-wrappers.ts +291 -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 +239 -0
- package/src/router/types.ts +78 -3
- package/src/router.ts +740 -4252
- package/src/rsc/handler-context.ts +45 -0
- package/src/rsc/handler.ts +907 -797
- package/src/rsc/helpers.ts +140 -6
- package/src/rsc/index.ts +0 -20
- package/src/rsc/loader-fetch.ts +229 -0
- package/src/rsc/manifest-init.ts +90 -0
- package/src/rsc/nonce.ts +14 -0
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +393 -0
- package/src/rsc/response-error.ts +37 -0
- package/src/rsc/response-route-handler.ts +347 -0
- package/src/rsc/rsc-rendering.ts +246 -0
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +358 -0
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +46 -11
- package/src/search-params.ts +230 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +134 -36
- package/src/server/context.ts +341 -61
- package/src/server/cookie-store.ts +190 -0
- package/src/server/fetchable-loader-store.ts +37 -0
- package/src/server/handle-store.ts +113 -15
- package/src/server/loader-registry.ts +24 -64
- package/src/server/request-context.ts +607 -81
- package/src/server.ts +35 -130
- package/src/ssr/index.tsx +103 -30
- package/src/static-handler.ts +126 -0
- package/src/theme/ThemeProvider.tsx +21 -15
- package/src/theme/ThemeScript.tsx +5 -5
- package/src/theme/constants.ts +5 -2
- package/src/theme/index.ts +4 -14
- package/src/theme/theme-context.ts +4 -30
- package/src/theme/theme-script.ts +21 -18
- package/src/types/boundaries.ts +158 -0
- package/src/types/cache-types.ts +198 -0
- package/src/types/error-types.ts +192 -0
- package/src/types/global-namespace.ts +100 -0
- package/src/types/handler-context.ts +791 -0
- package/src/types/index.ts +88 -0
- package/src/types/loader-types.ts +210 -0
- package/src/types/route-config.ts +170 -0
- package/src/types/route-entry.ts +120 -0
- package/src/types/segments.ts +150 -0
- package/src/types.ts +1 -1623
- package/src/urls/include-helper.ts +207 -0
- package/src/urls/index.ts +53 -0
- package/src/urls/path-helper-types.ts +372 -0
- package/src/urls/path-helper.ts +364 -0
- package/src/urls/pattern-types.ts +107 -0
- package/src/urls/response-types.ts +116 -0
- package/src/urls/type-extraction.ts +372 -0
- package/src/urls/urls-function.ts +98 -0
- package/src/urls.ts +1 -802
- package/src/use-loader.tsx +161 -81
- package/src/vite/discovery/bundle-postprocess.ts +181 -0
- package/src/vite/discovery/discover-routers.ts +348 -0
- package/src/vite/discovery/prerender-collection.ts +439 -0
- package/src/vite/discovery/route-types-writer.ts +258 -0
- package/src/vite/discovery/self-gen-tracking.ts +47 -0
- package/src/vite/discovery/state.ts +117 -0
- package/src/vite/discovery/virtual-module-codegen.ts +203 -0
- package/src/vite/index.ts +15 -1133
- package/src/vite/plugin-types.ts +103 -0
- package/src/vite/plugins/cjs-to-esm.ts +93 -0
- package/src/vite/plugins/client-ref-dedup.ts +115 -0
- package/src/vite/plugins/client-ref-hashing.ts +105 -0
- 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/{expose-action-id.ts → plugins/expose-action-id.ts} +72 -53
- package/src/vite/plugins/expose-id-utils.ts +299 -0
- package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +209 -0
- package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +110 -0
- package/src/vite/plugins/expose-ids/types.ts +45 -0
- package/src/vite/plugins/expose-internal-ids.ts +786 -0
- package/src/vite/plugins/performance-tracks.ts +88 -0
- package/src/vite/plugins/refresh-cmd.ts +127 -0
- package/src/vite/plugins/use-cache-transform.ts +323 -0
- package/src/vite/plugins/version-injector.ts +83 -0
- package/src/vite/plugins/version-plugin.ts +266 -0
- package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +23 -14
- package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
- package/src/vite/rango.ts +462 -0
- package/src/vite/router-discovery.ts +977 -0
- package/src/vite/utils/ast-handler-extract.ts +517 -0
- package/src/vite/utils/banner.ts +36 -0
- package/src/vite/utils/bundle-analysis.ts +137 -0
- package/src/vite/utils/manifest-utils.ts +70 -0
- package/src/vite/{package-resolution.ts → utils/package-resolution.ts} +25 -29
- package/src/vite/utils/prerender-utils.ts +221 -0
- package/src/vite/utils/shared-utils.ts +170 -0
- package/CLAUDE.md +0 -43
- package/src/browser/lru-cache.ts +0 -69
- 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/href.ts +0 -255
- package/src/server/route-manifest-cache.ts +0 -173
- package/src/vite/expose-handle-id.ts +0 -209
- package/src/vite/expose-loader-id.ts +0 -426
- package/src/vite/expose-location-state-id.ts +0 -177
- /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefetch Cache
|
|
3
|
+
*
|
|
4
|
+
* In-memory cache storing prefetched Response objects for instant cache hits
|
|
5
|
+
* on subsequent navigation. Cache key is source-dependent (includes the
|
|
6
|
+
* current page URL) because the server's diff-based response depends on
|
|
7
|
+
* where the user navigates from.
|
|
8
|
+
*
|
|
9
|
+
* Also tracks in-flight prefetch promises. Each promise resolves to the
|
|
10
|
+
* navigation branch of a tee'd Response, allowing navigation to adopt a
|
|
11
|
+
* still-downloading prefetch without reparsing or buffering the body.
|
|
12
|
+
*
|
|
13
|
+
* Replaces the previous browser HTTP cache approach which was unreliable
|
|
14
|
+
* due to response draining race conditions and browser inconsistencies.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { abortAllPrefetches } from "./queue.js";
|
|
18
|
+
import { invalidateRangoState } from "../rango-state.js";
|
|
19
|
+
|
|
20
|
+
// Default TTL: 5 minutes. Overridden by initPrefetchCache() with
|
|
21
|
+
// the server-configured prefetchCacheTTL from router options.
|
|
22
|
+
// 0 disables the in-memory cache entirely.
|
|
23
|
+
let cacheTTL = 300_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Initialize the prefetch cache with the configured TTL.
|
|
27
|
+
* Called once at app startup with the value from server metadata.
|
|
28
|
+
* A TTL of 0 disables the in-memory cache and all prefetching.
|
|
29
|
+
*/
|
|
30
|
+
export function initPrefetchCache(ttlMs: number): void {
|
|
31
|
+
cacheTTL = ttlMs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if the prefetch cache is disabled (TTL <= 0).
|
|
36
|
+
* When disabled, no prefetch requests should be issued.
|
|
37
|
+
*/
|
|
38
|
+
export function isPrefetchCacheDisabled(): boolean {
|
|
39
|
+
return cacheTTL <= 0;
|
|
40
|
+
}
|
|
41
|
+
const MAX_PREFETCH_CACHE_SIZE = 50;
|
|
42
|
+
|
|
43
|
+
interface PrefetchCacheEntry {
|
|
44
|
+
response: Response;
|
|
45
|
+
timestamp: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const cache = new Map<string, PrefetchCacheEntry>();
|
|
49
|
+
const inflight = new Set<string>();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* In-flight promise map. When a prefetch fetch is in progress, its
|
|
53
|
+
* Promise<Response | null> is stored here so navigation can await
|
|
54
|
+
* it instead of starting a duplicate request.
|
|
55
|
+
*/
|
|
56
|
+
const inflightPromises = new Map<string, Promise<Response | null>>();
|
|
57
|
+
|
|
58
|
+
// Generation counter incremented on each clearPrefetchCache(). Fetches that
|
|
59
|
+
// started before a clear carry a stale generation and must not store their
|
|
60
|
+
// response (the data may be stale due to a server action invalidation).
|
|
61
|
+
let generation = 0;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build a cache key for prefetched responses.
|
|
65
|
+
*
|
|
66
|
+
* By default the key includes the source page href so the same target
|
|
67
|
+
* prefetched from different pages gets separate entries (the server's
|
|
68
|
+
* diff response depends on the source page context).
|
|
69
|
+
*
|
|
70
|
+
* When `prefetchKey` is provided, the source portion is replaced with
|
|
71
|
+
* a `*` sentinel so all custom-keyed entries share one cache slot per
|
|
72
|
+
* target — enabling source-agnostic cache reuse.
|
|
73
|
+
*/
|
|
74
|
+
export function buildPrefetchKey(
|
|
75
|
+
sourceHref: string,
|
|
76
|
+
targetUrl: URL,
|
|
77
|
+
prefetchKey?: string | ((from: string) => string),
|
|
78
|
+
): string {
|
|
79
|
+
const source = prefetchKey != null ? "*" : sourceHref;
|
|
80
|
+
return source + "\0" + targetUrl.pathname + targetUrl.search;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if a prefetch is already cached, in-flight, or queued for the given key.
|
|
85
|
+
*/
|
|
86
|
+
export function hasPrefetch(key: string): boolean {
|
|
87
|
+
if (inflight.has(key)) return true;
|
|
88
|
+
if (cacheTTL <= 0) return false;
|
|
89
|
+
const entry = cache.get(key);
|
|
90
|
+
if (!entry) return false;
|
|
91
|
+
if (Date.now() - entry.timestamp > cacheTTL) {
|
|
92
|
+
cache.delete(key);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Consume a cached prefetch response. Returns null if not found or expired.
|
|
100
|
+
* One-time consumption: the entry is deleted after retrieval.
|
|
101
|
+
* Returns null when caching is disabled (TTL <= 0).
|
|
102
|
+
*
|
|
103
|
+
* Does NOT check in-flight prefetches — use consumeInflightPrefetch()
|
|
104
|
+
* for that (returns a Promise instead of a Response).
|
|
105
|
+
*/
|
|
106
|
+
export function consumePrefetch(key: string): Response | null {
|
|
107
|
+
if (cacheTTL <= 0) return null;
|
|
108
|
+
const entry = cache.get(key);
|
|
109
|
+
if (!entry) return null;
|
|
110
|
+
if (Date.now() - entry.timestamp > cacheTTL) {
|
|
111
|
+
cache.delete(key);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
cache.delete(key);
|
|
115
|
+
return entry.response;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Consume an in-flight prefetch promise. Returns null if no prefetch is
|
|
120
|
+
* in-flight for this key. The returned Promise resolves to the buffered
|
|
121
|
+
* Response (or null if the fetch failed/was aborted).
|
|
122
|
+
*
|
|
123
|
+
* One-time consumption: the promise entry is removed so a second call
|
|
124
|
+
* returns null. The `inflight` set entry is intentionally kept so that
|
|
125
|
+
* hasPrefetch() continues to return true while the underlying fetch is
|
|
126
|
+
* still downloading — this prevents prefetchDirect() or other callers
|
|
127
|
+
* from starting a duplicate request during the handoff window. The
|
|
128
|
+
* inflight flag is cleaned up naturally by clearPrefetchInflight() in
|
|
129
|
+
* the fetch's .finally().
|
|
130
|
+
*/
|
|
131
|
+
export function consumeInflightPrefetch(
|
|
132
|
+
key: string,
|
|
133
|
+
): Promise<Response | null> | null {
|
|
134
|
+
const promise = inflightPromises.get(key);
|
|
135
|
+
if (!promise) return null;
|
|
136
|
+
// Remove the promise (one-time consumption) but keep the inflight flag.
|
|
137
|
+
inflightPromises.delete(key);
|
|
138
|
+
return promise;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Store a prefetch response in the in-memory cache.
|
|
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.
|
|
145
|
+
*
|
|
146
|
+
* Skips storage if the generation has changed since the fetch started
|
|
147
|
+
* (a server action invalidated the cache mid-flight).
|
|
148
|
+
*/
|
|
149
|
+
export function storePrefetch(
|
|
150
|
+
key: string,
|
|
151
|
+
response: Response,
|
|
152
|
+
fetchGeneration: number,
|
|
153
|
+
): void {
|
|
154
|
+
if (cacheTTL <= 0) return;
|
|
155
|
+
if (fetchGeneration !== generation) return;
|
|
156
|
+
|
|
157
|
+
// Evict expired entries
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
for (const [k, entry] of cache) {
|
|
160
|
+
if (now - entry.timestamp > cacheTTL) {
|
|
161
|
+
cache.delete(k);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// FIFO eviction if at capacity
|
|
166
|
+
if (cache.size >= MAX_PREFETCH_CACHE_SIZE) {
|
|
167
|
+
const oldest = cache.keys().next().value;
|
|
168
|
+
if (oldest) cache.delete(oldest);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
cache.set(key, { response, timestamp: now });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Capture the current generation. The returned value is passed to
|
|
176
|
+
* storePrefetch so it can detect stale completions.
|
|
177
|
+
*/
|
|
178
|
+
export function currentGeneration(): number {
|
|
179
|
+
return generation;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function markPrefetchInflight(key: string): void {
|
|
183
|
+
inflight.add(key);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Store the in-flight Promise for a prefetch so navigation can reuse it.
|
|
188
|
+
*/
|
|
189
|
+
export function setInflightPromise(
|
|
190
|
+
key: string,
|
|
191
|
+
promise: Promise<Response | null>,
|
|
192
|
+
): void {
|
|
193
|
+
inflightPromises.set(key, promise);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function clearPrefetchInflight(key: string): void {
|
|
197
|
+
inflight.delete(key);
|
|
198
|
+
inflightPromises.delete(key);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Invalidate all prefetch state. Called when server actions mutate data.
|
|
203
|
+
* Clears the in-memory cache, cancels in-flight prefetches, and rotates
|
|
204
|
+
* the Rango state key so CDN-cached responses are also invalidated.
|
|
205
|
+
*
|
|
206
|
+
* Uses abortAllPrefetches (hard cancel) because in-flight responses
|
|
207
|
+
* may contain stale data after a mutation.
|
|
208
|
+
*/
|
|
209
|
+
export function clearPrefetchCache(): void {
|
|
210
|
+
generation++;
|
|
211
|
+
inflight.clear();
|
|
212
|
+
inflightPromises.clear();
|
|
213
|
+
cache.clear();
|
|
214
|
+
abortAllPrefetches();
|
|
215
|
+
invalidateRangoState();
|
|
216
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefetch Fetch
|
|
3
|
+
*
|
|
4
|
+
* Fetch-based prefetch logic used by Link (hover/viewport/render strategies)
|
|
5
|
+
* and useRouter().prefetch(). Sends the same headers and segment IDs as a
|
|
6
|
+
* real navigation so the server returns a proper diff. The Response is fully
|
|
7
|
+
* buffered and stored in an in-memory cache for instant consumption on
|
|
8
|
+
* subsequent navigation.
|
|
9
|
+
*
|
|
10
|
+
* In-flight promises are tracked in the cache so that navigation can reuse
|
|
11
|
+
* a prefetch that is still downloading instead of starting a duplicate request.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
buildPrefetchKey,
|
|
16
|
+
hasPrefetch,
|
|
17
|
+
markPrefetchInflight,
|
|
18
|
+
setInflightPromise,
|
|
19
|
+
storePrefetch,
|
|
20
|
+
clearPrefetchInflight,
|
|
21
|
+
currentGeneration,
|
|
22
|
+
} from "./cache.js";
|
|
23
|
+
import { getRangoState } from "../rango-state.js";
|
|
24
|
+
import { enqueuePrefetch } from "./queue.js";
|
|
25
|
+
import { shouldPrefetch } from "./policy.js";
|
|
26
|
+
import { debugLog } from "../logging.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a URL resolves to the current page (same pathname + search).
|
|
30
|
+
* Used to prevent same-page prefetching with prefetchKey, which would
|
|
31
|
+
* produce a trivial diff that corrupts the wildcard cache.
|
|
32
|
+
*/
|
|
33
|
+
function isSamePage(url: string): boolean {
|
|
34
|
+
try {
|
|
35
|
+
const target = new URL(url, window.location.origin);
|
|
36
|
+
return (
|
|
37
|
+
target.pathname + target.search ===
|
|
38
|
+
window.location.pathname + window.location.search
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build an RSC partial URL for prefetching.
|
|
47
|
+
* Includes _rsc_segments so the server can diff against currently mounted
|
|
48
|
+
* segments, and _rsc_v for version mismatch detection.
|
|
49
|
+
* Returns null for malformed or cross-origin URLs.
|
|
50
|
+
*/
|
|
51
|
+
function buildPrefetchUrl(
|
|
52
|
+
url: string,
|
|
53
|
+
segmentIds: string[],
|
|
54
|
+
version?: string,
|
|
55
|
+
routerId?: string,
|
|
56
|
+
): URL | null {
|
|
57
|
+
let targetUrl: URL;
|
|
58
|
+
try {
|
|
59
|
+
targetUrl = new URL(url, window.location.origin);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (targetUrl.origin !== window.location.origin) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
targetUrl.searchParams.set("_rsc_partial", "true");
|
|
67
|
+
if (segmentIds.length > 0) {
|
|
68
|
+
targetUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
|
|
69
|
+
}
|
|
70
|
+
if (version) {
|
|
71
|
+
targetUrl.searchParams.set("_rsc_v", version);
|
|
72
|
+
}
|
|
73
|
+
if (routerId) {
|
|
74
|
+
targetUrl.searchParams.set("_rsc_rid", routerId);
|
|
75
|
+
}
|
|
76
|
+
return targetUrl;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Core prefetch fetch logic. Fetches the response, tees the body, and stores
|
|
81
|
+
* one branch in the in-memory cache. The returned Promise resolves to the
|
|
82
|
+
* sibling navigation branch (or null on failure) so navigation can safely
|
|
83
|
+
* reuse an in-flight prefetch via consumeInflightPrefetch().
|
|
84
|
+
*/
|
|
85
|
+
function executePrefetchFetch(
|
|
86
|
+
key: string,
|
|
87
|
+
fetchUrl: string,
|
|
88
|
+
signal?: AbortSignal,
|
|
89
|
+
): Promise<Response | null> {
|
|
90
|
+
const gen = currentGeneration();
|
|
91
|
+
markPrefetchInflight(key);
|
|
92
|
+
|
|
93
|
+
const promise: Promise<Response | null> = fetch(fetchUrl, {
|
|
94
|
+
priority: "low" as RequestPriority,
|
|
95
|
+
signal,
|
|
96
|
+
headers: {
|
|
97
|
+
"X-Rango-State": getRangoState(),
|
|
98
|
+
"X-RSC-Router-Client-Path": window.location.href,
|
|
99
|
+
"X-Rango-Prefetch": "1",
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
.then((response) => {
|
|
103
|
+
if (!response.ok) return null;
|
|
104
|
+
// Don't buffer with arrayBuffer() — that blocks until the entire
|
|
105
|
+
// body downloads, defeating streaming for slow loaders.
|
|
106
|
+
// Tee the body: one branch for navigation, one for cache storage.
|
|
107
|
+
const [navStream, cacheStream] = response.body!.tee();
|
|
108
|
+
const responseInit = {
|
|
109
|
+
headers: response.headers,
|
|
110
|
+
status: response.status,
|
|
111
|
+
statusText: response.statusText,
|
|
112
|
+
};
|
|
113
|
+
storePrefetch(key, new Response(cacheStream, responseInit), gen);
|
|
114
|
+
return new Response(navStream, responseInit);
|
|
115
|
+
})
|
|
116
|
+
.catch(() => null)
|
|
117
|
+
.finally(() => {
|
|
118
|
+
clearPrefetchInflight(key);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
setInflightPromise(key, promise);
|
|
122
|
+
return promise;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Prefetch (direct): fetch with low priority and store in in-memory cache.
|
|
127
|
+
* Used by hover strategy -- fires immediately without queueing.
|
|
128
|
+
*/
|
|
129
|
+
export function prefetchDirect(
|
|
130
|
+
url: string,
|
|
131
|
+
segmentIds: string[],
|
|
132
|
+
version?: string,
|
|
133
|
+
routerId?: string,
|
|
134
|
+
prefetchKey?: string | ((from: string) => string),
|
|
135
|
+
): void {
|
|
136
|
+
if (!shouldPrefetch()) return;
|
|
137
|
+
|
|
138
|
+
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
139
|
+
if (!targetUrl) return;
|
|
140
|
+
// Skip same-page prefetch with prefetchKey — a same-page diff is trivial
|
|
141
|
+
// and would corrupt the wildcard cache entry for cross-page navigation.
|
|
142
|
+
if (prefetchKey != null && isSamePage(url)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
|
|
146
|
+
if (hasPrefetch(key)) {
|
|
147
|
+
debugLog("[prefetch] direct dedup (key already exists)", {
|
|
148
|
+
url,
|
|
149
|
+
key,
|
|
150
|
+
prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
debugLog("[prefetch] direct fetch", {
|
|
155
|
+
url,
|
|
156
|
+
key,
|
|
157
|
+
source: window.location.href,
|
|
158
|
+
prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
|
|
159
|
+
});
|
|
160
|
+
executePrefetchFetch(key, targetUrl.toString());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Prefetch (queued): goes through the concurrency-limited queue.
|
|
165
|
+
* Used by viewport/render strategies to avoid flooding the server.
|
|
166
|
+
* Returns the cache key for use in cleanup.
|
|
167
|
+
*/
|
|
168
|
+
export function prefetchQueued(
|
|
169
|
+
url: string,
|
|
170
|
+
segmentIds: string[],
|
|
171
|
+
version?: string,
|
|
172
|
+
routerId?: string,
|
|
173
|
+
prefetchKey?: string | ((from: string) => string),
|
|
174
|
+
): string {
|
|
175
|
+
if (!shouldPrefetch()) return "";
|
|
176
|
+
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
177
|
+
if (!targetUrl) return "";
|
|
178
|
+
// Skip same-page prefetch with prefetchKey — a same-page diff is trivial
|
|
179
|
+
// and would corrupt the wildcard cache entry for cross-page navigation.
|
|
180
|
+
if (prefetchKey != null && isSamePage(url)) {
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
|
|
184
|
+
if (hasPrefetch(key)) {
|
|
185
|
+
debugLog("[prefetch] queued dedup (key already exists)", {
|
|
186
|
+
url,
|
|
187
|
+
key,
|
|
188
|
+
prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
|
|
189
|
+
});
|
|
190
|
+
return key;
|
|
191
|
+
}
|
|
192
|
+
const fetchUrlStr = targetUrl.toString();
|
|
193
|
+
enqueuePrefetch(key, (signal) => {
|
|
194
|
+
// Re-check at execution time: a hover-triggered prefetchDirect may
|
|
195
|
+
// have started or completed this key while the item sat in the queue.
|
|
196
|
+
if (hasPrefetch(key)) return Promise.resolve();
|
|
197
|
+
// By execution time, the user may have navigated to the target page.
|
|
198
|
+
// A same-page prefetch produces a trivial diff that would overwrite
|
|
199
|
+
// the useful cross-page entry in the wildcard cache.
|
|
200
|
+
if (prefetchKey != null && isSamePage(url)) {
|
|
201
|
+
return Promise.resolve();
|
|
202
|
+
}
|
|
203
|
+
return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
|
|
204
|
+
});
|
|
205
|
+
return key;
|
|
206
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefetch Observer
|
|
3
|
+
*
|
|
4
|
+
* Shared singleton IntersectionObserver for viewport-based prefetching.
|
|
5
|
+
* One observer handles all Link components with prefetch="viewport".
|
|
6
|
+
*
|
|
7
|
+
* Lazy-created on first call to avoid issues in SSR or test environments
|
|
8
|
+
* where IntersectionObserver may not exist.
|
|
9
|
+
*
|
|
10
|
+
* Observation is one-shot: once a link enters the viewport and the callback
|
|
11
|
+
* fires, the element is unobserved. This prevents re-prefetching when a link
|
|
12
|
+
* scrolls in and out repeatedly.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type PrefetchCallback = () => void;
|
|
16
|
+
|
|
17
|
+
const callbacks = new Map<Element, PrefetchCallback>();
|
|
18
|
+
let observer: IntersectionObserver | null = null;
|
|
19
|
+
|
|
20
|
+
function getObserver(): IntersectionObserver {
|
|
21
|
+
if (!observer) {
|
|
22
|
+
observer = new IntersectionObserver(
|
|
23
|
+
(entries) => {
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (entry.isIntersecting) {
|
|
26
|
+
const callback = callbacks.get(entry.target);
|
|
27
|
+
if (callback) {
|
|
28
|
+
observer!.unobserve(entry.target);
|
|
29
|
+
callbacks.delete(entry.target);
|
|
30
|
+
callback();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{ rootMargin: "200px" },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return observer;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Observe an element for viewport intersection.
|
|
43
|
+
* When the element becomes visible (within 200px margin), the callback fires
|
|
44
|
+
* and the element is automatically unobserved.
|
|
45
|
+
* No-op in environments without IntersectionObserver (SSR, some test runners).
|
|
46
|
+
*/
|
|
47
|
+
export function observeForPrefetch(
|
|
48
|
+
element: Element,
|
|
49
|
+
onVisible: PrefetchCallback,
|
|
50
|
+
): void {
|
|
51
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
52
|
+
callbacks.set(element, onVisible);
|
|
53
|
+
getObserver().observe(element);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stop observing an element. Used for cleanup when a Link unmounts
|
|
58
|
+
* before entering the viewport.
|
|
59
|
+
*/
|
|
60
|
+
export function unobserveForPrefetch(element: Element): void {
|
|
61
|
+
callbacks.delete(element);
|
|
62
|
+
if (observer) {
|
|
63
|
+
observer.unobserve(element);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefetch Policy
|
|
3
|
+
*
|
|
4
|
+
* Determines whether speculative prefetching should run for the current user.
|
|
5
|
+
* Honors browser reduced-data preferences when available.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isPrefetchCacheDisabled } from "./cache.js";
|
|
9
|
+
|
|
10
|
+
type NavigatorWithConnection = Navigator & {
|
|
11
|
+
connection?: {
|
|
12
|
+
saveData?: boolean;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Evaluate on every call so runtime changes to Save-Data or
|
|
18
|
+
* prefers-reduced-data are respected immediately.
|
|
19
|
+
*/
|
|
20
|
+
export function shouldPrefetch(): boolean {
|
|
21
|
+
if (typeof window === "undefined") return false;
|
|
22
|
+
|
|
23
|
+
// When prefetchCacheTTL is false/0, prefetching is fully disabled —
|
|
24
|
+
// no point issuing requests whose responses will be discarded.
|
|
25
|
+
if (isPrefetchCacheDisabled()) return false;
|
|
26
|
+
|
|
27
|
+
const nav =
|
|
28
|
+
typeof navigator !== "undefined"
|
|
29
|
+
? (navigator as NavigatorWithConnection)
|
|
30
|
+
: undefined;
|
|
31
|
+
|
|
32
|
+
if (nav?.connection?.saveData) return false;
|
|
33
|
+
|
|
34
|
+
if (typeof window.matchMedia === "function") {
|
|
35
|
+
try {
|
|
36
|
+
if (window.matchMedia("(prefers-reduced-data: reduce)").matches) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Ignore unsupported query errors and allow prefetch.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** No-op, kept for test compatibility. */
|
|
48
|
+
export function resetPrefetchPolicy(): void {}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefetch Queue
|
|
3
|
+
*
|
|
4
|
+
* Concurrency-limited FIFO queue for speculative prefetches (viewport/render).
|
|
5
|
+
* Hover prefetches bypass this queue — they fire directly for immediate response
|
|
6
|
+
* to user intent.
|
|
7
|
+
*
|
|
8
|
+
* Draining waits for an idle main-thread moment and for viewport images to
|
|
9
|
+
* finish loading, so prefetch fetch() calls never compete with critical
|
|
10
|
+
* resources for the browser's connection pool.
|
|
11
|
+
*
|
|
12
|
+
* When a navigation starts, queued prefetches are cancelled but executing ones
|
|
13
|
+
* are left running. Navigation can reuse their in-flight responses via the
|
|
14
|
+
* prefetch cache's inflight promise map, avoiding duplicate requests.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { wait, waitForIdle, waitForViewportImages } from "./resource-ready.js";
|
|
18
|
+
|
|
19
|
+
const MAX_CONCURRENT = 2;
|
|
20
|
+
const IMAGE_WAIT_TIMEOUT = 2000;
|
|
21
|
+
|
|
22
|
+
let active = 0;
|
|
23
|
+
const queue: Array<{
|
|
24
|
+
key: string;
|
|
25
|
+
execute: (signal: AbortSignal) => Promise<void>;
|
|
26
|
+
}> = [];
|
|
27
|
+
const queued = new Set<string>();
|
|
28
|
+
const executing = new Set<string>();
|
|
29
|
+
const abortControllers = new Map<string, AbortController>();
|
|
30
|
+
let drainScheduled = false;
|
|
31
|
+
let drainGeneration = 0;
|
|
32
|
+
|
|
33
|
+
function startExecution(
|
|
34
|
+
key: string,
|
|
35
|
+
execute: (signal: AbortSignal) => Promise<void>,
|
|
36
|
+
): void {
|
|
37
|
+
active++;
|
|
38
|
+
executing.add(key);
|
|
39
|
+
const ac = new AbortController();
|
|
40
|
+
abortControllers.set(key, ac);
|
|
41
|
+
execute(ac.signal).finally(() => {
|
|
42
|
+
abortControllers.delete(key);
|
|
43
|
+
// Only decrement if this key wasn't already cleared by cancelAllPrefetches.
|
|
44
|
+
// Without this guard, cancelled tasks' .finally() would underflow active
|
|
45
|
+
// below zero, breaking the MAX_CONCURRENT guarantee.
|
|
46
|
+
if (executing.delete(key)) {
|
|
47
|
+
active--;
|
|
48
|
+
}
|
|
49
|
+
scheduleDrain();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Schedule a drain after the browser is idle and viewport images are loaded.
|
|
55
|
+
* Coalesces multiple drain requests into a single deferred callback so
|
|
56
|
+
* batch completion doesn't schedule redundant waits.
|
|
57
|
+
*
|
|
58
|
+
* The two-step wait ensures prefetch fetch() calls don't compete with
|
|
59
|
+
* images for the browser's connection pool:
|
|
60
|
+
* 1. waitForIdle — yield until the main thread has a quiet moment
|
|
61
|
+
* 2. waitForViewportImages OR 2s timeout — yield until visible images
|
|
62
|
+
* finish loading, but don't let slow/broken images block indefinitely
|
|
63
|
+
*/
|
|
64
|
+
function scheduleDrain(): void {
|
|
65
|
+
if (drainScheduled) return;
|
|
66
|
+
if (active >= MAX_CONCURRENT || queue.length === 0) return;
|
|
67
|
+
drainScheduled = true;
|
|
68
|
+
const gen = drainGeneration;
|
|
69
|
+
waitForIdle()
|
|
70
|
+
.then(() =>
|
|
71
|
+
Promise.race([waitForViewportImages(), wait(IMAGE_WAIT_TIMEOUT)]),
|
|
72
|
+
)
|
|
73
|
+
.then(() => {
|
|
74
|
+
drainScheduled = false;
|
|
75
|
+
// Stale drain: a cancel/abort happened while we were waiting.
|
|
76
|
+
// A fresh scheduleDrain will be called by whatever enqueues next.
|
|
77
|
+
if (gen !== drainGeneration) return;
|
|
78
|
+
if (queue.length > 0) drain();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function drain(): void {
|
|
83
|
+
while (active < MAX_CONCURRENT && queue.length > 0) {
|
|
84
|
+
const item = queue.shift()!;
|
|
85
|
+
queued.delete(item.key);
|
|
86
|
+
startExecution(item.key, item.execute);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Enqueue a prefetch for concurrency-limited execution.
|
|
92
|
+
* Execution is deferred until the browser is idle and viewport images
|
|
93
|
+
* have finished loading, so prefetches never compete with critical
|
|
94
|
+
* resources. Deduplicates by key — items already queued or executing
|
|
95
|
+
* are skipped.
|
|
96
|
+
*
|
|
97
|
+
* The executor receives an AbortSignal that is aborted when
|
|
98
|
+
* cancelAllPrefetches() is called (e.g. on navigation start).
|
|
99
|
+
*/
|
|
100
|
+
export function enqueuePrefetch(
|
|
101
|
+
key: string,
|
|
102
|
+
execute: (signal: AbortSignal) => Promise<void>,
|
|
103
|
+
): void {
|
|
104
|
+
if (queued.has(key) || executing.has(key)) return;
|
|
105
|
+
|
|
106
|
+
queued.add(key);
|
|
107
|
+
queue.push({ key, execute });
|
|
108
|
+
scheduleDrain();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Cancel queued prefetches and abort in-flight ones that don't match
|
|
113
|
+
* the current navigation target. If `keepUrl` is provided, the
|
|
114
|
+
* executing prefetch whose key contains that URL is kept alive so
|
|
115
|
+
* navigation can reuse its response via consumeInflightPrefetch.
|
|
116
|
+
*
|
|
117
|
+
* Called when a navigation starts via the NavigationProvider's
|
|
118
|
+
* event controller subscription.
|
|
119
|
+
*/
|
|
120
|
+
export function cancelAllPrefetches(keepUrl?: string | null): void {
|
|
121
|
+
queue.length = 0;
|
|
122
|
+
queued.clear();
|
|
123
|
+
drainScheduled = false;
|
|
124
|
+
drainGeneration++;
|
|
125
|
+
|
|
126
|
+
// Abort in-flight prefetches that aren't for the navigation target.
|
|
127
|
+
// Keys use format "sourceHref\0targetPathname+search" — match the
|
|
128
|
+
// target portion (after \0) against keepUrl.
|
|
129
|
+
for (const [key, ac] of abortControllers) {
|
|
130
|
+
const target = key.split("\0")[1];
|
|
131
|
+
if (keepUrl && target && keepUrl.startsWith(target)) continue;
|
|
132
|
+
ac.abort();
|
|
133
|
+
abortControllers.delete(key);
|
|
134
|
+
if (executing.delete(key)) {
|
|
135
|
+
active--;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Hard-cancel everything including in-flight prefetches.
|
|
142
|
+
* Used by clearPrefetchCache (server action invalidation) where
|
|
143
|
+
* in-flight responses would be stale.
|
|
144
|
+
*/
|
|
145
|
+
export function abortAllPrefetches(): void {
|
|
146
|
+
for (const ac of abortControllers.values()) {
|
|
147
|
+
ac.abort();
|
|
148
|
+
}
|
|
149
|
+
abortControllers.clear();
|
|
150
|
+
|
|
151
|
+
queue.length = 0;
|
|
152
|
+
queued.clear();
|
|
153
|
+
// Clear executing before resetting active. In-flight .finally() callbacks
|
|
154
|
+
// check executing.delete(key) — if the key is gone, they skip decrementing,
|
|
155
|
+
// so active settles at 0 without underflow.
|
|
156
|
+
executing.clear();
|
|
157
|
+
active = 0;
|
|
158
|
+
drainScheduled = false;
|
|
159
|
+
drainGeneration++;
|
|
160
|
+
}
|