@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.124
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/dist/bin/rango.js +7 -2
- package/dist/vite/index.js +47 -6
- package/package.json +61 -21
- package/skills/cache-guide/SKILL.md +8 -6
- package/skills/caching/SKILL.md +148 -1
- package/skills/hooks/SKILL.md +38 -27
- package/skills/host-router/SKILL.md +16 -2
- package/skills/intercept/SKILL.md +4 -2
- package/skills/layout/SKILL.md +11 -6
- package/skills/loader/SKILL.md +6 -2
- package/skills/middleware/SKILL.md +4 -2
- package/skills/migrate-nextjs/SKILL.md +38 -16
- package/skills/parallel/SKILL.md +9 -4
- package/skills/rango/SKILL.md +27 -15
- package/skills/route/SKILL.md +4 -2
- package/skills/testing/SKILL.md +129 -0
- package/skills/testing/bindings.md +89 -0
- package/skills/testing/cache-prerender.md +98 -0
- package/skills/testing/client-components.md +122 -0
- package/skills/testing/e2e-parity.md +125 -0
- package/skills/testing/flight.md +89 -0
- package/skills/testing/handles.md +129 -0
- package/skills/testing/loader.md +128 -0
- package/skills/testing/middleware.md +99 -0
- package/skills/testing/render-handler.md +118 -0
- package/skills/testing/response-routes.md +95 -0
- package/skills/testing/reverse-and-types.md +84 -0
- package/skills/testing/server-actions.md +107 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +120 -0
- package/skills/use-cache/SKILL.md +9 -7
- package/src/browser/action-fence.ts +37 -0
- package/src/browser/cookie-name.ts +140 -0
- package/src/browser/invalidate-client-cache.ts +52 -0
- package/src/browser/navigation-bridge.ts +14 -1
- package/src/browser/navigation-client.ts +14 -1
- package/src/browser/navigation-store-handle.ts +39 -0
- package/src/browser/navigation-store.ts +26 -12
- package/src/browser/prefetch/fetch.ts +7 -0
- package/src/browser/rango-state.ts +176 -97
- package/src/browser/react/index.ts +0 -6
- package/src/browser/rsc-router.tsx +12 -4
- package/src/browser/server-action-bridge.ts +77 -15
- package/src/browser/types.ts +7 -1
- package/src/cache/cache-error.ts +104 -0
- package/src/cache/cache-policy.ts +95 -1
- package/src/cache/cache-runtime.ts +79 -13
- package/src/cache/cache-scope.ts +55 -4
- package/src/cache/cache-tag.ts +135 -0
- package/src/cache/cf/cf-cache-store.ts +2080 -224
- package/src/cache/cf/index.ts +15 -1
- package/src/cache/document-cache.ts +74 -7
- package/src/cache/index.ts +17 -0
- package/src/cache/memory-segment-store.ts +164 -14
- package/src/cache/tag-invalidation.ts +230 -0
- package/src/cache/types.ts +27 -0
- package/src/client.rsc.tsx +1 -1
- package/src/client.tsx +0 -6
- package/src/component-utils.ts +19 -0
- package/src/handle.ts +29 -9
- package/src/host/testing.ts +43 -14
- package/src/index.rsc.ts +29 -1
- package/src/index.ts +43 -1
- package/src/loader.rsc.ts +24 -3
- package/src/loader.ts +16 -2
- package/src/prerender.ts +24 -3
- package/src/router/basename.ts +14 -0
- package/src/router/match-handlers.ts +62 -20
- package/src/router/prerender-match.ts +6 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +30 -0
- package/src/router/segment-resolution/loader-cache.ts +8 -17
- package/src/router/state-cookie-name.ts +33 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router.ts +36 -7
- package/src/rsc/handler.ts +13 -1
- package/src/rsc/helpers.ts +19 -0
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +8 -1
- package/src/rsc/rsc-rendering.ts +2 -0
- package/src/rsc/types.ts +2 -0
- package/src/runtime-env.ts +18 -0
- package/src/server/cookie-store.ts +52 -1
- package/src/server/request-context.ts +105 -2
- package/src/static-handler.ts +25 -3
- package/src/testing/cache-status.ts +166 -0
- package/src/testing/collect-handle.ts +63 -0
- package/src/testing/dispatch.ts +581 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +188 -0
- package/src/testing/e2e/index.ts +149 -0
- package/src/testing/e2e/matchers.ts +51 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +387 -0
- package/src/testing/e2e/server.ts +195 -0
- package/src/testing/flight-matchers.ts +110 -0
- package/src/testing/flight-normalize.ts +38 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +682 -0
- package/src/testing/flight.entry.ts +52 -0
- package/src/testing/flight.ts +234 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +119 -0
- package/src/testing/internal/context.ts +390 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +80 -0
- package/src/testing/render-handler.ts +360 -0
- package/src/testing/render-route.tsx +594 -0
- package/src/testing/run-loader.ts +474 -0
- package/src/testing/run-middleware.ts +231 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +305 -0
- package/src/types/cache-types.ts +13 -4
- package/src/types/error-types.ts +5 -1
- package/src/types/global-namespace.ts +11 -1
- package/src/types/handler-context.ts +16 -5
- package/src/browser/react/use-client-cache.ts +0 -58
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
ResolvedSegment,
|
|
6
6
|
} from "./types.js";
|
|
7
7
|
import { setAppVersion } from "./app-version.js";
|
|
8
|
+
import { isActionFenceActive } from "./action-fence.js";
|
|
9
|
+
import { getRangoState } from "./rango-state.js";
|
|
8
10
|
import * as React from "react";
|
|
9
11
|
import { startTransition } from "react";
|
|
10
12
|
import {
|
|
@@ -446,11 +448,22 @@ export function createNavigationBridge(
|
|
|
446
448
|
// Helper to check if streaming is in progress
|
|
447
449
|
const isStreaming = () => eventController.getState().isStreaming;
|
|
448
450
|
|
|
451
|
+
// Surface any external rotation of the rango state cookie (a server
|
|
452
|
+
// Set-Cookie, a sibling tab, a cookie clear) BEFORE reading the stale bit.
|
|
453
|
+
// The divergence observer only runs inside getRangoState() — fetch-time —
|
|
454
|
+
// so a popstate-first interaction would otherwise serve a pre-mutation
|
|
455
|
+
// page as fresh and never fetch to trigger the observer. Reading here lets
|
|
456
|
+
// the observer mark the history cache stale so getCachedSegments sees it.
|
|
457
|
+
getRangoState();
|
|
458
|
+
|
|
449
459
|
// Check if we can restore from history cache
|
|
450
460
|
const cached = store.getCachedSegments(historyKey);
|
|
451
461
|
const cachedSegments = cached?.segments;
|
|
452
462
|
const cachedHandleData = cached?.handleData;
|
|
453
|
-
|
|
463
|
+
// While an action is in flight the fence persists no stale flag, so OR it
|
|
464
|
+
// in here: a popstate during the flight serves the cached entry AND
|
|
465
|
+
// revalidates (SWR) instead of serving it as fresh.
|
|
466
|
+
const isStale = (cached?.stale ?? false) || isActionFenceActive();
|
|
454
467
|
|
|
455
468
|
if (cachedSegments && cachedSegments.length > 0) {
|
|
456
469
|
// Update store to point to this history entry
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
startBrowserTransaction,
|
|
13
13
|
} from "./logging.js";
|
|
14
14
|
import { getRangoState } from "./rango-state.js";
|
|
15
|
+
import { isActionFenceActive } from "./action-fence.js";
|
|
15
16
|
import {
|
|
16
17
|
extractRscHeaderUrl,
|
|
17
18
|
emptyResponse,
|
|
@@ -108,7 +109,14 @@ export function createNavigationClient(
|
|
|
108
109
|
// server-action invalidation) auto-invalidates both scopes.
|
|
109
110
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
110
111
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
111
|
-
|
|
112
|
+
// Suspend prefetch consumption while an action is in flight: a queued
|
|
113
|
+
// prefetch holds pre-mutation data and must not be served until the
|
|
114
|
+
// action's response decides whether anything changed.
|
|
115
|
+
const canUsePrefetch =
|
|
116
|
+
!staleRevalidation &&
|
|
117
|
+
!hmr &&
|
|
118
|
+
!interceptSourceUrl &&
|
|
119
|
+
!isActionFenceActive();
|
|
112
120
|
const rangoState = getRangoState();
|
|
113
121
|
const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
|
|
114
122
|
const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
|
|
@@ -207,6 +215,11 @@ export function createNavigationClient(
|
|
|
207
215
|
}
|
|
208
216
|
|
|
209
217
|
return fetch(fetchUrl, {
|
|
218
|
+
// During an action's flight the state is not rotated, so the old
|
|
219
|
+
// X-Rango-State still matches the Vary-keyed HTTP-cache entry; bypass
|
|
220
|
+
// it so a genuine mid-action navigation fetches fresh instead of being
|
|
221
|
+
// served the stale prefetched bytes.
|
|
222
|
+
...(isActionFenceActive() && { cache: "no-store" as RequestCache }),
|
|
210
223
|
headers: {
|
|
211
224
|
"X-RSC-Router-Client-Path": previousUrl,
|
|
212
225
|
"X-Rango-State": getRangoState(),
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A module-level handle to the active navigation store.
|
|
3
|
+
*
|
|
4
|
+
* The real boot path (`rsc-router.tsx`) calls `createNavigationStore()`
|
|
5
|
+
* directly, so the `getNavigationStore()` singleton in `navigation-store.ts`
|
|
6
|
+
* is never populated in a running app (it throws; only unit tests use it).
|
|
7
|
+
* This handle is the live reference for code that needs the store but does not
|
|
8
|
+
* receive it by argument: the jar-divergence observer (below) and the client
|
|
9
|
+
* seat of `invalidateClientCache()` (added later).
|
|
10
|
+
*
|
|
11
|
+
* Dependency-light on purpose: it imports only `setRangoStateObserver` and the
|
|
12
|
+
* store type, so pulling it into the default root entry does not drag the
|
|
13
|
+
* navigation store into bundles that previously lacked it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { setRangoStateObserver } from "./rango-state.js";
|
|
17
|
+
import type { NavigationStore } from "./types.js";
|
|
18
|
+
|
|
19
|
+
let registeredStore: NavigationStore | null = null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Register the active navigation store at boot, and wire the jar-divergence
|
|
23
|
+
* observer: when a per-request cookie read detects an EXTERNAL rotation (a
|
|
24
|
+
* sibling tab, a server `Set-Cookie`, or a cookie clear), mark this tab's
|
|
25
|
+
* history cache stale. The history cache is not state-keyed, so the value
|
|
26
|
+
* rotation alone does not reach it. No broadcast, no prefetch clear, no
|
|
27
|
+
* re-rotation — the value already changed externally.
|
|
28
|
+
*/
|
|
29
|
+
export function registerNavigationStore(store: NavigationStore): void {
|
|
30
|
+
registeredStore = store;
|
|
31
|
+
setRangoStateObserver(() => {
|
|
32
|
+
registeredStore?.markHistoryCacheStale();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The active navigation store, or null before boot has registered it. */
|
|
37
|
+
export function getRegisteredStore(): NavigationStore | null {
|
|
38
|
+
return registeredStore;
|
|
39
|
+
}
|
|
@@ -130,14 +130,14 @@ export interface NavigationStoreConfig {
|
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
132
|
* Enable cross-tab cache invalidation via BroadcastChannel (default: true)
|
|
133
|
-
* When cache is cleared (via server actions or
|
|
133
|
+
* When cache is cleared (via server actions or invalidateClientCache()),
|
|
134
134
|
* other tabs will also clear their cache
|
|
135
135
|
*/
|
|
136
136
|
crossTabSync?: boolean;
|
|
137
137
|
|
|
138
138
|
/**
|
|
139
139
|
* Auto-refresh when another tab mutates data on the same path (default: true)
|
|
140
|
-
* Triggered when cache is cleared via server actions or
|
|
140
|
+
* Triggered when cache is cleared via server actions or invalidateClientCache()
|
|
141
141
|
* Requires crossTabSync to be enabled
|
|
142
142
|
*/
|
|
143
143
|
crossTabAutoRefresh?: boolean;
|
|
@@ -335,12 +335,24 @@ export function createNavigationStore(
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
/**
|
|
338
|
-
* Mark
|
|
338
|
+
* Mark every history entry stale WITHOUT touching the prefetch caches or the
|
|
339
|
+
* rango state. Used by the jar-divergence observer: an external rotation has
|
|
340
|
+
* already changed the state value (so prefetch/HTTP entries strand under the
|
|
341
|
+
* retired key), and this tab must NOT re-rotate — only the history cache,
|
|
342
|
+
* which is not state-keyed, needs marking.
|
|
339
343
|
*/
|
|
340
|
-
function
|
|
344
|
+
function markHistoryStale(): void {
|
|
341
345
|
for (let i = 0; i < historyCache.length; i++) {
|
|
342
346
|
historyCache[i][2] = true;
|
|
343
347
|
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Mark all cache entries as stale (internal - does not broadcast). Also
|
|
352
|
+
* clears the prefetch caches, which rotates the rango state.
|
|
353
|
+
*/
|
|
354
|
+
function markCacheAsStaleInternal(): void {
|
|
355
|
+
markHistoryStale();
|
|
344
356
|
clearPrefetchCache();
|
|
345
357
|
}
|
|
346
358
|
|
|
@@ -659,6 +671,16 @@ export function createNavigationStore(
|
|
|
659
671
|
markCacheAsStaleInternal();
|
|
660
672
|
},
|
|
661
673
|
|
|
674
|
+
/**
|
|
675
|
+
* Mark every history entry stale WITHOUT clearing the prefetch caches or
|
|
676
|
+
* rotating the rango state. The jar-divergence observer calls this after an
|
|
677
|
+
* external rotation has already changed the state value, so re-rotating
|
|
678
|
+
* here would ping-pong with the tab that rotated.
|
|
679
|
+
*/
|
|
680
|
+
markHistoryCacheStale(): void {
|
|
681
|
+
markHistoryStale();
|
|
682
|
+
},
|
|
683
|
+
|
|
662
684
|
/**
|
|
663
685
|
* Clear the history cache and broadcast to other tabs
|
|
664
686
|
* Use this for hard invalidation when data is definitely stale
|
|
@@ -675,14 +697,6 @@ export function createNavigationStore(
|
|
|
675
697
|
markStaleAndBroadcast();
|
|
676
698
|
},
|
|
677
699
|
|
|
678
|
-
/**
|
|
679
|
-
* Broadcast cache invalidation to other tabs without clearing local cache
|
|
680
|
-
* Used after consolidation fetch where local cache has fresh data
|
|
681
|
-
*/
|
|
682
|
-
broadcastCacheInvalidation(): void {
|
|
683
|
-
broadcastInvalidation();
|
|
684
|
-
},
|
|
685
|
-
|
|
686
700
|
/**
|
|
687
701
|
* Set the callback to invoke when cross-tab refresh is triggered
|
|
688
702
|
* Called by navigation bridge during initialization
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type DecodedPrefetch,
|
|
28
28
|
} from "./cache.js";
|
|
29
29
|
import { getRangoState } from "../rango-state.js";
|
|
30
|
+
import { isActionFenceActive } from "../action-fence.js";
|
|
30
31
|
import { enqueuePrefetch } from "./queue.js";
|
|
31
32
|
import { shouldPrefetch } from "./policy.js";
|
|
32
33
|
import { debugLog } from "../logging.js";
|
|
@@ -150,6 +151,12 @@ function executePrefetchFetch(
|
|
|
150
151
|
|
|
151
152
|
const promise: Promise<DecodedPrefetch | null> = fetch(fetchUrl, {
|
|
152
153
|
priority: "low" as RequestPriority,
|
|
154
|
+
// During an action's flight the state is not rotated, so the old
|
|
155
|
+
// X-Rango-State still matches the Vary-keyed HTTP-cache entry; bypass it so
|
|
156
|
+
// a prefetch fetches fresh rather than warming the map with stale bytes (the
|
|
157
|
+
// fence's HTTP-cache-bypass requirement applies to prefetch as well as
|
|
158
|
+
// navigation fetches).
|
|
159
|
+
...(isActionFenceActive() && { cache: "no-store" as RequestCache }),
|
|
153
160
|
signal,
|
|
154
161
|
headers: {
|
|
155
162
|
"X-Rango-State": getRangoState(),
|
|
@@ -1,136 +1,215 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Rango State
|
|
3
3
|
*
|
|
4
|
-
* Manages a
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Manages a session-cookie-based state value for HTTP cache invalidation. The
|
|
5
|
+
* value is sent as the `X-Rango-State` header on prefetch and navigation
|
|
6
|
+
* requests; the server responds with `Vary: X-Rango-State`, so the browser HTTP
|
|
7
|
+
* cache keys responses by (URL, X-Rango-State value).
|
|
8
8
|
*
|
|
9
9
|
* Value format: `{buildVersion}:{invalidationTimestamp}`
|
|
10
|
-
* - Build version changes on deploy, busting all cached prefetches.
|
|
11
|
-
* - Timestamp
|
|
10
|
+
* - Build version changes on deploy, busting all cached prefetches at boot.
|
|
11
|
+
* - Timestamp rotates on invalidation (server action, invalidateClientCache).
|
|
12
12
|
*
|
|
13
|
-
* Storage
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* Storage is a session cookie named by the server-resolved name passed to
|
|
14
|
+
* initRangoState (`{prefix}_{routerId}`, default prefix `rango-state`). The
|
|
15
|
+
* cookie jar is shared across tabs, so a per-request read IS the cross-tab
|
|
16
|
+
* value sync — no `storage` event is needed. An in-memory mirror is a
|
|
17
|
+
* write-through copy that is authoritative only when the cookie is unreadable
|
|
18
|
+
* (e.g. a sandboxed frame, or site data blocked wholesale): the failure
|
|
19
|
+
* direction is always toward freshness.
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* Precedence is load-bearing: when `document.cookie` is readable, the
|
|
22
|
+
* per-request read wins; the mirror is a fallback, never a cache of the read.
|
|
23
|
+
* Caching the read across requests would reintroduce the staleness this
|
|
24
|
+
* mechanism removes.
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_STATE_COOKIE_PREFIX,
|
|
29
|
+
decodeStateValue,
|
|
30
|
+
getRawCookieValue,
|
|
31
|
+
mintStateValue,
|
|
32
|
+
serializeStateCookie,
|
|
33
|
+
} from "./cookie-name.js";
|
|
34
|
+
|
|
35
|
+
// The resolved cookie name this document is bound to (server-resolved, read
|
|
36
|
+
// from payload metadata at boot). Bare default until initRangoState runs.
|
|
37
|
+
let cookieName: string = DEFAULT_STATE_COOKIE_PREFIX;
|
|
38
|
+
|
|
39
|
+
// Build version for this document, used as the prefix of minted values.
|
|
40
|
+
let currentVersion = "0";
|
|
41
|
+
|
|
42
|
+
// Write-through mirror of the value. Authoritative only when the cookie is
|
|
43
|
+
// unreadable. `cookieBacked` records whether the mirror was last confirmed
|
|
44
|
+
// present in the jar, so a present->absent transition (an external clear) is
|
|
45
|
+
// detected exactly once instead of re-firing on every subsequent read.
|
|
46
|
+
let mirror: string | null = null;
|
|
47
|
+
let cookieBacked = false;
|
|
48
|
+
|
|
49
|
+
// External-rotation observer, registered by the store-handle wiring (so a
|
|
50
|
+
// sibling tab's rotation or a server Set-Cookie marks the history cache stale).
|
|
51
|
+
// Null until registered; self-rotations never call it.
|
|
52
|
+
let externalRotationObserver: ((value: string) => void) | null = null;
|
|
25
53
|
|
|
26
|
-
|
|
27
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Register the observer invoked when a read detects an EXTERNAL rotation (a
|
|
56
|
+
* sibling tab, a server `Set-Cookie`, or a cookie clear). Self-rotations
|
|
57
|
+
* (invalidateRangoState) update the mirror synchronously and never fire it.
|
|
58
|
+
*/
|
|
59
|
+
export function setRangoStateObserver(
|
|
60
|
+
observer: ((value: string) => void) | null,
|
|
61
|
+
): void {
|
|
62
|
+
externalRotationObserver = observer;
|
|
28
63
|
}
|
|
29
64
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
let cachedState: string | null = null;
|
|
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
|
-
|
|
39
|
-
// Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
|
|
40
|
-
// to localStorage, keeping cachedState fresh without polling.
|
|
41
|
-
let storageListenerAttached = false;
|
|
42
|
-
|
|
43
|
-
function attachStorageListener(): void {
|
|
44
|
-
if (storageListenerAttached || typeof window === "undefined") return;
|
|
45
|
-
window.addEventListener("storage", (e) => {
|
|
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;
|
|
50
|
-
cachedState = e.newValue;
|
|
51
|
-
});
|
|
52
|
-
storageListenerAttached = true;
|
|
65
|
+
function notifyExternalRotation(value: string): void {
|
|
66
|
+
externalRotationObserver?.(value);
|
|
53
67
|
}
|
|
54
68
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
65
|
-
*/
|
|
66
|
-
export function initRangoState(version: string, routerId?: string): void {
|
|
67
|
-
currentStorageKey = buildStorageKey(routerId);
|
|
68
|
-
if (typeof window === "undefined") return;
|
|
69
|
+
interface CookieRead {
|
|
70
|
+
/** False when there is no document or the read threw (sandboxed frame). */
|
|
71
|
+
readable: boolean;
|
|
72
|
+
/** The cookie value, or null when readable but absent. */
|
|
73
|
+
value: string | null;
|
|
74
|
+
}
|
|
69
75
|
|
|
70
|
-
|
|
76
|
+
function readCookie(name: string): CookieRead {
|
|
77
|
+
if (typeof document === "undefined") return { readable: false, value: null };
|
|
78
|
+
let raw: string;
|
|
79
|
+
try {
|
|
80
|
+
raw = document.cookie;
|
|
81
|
+
} catch {
|
|
82
|
+
return { readable: false, value: null };
|
|
83
|
+
}
|
|
84
|
+
// Shared parser with the server seat so both read the same jar entry.
|
|
85
|
+
return { readable: true, value: getRawCookieValue(raw, name) };
|
|
86
|
+
}
|
|
71
87
|
|
|
88
|
+
function writeCookie(name: string, value: string): void {
|
|
89
|
+
if (typeof document === "undefined") return;
|
|
90
|
+
const secure =
|
|
91
|
+
typeof location !== "undefined" && location.protocol === "https:";
|
|
72
92
|
try {
|
|
73
|
-
|
|
74
|
-
if (existing) {
|
|
75
|
-
const colonIdx = existing.indexOf(":");
|
|
76
|
-
if (colonIdx > 0) {
|
|
77
|
-
const existingVersion = existing.slice(0, colonIdx);
|
|
78
|
-
if (existingVersion === version) {
|
|
79
|
-
cachedState = existing;
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
// New version or first load
|
|
85
|
-
const newState = `${version}:${Date.now()}`;
|
|
86
|
-
localStorage.setItem(currentStorageKey, newState);
|
|
87
|
-
cachedState = newState;
|
|
93
|
+
document.cookie = serializeStateCookie(name, value, secure);
|
|
88
94
|
} catch {
|
|
89
|
-
//
|
|
90
|
-
cachedState = `${version}:${Date.now()}`;
|
|
95
|
+
// Write failures are silently absorbed; the mirror carries the value.
|
|
91
96
|
}
|
|
92
97
|
}
|
|
93
98
|
|
|
99
|
+
// Mint a fresh value: same version, a timestamp strictly greater than the
|
|
100
|
+
// current one (the in-memory mirror is the previous value). The monotonic guard
|
|
101
|
+
// lives in mintStateValue, shared with the server seat.
|
|
102
|
+
function mintValue(): string {
|
|
103
|
+
return mintStateValue(currentVersion, mirror);
|
|
104
|
+
}
|
|
105
|
+
|
|
94
106
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
107
|
+
* Initialize the Rango state cookie at app startup. `version` is the build
|
|
108
|
+
* version; `stateCookieName` is the server-resolved cookie name from payload
|
|
109
|
+
* metadata (falls back to the bare default prefix when a payload arrives
|
|
110
|
+
* without it). Keeps an existing matching-version cookie (preserves the cache
|
|
111
|
+
* key across reloads); mints fresh on a version change or a missing cookie.
|
|
112
|
+
*/
|
|
113
|
+
export function initRangoState(
|
|
114
|
+
version: string,
|
|
115
|
+
stateCookieName?: string,
|
|
116
|
+
): void {
|
|
117
|
+
currentVersion = version;
|
|
118
|
+
cookieName = stateCookieName || DEFAULT_STATE_COOKIE_PREFIX;
|
|
119
|
+
cleanupLegacyStorage();
|
|
120
|
+
|
|
121
|
+
const read = readCookie(cookieName);
|
|
122
|
+
if (!read.readable) {
|
|
123
|
+
// Cookies unreadable: the mirror is the source of truth for this session.
|
|
124
|
+
mirror = mintValue();
|
|
125
|
+
cookieBacked = false;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (read.value !== null) {
|
|
129
|
+
const decoded = decodeStateValue(read.value);
|
|
130
|
+
if (decoded && decoded.version === version) {
|
|
131
|
+
// Keep: a matching-version cookie survives the reload warm.
|
|
132
|
+
mirror = read.value;
|
|
133
|
+
cookieBacked = true;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Absent, malformed, or a version change (deploy): mint fresh and write.
|
|
138
|
+
mirror = mintValue();
|
|
139
|
+
cookieBacked = false;
|
|
140
|
+
writeCookie(cookieName, mirror);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get the current Rango state value, used as the `X-Rango-State` header on
|
|
145
|
+
* prefetch and navigation requests. Reads the cookie every call (the read is
|
|
146
|
+
* the cross-tab sync channel) and reconciles the mirror.
|
|
97
147
|
*/
|
|
98
148
|
export function getRangoState(): string {
|
|
99
|
-
|
|
149
|
+
const read = readCookie(cookieName);
|
|
100
150
|
|
|
101
|
-
if (
|
|
151
|
+
if (!read.readable) {
|
|
152
|
+
// Mirror authoritative when the jar is unreadable.
|
|
153
|
+
return mirror ?? "0:0";
|
|
154
|
+
}
|
|
102
155
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
156
|
+
if (read.value !== null) {
|
|
157
|
+
if (read.value !== mirror) {
|
|
158
|
+
// External rotation (sibling tab / server Set-Cookie): adopt it. The
|
|
159
|
+
// mirror update makes this idempotent across a burst of reads.
|
|
160
|
+
mirror = read.value;
|
|
161
|
+
cookieBacked = true;
|
|
162
|
+
notifyExternalRotation(read.value);
|
|
163
|
+
} else {
|
|
164
|
+
cookieBacked = true;
|
|
108
165
|
}
|
|
109
|
-
|
|
110
|
-
// Fallback for unavailable localStorage
|
|
166
|
+
return read.value;
|
|
111
167
|
}
|
|
112
168
|
|
|
113
|
-
|
|
169
|
+
// Readable but absent.
|
|
170
|
+
if (cookieBacked) {
|
|
171
|
+
// present -> absent: an external clear. Mint fresh, write back, and notify
|
|
172
|
+
// once (cookieBacked flips to false so we don't re-fire on the next read).
|
|
173
|
+
mirror = mintValue();
|
|
174
|
+
cookieBacked = false;
|
|
175
|
+
writeCookie(cookieName, mirror);
|
|
176
|
+
notifyExternalRotation(mirror);
|
|
177
|
+
} else if (mirror === null) {
|
|
178
|
+
// First access with no cookie yet (pre-boot): mint silently — there is
|
|
179
|
+
// nothing to invalidate.
|
|
180
|
+
mirror = mintValue();
|
|
181
|
+
writeCookie(cookieName, mirror);
|
|
182
|
+
}
|
|
183
|
+
return mirror;
|
|
114
184
|
}
|
|
115
185
|
|
|
116
186
|
/**
|
|
117
|
-
* Invalidate the Rango state
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
187
|
+
* Invalidate the Rango state (self-rotation). Called when the client clears its
|
|
188
|
+
* prefetch caches (e.g. via the server-action bridge). Rotates the timestamp,
|
|
189
|
+
* keeps the version, writes the cookie, and updates the mirror synchronously so
|
|
190
|
+
* the external-rotation observer is NOT triggered by our own write.
|
|
121
191
|
*/
|
|
122
192
|
export function invalidateRangoState(): void {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
cachedState = newState;
|
|
128
|
-
|
|
129
|
-
if (typeof window === "undefined") return;
|
|
193
|
+
mirror = mintValue();
|
|
194
|
+
cookieBacked = false;
|
|
195
|
+
writeCookie(cookieName, mirror);
|
|
196
|
+
}
|
|
130
197
|
|
|
198
|
+
// One-time migration: remove the legacy localStorage keys this mechanism used
|
|
199
|
+
// before the cookie cutover. No value porting — a fresh cookie mint just misses
|
|
200
|
+
// cleanly. Idempotent: scans for `rango-state` and `rango-state:{routerId}`.
|
|
201
|
+
function cleanupLegacyStorage(): void {
|
|
202
|
+
if (typeof localStorage === "undefined") return;
|
|
131
203
|
try {
|
|
132
|
-
|
|
204
|
+
const toRemove: string[] = [];
|
|
205
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
206
|
+
const key = localStorage.key(i);
|
|
207
|
+
if (key === "rango-state" || (key && key.startsWith("rango-state:"))) {
|
|
208
|
+
toRemove.push(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const key of toRemove) localStorage.removeItem(key);
|
|
133
212
|
} catch {
|
|
134
|
-
//
|
|
213
|
+
// localStorage unavailable; nothing to clean.
|
|
135
214
|
}
|
|
136
215
|
}
|
|
@@ -23,12 +23,6 @@ export { useHandle } from "./use-handle.js";
|
|
|
23
23
|
// Mount-aware reverse hook
|
|
24
24
|
export { useReverse } from "./use-reverse.js";
|
|
25
25
|
|
|
26
|
-
// Client cache controls hook
|
|
27
|
-
export {
|
|
28
|
-
useClientCache,
|
|
29
|
-
type ClientCacheControls,
|
|
30
|
-
} from "./use-client-cache.js";
|
|
31
|
-
|
|
32
26
|
// Provider
|
|
33
27
|
export {
|
|
34
28
|
NavigationProvider,
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
import type { EventController } from "./event-controller.js";
|
|
23
23
|
import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
|
|
24
24
|
import { initRangoState } from "./rango-state.js";
|
|
25
|
+
import { registerNavigationStore } from "./navigation-store-handle.js";
|
|
25
26
|
import { initPrefetchCache } from "./prefetch/cache.js";
|
|
26
27
|
import { setPrefetchDecoder } from "./prefetch/fetch.js";
|
|
27
28
|
import { setAppVersion } from "./app-version.js";
|
|
@@ -175,6 +176,12 @@ export async function initBrowserApp(
|
|
|
175
176
|
...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
|
|
176
177
|
});
|
|
177
178
|
|
|
179
|
+
// Register the active store on the module-level handle and wire the
|
|
180
|
+
// jar-divergence observer before any getRangoState() read can detect a
|
|
181
|
+
// cross-tab/server rotation. The real boot path never populates the
|
|
182
|
+
// getNavigationStore() singleton, so this handle is the live reference.
|
|
183
|
+
registerNavigationStore(store);
|
|
184
|
+
|
|
178
185
|
// Seed router identity from the initial SSR payload so the first
|
|
179
186
|
// cross-app SPA navigation can detect the app switch.
|
|
180
187
|
if (initialPayload.metadata?.routerId) {
|
|
@@ -228,10 +235,11 @@ export async function initBrowserApp(
|
|
|
228
235
|
version,
|
|
229
236
|
});
|
|
230
237
|
|
|
231
|
-
// Initialize the
|
|
232
|
-
//
|
|
233
|
-
// namespaces the
|
|
234
|
-
|
|
238
|
+
// Initialize the rango state cookie for cache invalidation. The build version
|
|
239
|
+
// busts cached prefetches on deploy; the server-resolved cookie name
|
|
240
|
+
// namespaces the cookie so sibling apps on the same origin don't collide
|
|
241
|
+
// (falls back to the bare default prefix if metadata lacks the name).
|
|
242
|
+
initRangoState(version ?? "0", initialPayload.metadata?.stateCookieName);
|
|
235
243
|
setAppVersion(version);
|
|
236
244
|
|
|
237
245
|
// Initialize the in-memory prefetch cache TTL from server config.
|