@rangojs/router 0.0.0-experimental.0f44aca1
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 +5 -0
- package/README.md +899 -0
- package/dist/bin/rango.js +1601 -0
- package/dist/vite/index.js +5214 -0
- package/package.json +176 -0
- package/skills/breadcrumbs/SKILL.md +250 -0
- package/skills/cache-guide/SKILL.md +262 -0
- package/skills/caching/SKILL.md +220 -0
- package/skills/composability/SKILL.md +172 -0
- package/skills/debug-manifest/SKILL.md +112 -0
- package/skills/document-cache/SKILL.md +182 -0
- package/skills/fonts/SKILL.md +167 -0
- package/skills/hooks/SKILL.md +704 -0
- package/skills/host-router/SKILL.md +218 -0
- package/skills/intercept/SKILL.md +313 -0
- package/skills/layout/SKILL.md +310 -0
- package/skills/links/SKILL.md +239 -0
- package/skills/loader/SKILL.md +596 -0
- package/skills/middleware/SKILL.md +339 -0
- package/skills/mime-routes/SKILL.md +128 -0
- package/skills/parallel/SKILL.md +305 -0
- package/skills/prerender/SKILL.md +643 -0
- package/skills/rango/SKILL.md +118 -0
- package/skills/response-routes/SKILL.md +411 -0
- package/skills/route/SKILL.md +385 -0
- package/skills/router-setup/SKILL.md +439 -0
- package/skills/tailwind/SKILL.md +129 -0
- package/skills/theme/SKILL.md +79 -0
- package/skills/typesafety/SKILL.md +623 -0
- package/skills/use-cache/SKILL.md +324 -0
- package/src/__internal.ts +273 -0
- 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/event-controller.ts +899 -0
- package/src/browser/history-state.ts +80 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/intercept-utils.ts +52 -0
- package/src/browser/link-interceptor.ts +141 -0
- package/src/browser/logging.ts +55 -0
- package/src/browser/merge-segment-loaders.ts +134 -0
- package/src/browser/navigation-bridge.ts +645 -0
- package/src/browser/navigation-client.ts +215 -0
- package/src/browser/navigation-store.ts +806 -0
- package/src/browser/navigation-transaction.ts +295 -0
- package/src/browser/network-error-handler.ts +61 -0
- package/src/browser/partial-update.ts +550 -0
- package/src/browser/prefetch/cache.ts +146 -0
- package/src/browser/prefetch/fetch.ts +135 -0
- package/src/browser/prefetch/observer.ts +65 -0
- package/src/browser/prefetch/policy.ts +42 -0
- package/src/browser/prefetch/queue.ts +88 -0
- package/src/browser/rango-state.ts +112 -0
- package/src/browser/react/Link.tsx +360 -0
- package/src/browser/react/NavigationProvider.tsx +386 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +59 -0
- package/src/browser/react/filter-segment-order.ts +11 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +162 -0
- package/src/browser/react/location-state.ts +107 -0
- package/src/browser/react/mount-context.ts +37 -0
- 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 +218 -0
- package/src/browser/react/use-client-cache.ts +58 -0
- package/src/browser/react/use-handle.ts +162 -0
- package/src/browser/react/use-href.tsx +40 -0
- package/src/browser/react/use-link-status.ts +135 -0
- package/src/browser/react/use-mount.ts +31 -0
- package/src/browser/react/use-navigation.ts +99 -0
- package/src/browser/react/use-params.ts +65 -0
- package/src/browser/react/use-pathname.ts +47 -0
- package/src/browser/react/use-router.ts +63 -0
- package/src/browser/react/use-search-params.ts +56 -0
- package/src/browser/react/use-segments.ts +171 -0
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +431 -0
- package/src/browser/scroll-restoration.ts +400 -0
- package/src/browser/segment-reconciler.ts +216 -0
- package/src/browser/segment-structure-assert.ts +83 -0
- package/src/browser/server-action-bridge.ts +667 -0
- package/src/browser/shallow.ts +40 -0
- package/src/browser/types.ts +538 -0
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +438 -0
- package/src/build/generate-route-types.ts +36 -0
- package/src/build/index.ts +35 -0
- package/src/build/route-trie.ts +265 -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 +411 -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 +469 -0
- package/src/build/route-types/scan-filter.ts +78 -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 +338 -0
- package/src/cache/cache-scope.ts +382 -0
- package/src/cache/cf/cf-cache-store.ts +540 -0
- package/src/cache/cf/index.ts +25 -0
- package/src/cache/document-cache.ts +369 -0
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/handle-snapshot.ts +41 -0
- package/src/cache/index.ts +43 -0
- package/src/cache/memory-segment-store.ts +328 -0
- 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 +98 -0
- package/src/cache/types.ts +342 -0
- package/src/client.rsc.tsx +85 -0
- package/src/client.tsx +601 -0
- package/src/component-utils.ts +76 -0
- package/src/components/DefaultDocument.tsx +27 -0
- package/src/context-var.ts +86 -0
- package/src/debug.ts +243 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +365 -0
- package/src/handle.ts +135 -0
- package/src/handles/MetaTags.tsx +246 -0
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +7 -0
- package/src/handles/meta.ts +264 -0
- package/src/host/cookie-handler.ts +165 -0
- package/src/host/errors.ts +97 -0
- package/src/host/index.ts +53 -0
- package/src/host/pattern-matcher.ts +214 -0
- package/src/host/router.ts +352 -0
- package/src/host/testing.ts +79 -0
- package/src/host/types.ts +146 -0
- package/src/host/utils.ts +25 -0
- package/src/href-client.ts +222 -0
- package/src/index.rsc.ts +233 -0
- package/src/index.ts +277 -0
- package/src/internal-debug.ts +11 -0
- package/src/loader.rsc.ts +89 -0
- package/src/loader.ts +64 -0
- package/src/network-error-thrower.tsx +23 -0
- package/src/outlet-context.ts +15 -0
- package/src/outlet-provider.tsx +45 -0
- package/src/prerender/param-hash.ts +37 -0
- package/src/prerender/store.ts +185 -0
- package/src/prerender.ts +463 -0
- package/src/reverse.ts +330 -0
- package/src/root-error-boundary.tsx +289 -0
- package/src/route-content-wrapper.tsx +196 -0
- package/src/route-definition/dsl-helpers.ts +934 -0
- package/src/route-definition/helper-factories.ts +200 -0
- package/src/route-definition/helpers-types.ts +430 -0
- package/src/route-definition/index.ts +52 -0
- package/src/route-definition/redirect.ts +93 -0
- package/src/route-definition.ts +1 -0
- package/src/route-map-builder.ts +275 -0
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +259 -0
- package/src/router/content-negotiation.ts +116 -0
- package/src/router/debug-manifest.ts +72 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/find-match.ts +158 -0
- package/src/router/handler-context.ts +451 -0
- package/src/router/intercept-resolution.ts +395 -0
- package/src/router/lazy-includes.ts +234 -0
- package/src/router/loader-resolution.ts +420 -0
- package/src/router/logging.ts +248 -0
- package/src/router/manifest.ts +267 -0
- package/src/router/match-api.ts +620 -0
- package/src/router/match-context.ts +266 -0
- package/src/router/match-handlers.ts +440 -0
- package/src/router/match-middleware/background-revalidation.ts +223 -0
- package/src/router/match-middleware/cache-lookup.ts +634 -0
- package/src/router/match-middleware/cache-store.ts +295 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +306 -0
- package/src/router/match-middleware/segment-resolution.ts +192 -0
- package/src/router/match-pipelines.ts +179 -0
- package/src/router/match-result.ts +219 -0
- package/src/router/metrics.ts +282 -0
- package/src/router/middleware-cookies.ts +55 -0
- package/src/router/middleware-types.ts +222 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +563 -0
- package/src/router/prerender-match.ts +402 -0
- package/src/router/preview-match.ts +170 -0
- package/src/router/revalidation.ts +289 -0
- package/src/router/router-context.ts +316 -0
- package/src/router/router-interfaces.ts +452 -0
- package/src/router/router-options.ts +592 -0
- package/src/router/router-registry.ts +24 -0
- package/src/router/segment-resolution/fresh.ts +570 -0
- package/src/router/segment-resolution/helpers.ts +263 -0
- package/src/router/segment-resolution/loader-cache.ts +198 -0
- package/src/router/segment-resolution/revalidation.ts +1239 -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 +289 -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 +170 -0
- package/src/router.ts +1002 -0
- package/src/rsc/handler-context.ts +45 -0
- package/src/rsc/handler.ts +1089 -0
- package/src/rsc/helpers.ts +198 -0
- package/src/rsc/index.ts +36 -0
- package/src/rsc/loader-fetch.ts +209 -0
- package/src/rsc/manifest-init.ts +86 -0
- package/src/rsc/nonce.ts +32 -0
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +379 -0
- package/src/rsc/response-error.ts +37 -0
- package/src/rsc/response-route-handler.ts +347 -0
- package/src/rsc/rsc-rendering.ts +235 -0
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +348 -0
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +263 -0
- package/src/search-params.ts +230 -0
- package/src/segment-system.tsx +454 -0
- package/src/server/context.ts +591 -0
- package/src/server/cookie-store.ts +190 -0
- package/src/server/fetchable-loader-store.ts +37 -0
- package/src/server/handle-store.ts +308 -0
- package/src/server/loader-registry.ts +133 -0
- package/src/server/request-context.ts +914 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +51 -0
- package/src/ssr/index.tsx +365 -0
- package/src/static-handler.ts +114 -0
- package/src/theme/ThemeProvider.tsx +297 -0
- package/src/theme/ThemeScript.tsx +61 -0
- package/src/theme/constants.ts +62 -0
- package/src/theme/index.ts +48 -0
- package/src/theme/theme-context.ts +44 -0
- package/src/theme/theme-script.ts +155 -0
- package/src/theme/types.ts +182 -0
- package/src/theme/use-theme.ts +44 -0
- 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 +687 -0
- package/src/types/index.ts +88 -0
- package/src/types/loader-types.ts +183 -0
- package/src/types/route-config.ts +170 -0
- package/src/types/route-entry.ts +102 -0
- package/src/types/segments.ts +148 -0
- package/src/types.ts +1 -0
- package/src/urls/include-helper.ts +197 -0
- package/src/urls/index.ts +53 -0
- package/src/urls/path-helper-types.ts +339 -0
- package/src/urls/path-helper.ts +329 -0
- package/src/urls/pattern-types.ts +95 -0
- package/src/urls/response-types.ts +106 -0
- package/src/urls/type-extraction.ts +372 -0
- package/src/urls/urls-function.ts +98 -0
- package/src/urls.ts +1 -0
- package/src/use-loader.tsx +354 -0
- package/src/vite/discovery/bundle-postprocess.ts +184 -0
- package/src/vite/discovery/discover-routers.ts +344 -0
- package/src/vite/discovery/prerender-collection.ts +385 -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 +110 -0
- package/src/vite/discovery/virtual-module-codegen.ts +203 -0
- package/src/vite/index.ts +16 -0
- package/src/vite/plugin-types.ts +131 -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/expose-action-id.ts +365 -0
- package/src/vite/plugins/expose-id-utils.ts +287 -0
- package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +179 -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 +569 -0
- package/src/vite/plugins/refresh-cmd.ts +65 -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 +254 -0
- package/src/vite/plugins/version.d.ts +12 -0
- package/src/vite/plugins/virtual-entries.ts +123 -0
- package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
- package/src/vite/rango.ts +510 -0
- package/src/vite/router-discovery.ts +785 -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/utils/package-resolution.ts +121 -0
- package/src/vite/utils/prerender-utils.ts +189 -0
- package/src/vite/utils/shared-utils.ts +169 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NavigationStore,
|
|
3
|
+
NavigationClient,
|
|
4
|
+
UpdateSubscriber,
|
|
5
|
+
ResolvedSegment,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import * as React from "react";
|
|
9
|
+
import { startTransition } from "react";
|
|
10
|
+
|
|
11
|
+
// addTransitionType is only available in React experimental
|
|
12
|
+
const addTransitionType: ((type: string) => void) | undefined =
|
|
13
|
+
"addTransitionType" in React ? (React as any).addTransitionType : undefined;
|
|
14
|
+
import type { RenderSegmentsOptions } from "../segment-system.js";
|
|
15
|
+
import { reconcileSegments } from "./segment-reconciler.js";
|
|
16
|
+
import type { ReconcileActor } from "./segment-reconciler.js";
|
|
17
|
+
import { hasActiveIntercept as hasActiveInterceptSlots } from "./intercept-utils.js";
|
|
18
|
+
import type { BoundTransaction } from "./navigation-transaction.js";
|
|
19
|
+
import { ServerRedirect } from "../errors.js";
|
|
20
|
+
import { debugLog } from "./logging.js";
|
|
21
|
+
import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Configuration for creating a partial updater
|
|
25
|
+
*/
|
|
26
|
+
export interface PartialUpdateConfig {
|
|
27
|
+
store: NavigationStore;
|
|
28
|
+
client: NavigationClient;
|
|
29
|
+
onUpdate: UpdateSubscriber;
|
|
30
|
+
renderSegments: (
|
|
31
|
+
segments: ResolvedSegment[],
|
|
32
|
+
options?: RenderSegmentsOptions,
|
|
33
|
+
) => Promise<ReactNode> | ReactNode;
|
|
34
|
+
/** RSC version received from server (from initial payload metadata) */
|
|
35
|
+
version?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Options that can override the pre-configured commit settings
|
|
40
|
+
*/
|
|
41
|
+
export interface CommitOverrides {
|
|
42
|
+
/** Override scroll behavior (e.g., disable for intercepts) */
|
|
43
|
+
scroll?: boolean;
|
|
44
|
+
/** Override replace behavior (e.g., force replace for intercepts) */
|
|
45
|
+
replace?: boolean;
|
|
46
|
+
/** Mark this as an intercept route */
|
|
47
|
+
intercept?: boolean;
|
|
48
|
+
/** Source URL where intercept was triggered from */
|
|
49
|
+
interceptSourceUrl?: string;
|
|
50
|
+
/** Server-set location state to merge into history.pushState */
|
|
51
|
+
serverState?: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Discriminated update mode for partial updates.
|
|
56
|
+
*/
|
|
57
|
+
export type UpdateMode =
|
|
58
|
+
| {
|
|
59
|
+
type: "navigate";
|
|
60
|
+
/** Cached segments for the target URL. When provided, these are used to build
|
|
61
|
+
* the segment map instead of the current page's segments. This ensures consistency
|
|
62
|
+
* when we send cached segment IDs to the server - if the server returns empty diff,
|
|
63
|
+
* we use the same segments we told the server we have. */
|
|
64
|
+
targetCacheSegments?: ResolvedSegment[];
|
|
65
|
+
/** Cached handle data for the target URL. When server returns empty diff and we're
|
|
66
|
+
* rendering from cache, this is passed to the UI to restore breadcrumbs etc. */
|
|
67
|
+
targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
|
|
68
|
+
/** Source URL for intercept restore (popstate cache miss) */
|
|
69
|
+
interceptSourceUrl?: string;
|
|
70
|
+
}
|
|
71
|
+
| { type: "leave-intercept" }
|
|
72
|
+
| { type: "stale-revalidation"; interceptSourceUrl?: string }
|
|
73
|
+
| { type: "action"; interceptSourceUrl?: string };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Type for the fetchPartialUpdate function
|
|
77
|
+
*/
|
|
78
|
+
export type PartialUpdater = (
|
|
79
|
+
targetUrl: string,
|
|
80
|
+
segmentIds: string[] | undefined,
|
|
81
|
+
isRetry: boolean,
|
|
82
|
+
signal: AbortSignal | undefined,
|
|
83
|
+
tx: BoundTransaction,
|
|
84
|
+
mode?: UpdateMode,
|
|
85
|
+
) => Promise<void>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create a partial updater for fetching and applying RSC partial updates
|
|
89
|
+
*
|
|
90
|
+
* This function is shared between navigation-bridge and server-action-bridge
|
|
91
|
+
* to handle partial RSC updates with HMR resilience.
|
|
92
|
+
*
|
|
93
|
+
* @param config - Partial update configuration
|
|
94
|
+
* @returns fetchPartialUpdate function
|
|
95
|
+
*/
|
|
96
|
+
export function createPartialUpdater(
|
|
97
|
+
config: PartialUpdateConfig,
|
|
98
|
+
): PartialUpdater {
|
|
99
|
+
const { store, client, onUpdate, renderSegments, version } = config;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get current page's cached segments as an array
|
|
103
|
+
*/
|
|
104
|
+
function getCurrentCachedSegments(): ResolvedSegment[] {
|
|
105
|
+
const currentKey = store.getHistoryKey();
|
|
106
|
+
const cached = store.getCachedSegments(currentKey);
|
|
107
|
+
return cached?.segments || [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fetch partial update and trigger UI update
|
|
112
|
+
*
|
|
113
|
+
* @param tx - Transaction for committing segment state (required)
|
|
114
|
+
* @param signal - AbortSignal to check if navigation is stale (not for aborting fetch)
|
|
115
|
+
*/
|
|
116
|
+
async function fetchPartialUpdate(
|
|
117
|
+
targetUrl: string,
|
|
118
|
+
segmentIds: string[] | undefined,
|
|
119
|
+
isRetry: boolean,
|
|
120
|
+
signal: AbortSignal | undefined,
|
|
121
|
+
tx: BoundTransaction,
|
|
122
|
+
mode: UpdateMode = { type: "navigate" },
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const segmentState = store.getSegmentState();
|
|
125
|
+
const url = targetUrl || window.location.href;
|
|
126
|
+
|
|
127
|
+
// Capture history key at start for stale revalidation consistency check
|
|
128
|
+
const historyKeyAtStart = store.getHistoryKey();
|
|
129
|
+
|
|
130
|
+
// Derive interceptSourceUrl from modes that carry it
|
|
131
|
+
const interceptSourceUrl =
|
|
132
|
+
mode.type === "stale-revalidation" ||
|
|
133
|
+
mode.type === "action" ||
|
|
134
|
+
mode.type === "navigate"
|
|
135
|
+
? mode.interceptSourceUrl
|
|
136
|
+
: undefined;
|
|
137
|
+
|
|
138
|
+
// When leaving intercept, filter out intercept-specific segments
|
|
139
|
+
let segments: string[];
|
|
140
|
+
if (mode.type === "leave-intercept") {
|
|
141
|
+
const currentSegments = segmentIds ?? segmentState.currentSegmentIds;
|
|
142
|
+
const currentCached = getCurrentCachedSegments();
|
|
143
|
+
const interceptIds = new Set(
|
|
144
|
+
currentCached
|
|
145
|
+
.filter((s) => s.namespace?.startsWith("intercept:"))
|
|
146
|
+
.map((s) => s.id),
|
|
147
|
+
);
|
|
148
|
+
segments = currentSegments.filter((id) => !interceptIds.has(id));
|
|
149
|
+
debugLog(
|
|
150
|
+
`[Browser] Leaving intercept - filtered segments: ${segments.join(", ")}`,
|
|
151
|
+
);
|
|
152
|
+
} else {
|
|
153
|
+
segments = segmentIds ?? segmentState.currentSegmentIds;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// For intercept revalidation, use the intercept source URL as previousUrl
|
|
157
|
+
const previousUrl =
|
|
158
|
+
interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
|
|
159
|
+
|
|
160
|
+
debugLog(`\n[Browser] >>> NAVIGATION`);
|
|
161
|
+
debugLog(`[Browser] From: ${previousUrl}`);
|
|
162
|
+
debugLog(`[Browser] To: ${url}`);
|
|
163
|
+
debugLog(`[Browser] Segments to send: ${segments.join(", ")}`);
|
|
164
|
+
if (interceptSourceUrl) {
|
|
165
|
+
debugLog(`[Browser] Intercept context from: ${interceptSourceUrl}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Get cached segments for merging with server diff.
|
|
169
|
+
// When navigating with targetCacheSegments, use those for consistency.
|
|
170
|
+
// Otherwise fall back to current page's segments (for same-route revalidation).
|
|
171
|
+
const targetCache =
|
|
172
|
+
mode.type === "navigate" ? mode.targetCacheSegments : undefined;
|
|
173
|
+
const cachedSegs =
|
|
174
|
+
targetCache && targetCache.length > 0
|
|
175
|
+
? targetCache
|
|
176
|
+
: getCurrentCachedSegments();
|
|
177
|
+
|
|
178
|
+
// Fetch partial payload (no abort signal - RSC doesn't support it well)
|
|
179
|
+
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
180
|
+
fetchResult = await client.fetchPartial({
|
|
181
|
+
targetUrl: url,
|
|
182
|
+
segmentIds: segments,
|
|
183
|
+
previousUrl,
|
|
184
|
+
// Mark stale when explicitly requested OR when no segments are sent
|
|
185
|
+
// (action redirect sends empty segments for a fresh render).
|
|
186
|
+
staleRevalidation:
|
|
187
|
+
mode.type === "stale-revalidation" || segments.length === 0,
|
|
188
|
+
version,
|
|
189
|
+
});
|
|
190
|
+
// Mark navigation as streaming (response received, now parsing RSC).
|
|
191
|
+
// Called after fetchPartial so pendingUrl stays set during the network wait,
|
|
192
|
+
// allowing useLinkStatus to show per-link pending indicators.
|
|
193
|
+
const streamingToken = tx.startStreaming();
|
|
194
|
+
const { payload, streamComplete: rawStreamComplete } = fetchResult;
|
|
195
|
+
debugLog("payload.metadata", payload.metadata);
|
|
196
|
+
|
|
197
|
+
const streamComplete = rawStreamComplete.then(() => {
|
|
198
|
+
streamingToken.end();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Handle server-side redirect with state
|
|
202
|
+
if (payload.metadata?.redirect) {
|
|
203
|
+
if (signal?.aborted) {
|
|
204
|
+
debugLog("[Browser] Ignoring stale redirect (aborted)");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const redirectUrl = validateRedirectOrigin(
|
|
208
|
+
payload.metadata.redirect.url,
|
|
209
|
+
window.location.origin,
|
|
210
|
+
);
|
|
211
|
+
if (!redirectUrl) {
|
|
212
|
+
debugLog("[Browser] Ignoring blocked redirect payload");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const serverState = payload.metadata.locationState;
|
|
216
|
+
throw new ServerRedirect(redirectUrl, serverState);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (payload.metadata?.isPartial) {
|
|
220
|
+
const { segments: newSegments, matched, diff } = payload.metadata;
|
|
221
|
+
|
|
222
|
+
// Check if this navigation is stale (a newer one started)
|
|
223
|
+
if (signal?.aborted) {
|
|
224
|
+
debugLog("[Browser] Ignoring stale navigation (aborted)");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
|
|
229
|
+
debugLog(`[Browser] Diff: ${diff?.join(", ")}`);
|
|
230
|
+
|
|
231
|
+
// If diff is empty, nothing changed on server side.
|
|
232
|
+
if (!diff || diff.length === 0) {
|
|
233
|
+
const matchedIds = matched || [];
|
|
234
|
+
const cacheMap = new Map(cachedSegs.map((s) => [s.id, s]));
|
|
235
|
+
const existingSegments = matchedIds
|
|
236
|
+
.map((id: string) => cacheMap.get(id))
|
|
237
|
+
.filter(Boolean) as ResolvedSegment[];
|
|
238
|
+
|
|
239
|
+
// When navigating with cached segments to a different route, render them.
|
|
240
|
+
if (mode.type === "navigate" && targetCache && targetCache.length > 0) {
|
|
241
|
+
debugLog(
|
|
242
|
+
"[Browser] No diff but navigating with cached segments - rendering target route",
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const newTree = await renderSegments(existingSegments, {
|
|
246
|
+
forceAwait: true,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
tx.commit(matchedIds, existingSegments);
|
|
250
|
+
|
|
251
|
+
// Include cachedHandleData in metadata so NavigationProvider can restore
|
|
252
|
+
// breadcrumbs and other handle data from cache.
|
|
253
|
+
// Remove `handles` from metadata to prevent NavigationProvider from
|
|
254
|
+
// processing an empty handles stream, which would clear the cached breadcrumbs.
|
|
255
|
+
const { handles: _unusedHandles, ...metadataWithoutHandles } =
|
|
256
|
+
payload.metadata!;
|
|
257
|
+
const cachedUpdate = {
|
|
258
|
+
root: newTree,
|
|
259
|
+
metadata: {
|
|
260
|
+
...metadataWithoutHandles,
|
|
261
|
+
cachedHandleData: mode.targetCacheHandleData,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const cachedHasTransition = existingSegments.some(
|
|
266
|
+
(s) => s.transition,
|
|
267
|
+
);
|
|
268
|
+
if (cachedHasTransition) {
|
|
269
|
+
startTransition(() => {
|
|
270
|
+
if (addTransitionType) {
|
|
271
|
+
addTransitionType("navigation");
|
|
272
|
+
}
|
|
273
|
+
onUpdate(cachedUpdate);
|
|
274
|
+
});
|
|
275
|
+
} else {
|
|
276
|
+
onUpdate(cachedUpdate);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
debugLog("[Browser] Navigation complete (rendered from cache)");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// When leaving intercept, force re-render even with empty diff
|
|
284
|
+
if (mode.type === "leave-intercept") {
|
|
285
|
+
debugLog(
|
|
286
|
+
"[Browser] Leaving intercept - forcing re-render to remove modal",
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const newTree = await renderSegments(existingSegments, {
|
|
290
|
+
forceAwait: true,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
tx.commit(matchedIds, existingSegments);
|
|
294
|
+
|
|
295
|
+
onUpdate({
|
|
296
|
+
root: newTree,
|
|
297
|
+
metadata: payload.metadata,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
debugLog("[Browser] Navigation complete (left intercept)");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Same route revalidation with no changes - skip UI update
|
|
305
|
+
debugLog(
|
|
306
|
+
"[Browser] No changes - all revalidations returned false, keeping existing UI",
|
|
307
|
+
);
|
|
308
|
+
tx.commit(matchedIds, existingSegments);
|
|
309
|
+
debugLog("[Browser] Navigation complete (no re-render)");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Reconcile server segments with cached segments (single source of truth)
|
|
314
|
+
const matchedIds = matched || [];
|
|
315
|
+
const actor: ReconcileActor =
|
|
316
|
+
mode.type === "stale-revalidation" || mode.type === "action"
|
|
317
|
+
? "stale-revalidation"
|
|
318
|
+
: "navigation";
|
|
319
|
+
|
|
320
|
+
const reconciled = reconcileSegments({
|
|
321
|
+
actor,
|
|
322
|
+
matched: matchedIds,
|
|
323
|
+
diff: diff || [],
|
|
324
|
+
serverSegments: newSegments || [],
|
|
325
|
+
cachedSegments: cachedSegs,
|
|
326
|
+
insertMissingDiff: true,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// HMR RESILIENCE: Check if we're missing any matched segments
|
|
330
|
+
const reconciledIdSet = new Set(reconciled.segments.map((s) => s.id));
|
|
331
|
+
const missingIds = matchedIds.filter(
|
|
332
|
+
(id: string) => !reconciledIdSet.has(id),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
if (missingIds.length > 0) {
|
|
336
|
+
const missingCount = missingIds.length;
|
|
337
|
+
|
|
338
|
+
if (isRetry) {
|
|
339
|
+
console.warn("Missing ids", { missingIds });
|
|
340
|
+
throw new Error(
|
|
341
|
+
`[Browser] Failed to fetch segments after retry. Missing: [${missingIds.join(", ")}]`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (signal?.aborted) {
|
|
345
|
+
debugLog(
|
|
346
|
+
"[Browser] Ignoring stale navigation (aborted during HMR retry)",
|
|
347
|
+
);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (mode.type === "action") {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
console.warn(
|
|
354
|
+
`[Browser] HMR detected: Missing ${missingCount} segments. Refetching all...`,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Refetch with empty segments = server sends everything
|
|
358
|
+
return fetchPartialUpdate(url, [], true, signal, tx, mode);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (signal?.aborted) {
|
|
362
|
+
debugLog("[Browser] Ignoring stale navigation (aborted before render)");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Rebuild tree on client (await for loader data resolution)
|
|
367
|
+
const renderOptions = {
|
|
368
|
+
isAction: mode.type === "action",
|
|
369
|
+
forceAwait: mode.type === "stale-revalidation",
|
|
370
|
+
interceptSegments:
|
|
371
|
+
reconciled.interceptSegments.length > 0
|
|
372
|
+
? reconciled.interceptSegments
|
|
373
|
+
: undefined,
|
|
374
|
+
};
|
|
375
|
+
const newTree = await (signal
|
|
376
|
+
? Promise.race([
|
|
377
|
+
renderSegments(reconciled.mainSegments, renderOptions),
|
|
378
|
+
new Promise<never>((_, reject) => {
|
|
379
|
+
if (signal.aborted) {
|
|
380
|
+
reject(new DOMException("Navigation aborted", "AbortError"));
|
|
381
|
+
}
|
|
382
|
+
signal.addEventListener("abort", () => {
|
|
383
|
+
reject(new DOMException("Navigation aborted", "AbortError"));
|
|
384
|
+
});
|
|
385
|
+
}),
|
|
386
|
+
])
|
|
387
|
+
: renderSegments(reconciled.mainSegments, renderOptions));
|
|
388
|
+
|
|
389
|
+
// Final abort check before committing - another navigation may have started
|
|
390
|
+
if (signal?.aborted) {
|
|
391
|
+
debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Check if this is an intercept response (any slot is active)
|
|
396
|
+
const isInterceptResponse = hasActiveInterceptSlots(
|
|
397
|
+
payload.metadata?.slots,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// Track intercept context (only on navigation, not actions or stale revalidation)
|
|
401
|
+
// Use the authoritative source from mode/history state when restoring an
|
|
402
|
+
// intercept via popstate cache miss; fall back to the current URL for fresh
|
|
403
|
+
// intercept navigations.
|
|
404
|
+
const effectiveInterceptSource =
|
|
405
|
+
interceptSourceUrl || segmentState.currentUrl;
|
|
406
|
+
if (mode.type !== "action" && mode.type !== "stale-revalidation") {
|
|
407
|
+
if (isInterceptResponse) {
|
|
408
|
+
store.setInterceptSourceUrl(effectiveInterceptSource);
|
|
409
|
+
} else {
|
|
410
|
+
store.setInterceptSourceUrl(null);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Commit navigation - transaction handles all store mutations atomically
|
|
415
|
+
const allSegmentIds = reconciled.segments.map((s) => s.id);
|
|
416
|
+
const serverLocationState = payload.metadata?.locationState;
|
|
417
|
+
const overrides: CommitOverrides | undefined = isInterceptResponse
|
|
418
|
+
? {
|
|
419
|
+
scroll: false,
|
|
420
|
+
intercept: true,
|
|
421
|
+
interceptSourceUrl: effectiveInterceptSource,
|
|
422
|
+
...(serverLocationState && { serverState: serverLocationState }),
|
|
423
|
+
}
|
|
424
|
+
: serverLocationState
|
|
425
|
+
? { serverState: serverLocationState }
|
|
426
|
+
: undefined;
|
|
427
|
+
tx.commit(allSegmentIds, reconciled.segments, overrides);
|
|
428
|
+
|
|
429
|
+
// For stale revalidation: verify history key hasn't changed before updating UI
|
|
430
|
+
if (mode.type === "stale-revalidation") {
|
|
431
|
+
const historyKeyNow = store.getHistoryKey();
|
|
432
|
+
if (historyKeyNow !== historyKeyAtStart) {
|
|
433
|
+
debugLog(
|
|
434
|
+
`[Browser] Stale revalidation: history key changed (${historyKeyAtStart} -> ${historyKeyNow}), skipping UI update`,
|
|
435
|
+
);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
debugLog("[partial-update] updating document");
|
|
441
|
+
|
|
442
|
+
// Emit update to trigger React render
|
|
443
|
+
const hasTransition = reconciled.mainSegments.some((s) => s.transition);
|
|
444
|
+
|
|
445
|
+
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
446
|
+
startTransition(() => {
|
|
447
|
+
if (hasTransition && addTransitionType) {
|
|
448
|
+
addTransitionType("action");
|
|
449
|
+
}
|
|
450
|
+
onUpdate({
|
|
451
|
+
root: newTree,
|
|
452
|
+
metadata: payload.metadata!,
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
} else if (hasTransition) {
|
|
456
|
+
startTransition(() => {
|
|
457
|
+
if (addTransitionType) {
|
|
458
|
+
addTransitionType("navigation");
|
|
459
|
+
}
|
|
460
|
+
onUpdate({
|
|
461
|
+
root: newTree,
|
|
462
|
+
metadata: payload.metadata!,
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
} else {
|
|
466
|
+
onUpdate({
|
|
467
|
+
root: newTree,
|
|
468
|
+
metadata: payload.metadata!,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
debugLog("[Browser] Navigation complete");
|
|
473
|
+
return;
|
|
474
|
+
} else {
|
|
475
|
+
// Full update (fallback)
|
|
476
|
+
console.warn(`[Browser] Full update (fallback)`);
|
|
477
|
+
|
|
478
|
+
const segments = payload.metadata?.segments || [];
|
|
479
|
+
|
|
480
|
+
if (signal?.aborted) {
|
|
481
|
+
debugLog("[Browser] Ignoring stale navigation (aborted)");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const segmentIds = segments.map((s: ResolvedSegment) => s.id);
|
|
486
|
+
|
|
487
|
+
const newTree = await renderSegments(segments);
|
|
488
|
+
|
|
489
|
+
if (signal?.aborted) {
|
|
490
|
+
debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const fullUpdateServerState = payload.metadata?.locationState;
|
|
495
|
+
if (fullUpdateServerState) {
|
|
496
|
+
tx.commit(segmentIds, segments, { serverState: fullUpdateServerState });
|
|
497
|
+
} else {
|
|
498
|
+
tx.commit(segmentIds, segments);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const fullHasTransition = segments.some(
|
|
502
|
+
(s: ResolvedSegment) => s.transition,
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
if (mode.type === "stale-revalidation") {
|
|
506
|
+
await rawStreamComplete;
|
|
507
|
+
startTransition(() => {
|
|
508
|
+
if (fullHasTransition && addTransitionType) {
|
|
509
|
+
addTransitionType("action");
|
|
510
|
+
}
|
|
511
|
+
onUpdate({
|
|
512
|
+
root: newTree,
|
|
513
|
+
metadata: payload.metadata!,
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
} else if (mode.type === "action") {
|
|
517
|
+
startTransition(async () => {
|
|
518
|
+
if (fullHasTransition && addTransitionType) {
|
|
519
|
+
addTransitionType("action");
|
|
520
|
+
}
|
|
521
|
+
onUpdate({
|
|
522
|
+
root: newTree,
|
|
523
|
+
metadata: payload.metadata!,
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
} else if (fullHasTransition) {
|
|
527
|
+
startTransition(() => {
|
|
528
|
+
if (addTransitionType) {
|
|
529
|
+
addTransitionType("navigation");
|
|
530
|
+
}
|
|
531
|
+
onUpdate({
|
|
532
|
+
root: newTree,
|
|
533
|
+
metadata: payload.metadata!,
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
} else {
|
|
537
|
+
onUpdate({
|
|
538
|
+
root: newTree,
|
|
539
|
+
metadata: payload.metadata!,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return fetchPartialUpdate;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export { createPartialUpdater as default };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefetch Cache
|
|
3
|
+
*
|
|
4
|
+
* In-memory cache storing prefetch 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
|
+
* Replaces the previous browser HTTP cache approach which was unreliable
|
|
10
|
+
* due to response draining race conditions and browser inconsistencies.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { cancelAllPrefetches } from "./queue.js";
|
|
14
|
+
import { invalidateRangoState } from "../rango-state.js";
|
|
15
|
+
|
|
16
|
+
// Default TTL: 5 minutes. Overridden by initPrefetchCache() with
|
|
17
|
+
// the server-configured prefetchCacheTTL from router options.
|
|
18
|
+
// 0 disables the in-memory cache entirely.
|
|
19
|
+
let cacheTTL = 300_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Initialize the prefetch cache with the configured TTL.
|
|
23
|
+
* Called once at app startup with the value from server metadata.
|
|
24
|
+
* A TTL of 0 disables the in-memory cache.
|
|
25
|
+
*/
|
|
26
|
+
export function initPrefetchCache(ttlMs: number): void {
|
|
27
|
+
cacheTTL = ttlMs;
|
|
28
|
+
}
|
|
29
|
+
const MAX_PREFETCH_CACHE_SIZE = 50;
|
|
30
|
+
|
|
31
|
+
interface PrefetchCacheEntry {
|
|
32
|
+
response: Response;
|
|
33
|
+
timestamp: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cache = new Map<string, PrefetchCacheEntry>();
|
|
37
|
+
const inflight = new Set<string>();
|
|
38
|
+
|
|
39
|
+
// Generation counter incremented on each clearPrefetchCache(). Fetches that
|
|
40
|
+
// started before a clear carry a stale generation and must not store their
|
|
41
|
+
// response (the data may be stale due to a server action invalidation).
|
|
42
|
+
let generation = 0;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a source-dependent cache key.
|
|
46
|
+
* Includes the source page href so the same target prefetched from
|
|
47
|
+
* different pages gets separate entries — the server response varies
|
|
48
|
+
* based on the source page context (diff-based rendering).
|
|
49
|
+
*/
|
|
50
|
+
export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
|
|
51
|
+
return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a prefetch is already cached, in-flight, or queued for the given key.
|
|
56
|
+
*/
|
|
57
|
+
export function hasPrefetch(key: string): boolean {
|
|
58
|
+
if (inflight.has(key)) return true;
|
|
59
|
+
if (cacheTTL <= 0) return false;
|
|
60
|
+
const entry = cache.get(key);
|
|
61
|
+
if (!entry) return false;
|
|
62
|
+
if (Date.now() - entry.timestamp > cacheTTL) {
|
|
63
|
+
cache.delete(key);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Consume a cached prefetch response. Returns null if not found or expired.
|
|
71
|
+
* One-time consumption: the entry is deleted after retrieval.
|
|
72
|
+
* Returns null when caching is disabled (TTL <= 0).
|
|
73
|
+
*/
|
|
74
|
+
export function consumePrefetch(key: string): Response | null {
|
|
75
|
+
if (cacheTTL <= 0) return null;
|
|
76
|
+
const entry = cache.get(key);
|
|
77
|
+
if (!entry) return null;
|
|
78
|
+
if (Date.now() - entry.timestamp > cacheTTL) {
|
|
79
|
+
cache.delete(key);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
cache.delete(key);
|
|
83
|
+
return entry.response;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Store a prefetch response in the in-memory cache.
|
|
88
|
+
* The response body must be fully buffered (e.g. via arrayBuffer()) before
|
|
89
|
+
* storing, so the cached Response is self-contained and network-independent.
|
|
90
|
+
*
|
|
91
|
+
* Skips storage if the generation has changed since the fetch started
|
|
92
|
+
* (a server action invalidated the cache mid-flight).
|
|
93
|
+
*/
|
|
94
|
+
export function storePrefetch(
|
|
95
|
+
key: string,
|
|
96
|
+
response: Response,
|
|
97
|
+
fetchGeneration: number,
|
|
98
|
+
): void {
|
|
99
|
+
if (cacheTTL <= 0) return;
|
|
100
|
+
if (fetchGeneration !== generation) return;
|
|
101
|
+
|
|
102
|
+
// Evict expired entries
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
for (const [k, entry] of cache) {
|
|
105
|
+
if (now - entry.timestamp > cacheTTL) {
|
|
106
|
+
cache.delete(k);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// FIFO eviction if at capacity
|
|
111
|
+
if (cache.size >= MAX_PREFETCH_CACHE_SIZE) {
|
|
112
|
+
const oldest = cache.keys().next().value;
|
|
113
|
+
if (oldest) cache.delete(oldest);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
cache.set(key, { response, timestamp: now });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Capture the current generation. The returned value is passed to
|
|
121
|
+
* storePrefetch so it can detect stale completions.
|
|
122
|
+
*/
|
|
123
|
+
export function currentGeneration(): number {
|
|
124
|
+
return generation;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function markPrefetchInflight(key: string): void {
|
|
128
|
+
inflight.add(key);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function clearPrefetchInflight(key: string): void {
|
|
132
|
+
inflight.delete(key);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Invalidate all prefetch state. Called when server actions mutate data.
|
|
137
|
+
* Clears the in-memory cache, cancels in-flight prefetches, and rotates
|
|
138
|
+
* the Rango state key so CDN-cached responses are also invalidated.
|
|
139
|
+
*/
|
|
140
|
+
export function clearPrefetchCache(): void {
|
|
141
|
+
generation++;
|
|
142
|
+
inflight.clear();
|
|
143
|
+
cache.clear();
|
|
144
|
+
cancelAllPrefetches();
|
|
145
|
+
invalidateRangoState();
|
|
146
|
+
}
|