@ivogt/rsc-router 0.0.0-experimental.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/package.json +131 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +891 -0
- package/src/browser/navigation-client.ts +155 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +545 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +228 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +149 -0
- package/src/browser/rsc-router.tsx +310 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +443 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
- package/src/cache/cf/cf-cache-store.ts +274 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/index.ts +52 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +366 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +609 -0
- package/src/components/DefaultDocument.tsx +20 -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 +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +178 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href.ts +139 -0
- package/src/index.rsc.ts +69 -0
- package/src/index.ts +84 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1333 -0
- package/src/route-map-builder.ts +140 -0
- package/src/route-types.ts +148 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +60 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +116 -0
- package/src/router/match-context.ts +261 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +250 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +212 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +271 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3484 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +942 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +225 -0
- package/src/segment-system.tsx +405 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +340 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +470 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +126 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +215 -0
- package/src/types.ts +1473 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +608 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +109 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NavigationState,
|
|
3
|
+
NavigationLocation,
|
|
4
|
+
SegmentState,
|
|
5
|
+
NavigationStore,
|
|
6
|
+
NavigationUpdate,
|
|
7
|
+
UpdateSubscriber,
|
|
8
|
+
StateListener,
|
|
9
|
+
ResolvedSegment,
|
|
10
|
+
InflightAction,
|
|
11
|
+
TrackedActionState,
|
|
12
|
+
ActionStateListener,
|
|
13
|
+
HandleData,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default action state (idle with no payload)
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_ACTION_STATE: TrackedActionState = {
|
|
20
|
+
state: "idle",
|
|
21
|
+
actionId: null,
|
|
22
|
+
payload: null,
|
|
23
|
+
error: null,
|
|
24
|
+
result: null,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Maximum number of history entries to cache (URLs visited)
|
|
28
|
+
const HISTORY_CACHE_SIZE = 20;
|
|
29
|
+
|
|
30
|
+
// Cache entry: [url-key, segments, stale, handleData?]
|
|
31
|
+
// stale=true means the data may be outdated and should be revalidated on access
|
|
32
|
+
type HistoryCacheEntry = [string, ResolvedSegment[], boolean, HandleData?];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Shallow clone handleData to avoid reference sharing between cache entries.
|
|
36
|
+
* Only clones the structure (objects and arrays), not the data items themselves,
|
|
37
|
+
* since mutations happen at the array level, not on individual data objects.
|
|
38
|
+
* This preserves any non-serializable types (React elements, functions, etc.)
|
|
39
|
+
*/
|
|
40
|
+
function cloneHandleData(handleData: HandleData): HandleData {
|
|
41
|
+
const cloned: HandleData = {};
|
|
42
|
+
for (const [handleKey, segmentMap] of Object.entries(handleData)) {
|
|
43
|
+
cloned[handleKey] = {};
|
|
44
|
+
for (const [segmentId, dataArray] of Object.entries(segmentMap)) {
|
|
45
|
+
cloned[handleKey][segmentId] = [...dataArray];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return cloned;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// BroadcastChannel for cross-tab cache invalidation
|
|
52
|
+
const CACHE_INVALIDATION_CHANNEL = "rsc-router-cache-invalidation";
|
|
53
|
+
|
|
54
|
+
// BroadcastChannel instance (lazily initialized)
|
|
55
|
+
let cacheInvalidationChannel: BroadcastChannel | null = null;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get or create the BroadcastChannel for cache invalidation
|
|
59
|
+
*/
|
|
60
|
+
function getCacheInvalidationChannel(): BroadcastChannel | null {
|
|
61
|
+
if (
|
|
62
|
+
typeof window === "undefined" ||
|
|
63
|
+
typeof BroadcastChannel === "undefined"
|
|
64
|
+
) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (!cacheInvalidationChannel) {
|
|
68
|
+
cacheInvalidationChannel = new BroadcastChannel(CACHE_INVALIDATION_CHANNEL);
|
|
69
|
+
}
|
|
70
|
+
return cacheInvalidationChannel;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Options for generating a history key
|
|
75
|
+
*/
|
|
76
|
+
export interface HistoryKeyOptions {
|
|
77
|
+
/** If true, append :intercept suffix to differentiate intercept entries */
|
|
78
|
+
intercept?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generate a cache key from a URL.
|
|
83
|
+
* Uses pathname + search (query params) directly as the key.
|
|
84
|
+
* Hash fragments (#) are excluded since they don't affect server data.
|
|
85
|
+
*
|
|
86
|
+
* For intercept routes, append `:intercept` suffix to cache them separately
|
|
87
|
+
* from non-intercept versions of the same URL.
|
|
88
|
+
*/
|
|
89
|
+
export function generateHistoryKey(
|
|
90
|
+
url?: string,
|
|
91
|
+
options?: HistoryKeyOptions
|
|
92
|
+
): string {
|
|
93
|
+
if (!url) {
|
|
94
|
+
url = typeof window !== "undefined" ? window.location.href : "/";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Parse URL and use only pathname + search (exclude hash fragment)
|
|
98
|
+
const parsed = new URL(url, "http://localhost");
|
|
99
|
+
let key = parsed.pathname + parsed.search;
|
|
100
|
+
|
|
101
|
+
// Append intercept suffix for separate caching
|
|
102
|
+
if (options?.intercept) {
|
|
103
|
+
key += ":intercept";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return key;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Configuration for creating a navigation store
|
|
111
|
+
*/
|
|
112
|
+
export interface NavigationStoreConfig {
|
|
113
|
+
initialLocation?: { href: string };
|
|
114
|
+
initialSegmentIds?: string[];
|
|
115
|
+
initialHistoryKey?: string;
|
|
116
|
+
initialSegments?: ResolvedSegment[];
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Maximum number of history entries to cache (default: 20)
|
|
120
|
+
* Older entries are evicted when limit is reached
|
|
121
|
+
*/
|
|
122
|
+
cacheSize?: number;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Enable cross-tab cache invalidation via BroadcastChannel (default: true)
|
|
126
|
+
* When cache is cleared (via server actions or useClientCache().clear()),
|
|
127
|
+
* other tabs will also clear their cache
|
|
128
|
+
*/
|
|
129
|
+
crossTabSync?: boolean;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Auto-refresh when another tab mutates data on the same path (default: true)
|
|
133
|
+
* Triggered when cache is cleared via server actions or useClientCache().clear()
|
|
134
|
+
* Requires crossTabSync to be enabled
|
|
135
|
+
*/
|
|
136
|
+
crossTabAutoRefresh?: boolean;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Callback to invoke when cross-tab refresh is triggered
|
|
140
|
+
* Called when another tab invalidates the cache for a related route
|
|
141
|
+
*/
|
|
142
|
+
onCrossTabRefresh?: () => void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create a URL instance from window.location or custom values
|
|
147
|
+
*/
|
|
148
|
+
function createLocation(loc: { href: string }): NavigationLocation {
|
|
149
|
+
return new URL(loc.href);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create a navigation store for managing browser-side navigation state
|
|
154
|
+
*
|
|
155
|
+
* The store manages two types of state:
|
|
156
|
+
* - NavigationState: Public state exposed via useNavigation hook
|
|
157
|
+
* - SegmentState: Internal segment management for partial RSC updates
|
|
158
|
+
*
|
|
159
|
+
* @param config - Initial configuration
|
|
160
|
+
* @returns NavigationStore instance
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```typescript
|
|
164
|
+
* const store = createNavigationStore({
|
|
165
|
+
* initialLocation: window.location,
|
|
166
|
+
* initialSegmentIds: [],
|
|
167
|
+
* });
|
|
168
|
+
*
|
|
169
|
+
* // Subscribe to state changes (for useNavigation hook)
|
|
170
|
+
* const unsubscribe = store.subscribe(() => {
|
|
171
|
+
* const state = store.getState();
|
|
172
|
+
* console.log('Navigation state:', state);
|
|
173
|
+
* });
|
|
174
|
+
*
|
|
175
|
+
* // Update state
|
|
176
|
+
* store.setState({ state: 'loading' });
|
|
177
|
+
*
|
|
178
|
+
* // Subscribe to UI updates (for re-rendering)
|
|
179
|
+
* store.onUpdate((update) => {
|
|
180
|
+
* console.log('New root:', update.root);
|
|
181
|
+
* });
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
export function createNavigationStore(
|
|
185
|
+
config?: NavigationStoreConfig
|
|
186
|
+
): NavigationStore {
|
|
187
|
+
// Default location from window or config
|
|
188
|
+
const defaultLocation: NavigationLocation =
|
|
189
|
+
typeof window !== "undefined"
|
|
190
|
+
? createLocation(window.location)
|
|
191
|
+
: new URL("/", "http://localhost");
|
|
192
|
+
|
|
193
|
+
// Public navigation state (for useNavigation hook)
|
|
194
|
+
// isStreaming starts false to match SSR and avoid hydration mismatch
|
|
195
|
+
// After hydration, entry.browser.tsx sets it to true if stream is still open
|
|
196
|
+
let navState: NavigationState = {
|
|
197
|
+
state: "idle",
|
|
198
|
+
isStreaming: false,
|
|
199
|
+
location: config?.initialLocation
|
|
200
|
+
? createLocation(config.initialLocation)
|
|
201
|
+
: defaultLocation,
|
|
202
|
+
pendingUrl: null,
|
|
203
|
+
inflightActions: [],
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Resolve the initial location for segment state
|
|
207
|
+
const initialLoc = config?.initialLocation
|
|
208
|
+
? createLocation(config.initialLocation)
|
|
209
|
+
: defaultLocation;
|
|
210
|
+
|
|
211
|
+
// Internal segment state (for partial updates)
|
|
212
|
+
const segmentState: SegmentState = {
|
|
213
|
+
path: initialLoc.pathname,
|
|
214
|
+
currentUrl: initialLoc.href,
|
|
215
|
+
currentSegmentIds: config?.initialSegmentIds ?? [],
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Configuration with defaults
|
|
219
|
+
const cacheSize = config?.cacheSize ?? HISTORY_CACHE_SIZE;
|
|
220
|
+
const crossTabSync = config?.crossTabSync !== false; // Default: true
|
|
221
|
+
const crossTabAutoRefresh = config?.crossTabAutoRefresh !== false; // Default: true
|
|
222
|
+
|
|
223
|
+
// Cross-tab refresh callback (set by navigation bridge)
|
|
224
|
+
let crossTabRefreshCallback: (() => void) | null =
|
|
225
|
+
config?.onCrossTabRefresh ?? null;
|
|
226
|
+
|
|
227
|
+
// Track pending cross-tab refresh to prevent duplicate refreshes
|
|
228
|
+
let pendingCrossTabRefresh = false;
|
|
229
|
+
|
|
230
|
+
// History-based segment cache: array of [url-key, segments] tuples
|
|
231
|
+
// Each URL gets its own complete snapshot of segments for back/forward and partial merging
|
|
232
|
+
// Oldest entries (at front) are removed when over cacheSize limit
|
|
233
|
+
const historyCache: HistoryCacheEntry[] = [];
|
|
234
|
+
|
|
235
|
+
// Current history key (set on navigation, stored in history.state)
|
|
236
|
+
let currentHistoryKey = config?.initialHistoryKey || generateHistoryKey();
|
|
237
|
+
|
|
238
|
+
// Store initial segments if provided (not stale)
|
|
239
|
+
if (config?.initialHistoryKey && config?.initialSegments) {
|
|
240
|
+
historyCache.push([
|
|
241
|
+
config.initialHistoryKey,
|
|
242
|
+
config.initialSegments,
|
|
243
|
+
false,
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// State change listeners (for useNavigation subscriptions)
|
|
248
|
+
const stateListeners = new Set<StateListener>();
|
|
249
|
+
|
|
250
|
+
// UI update subscribers (for re-rendering)
|
|
251
|
+
const updateSubscribers = new Set<UpdateSubscriber>();
|
|
252
|
+
|
|
253
|
+
// Internal flag to track if a server action is in progress
|
|
254
|
+
let actionInProgress = false;
|
|
255
|
+
|
|
256
|
+
// Intercept source URL - tracks where the intercept was triggered from
|
|
257
|
+
// Used to maintain intercept context during action revalidation
|
|
258
|
+
let interceptSourceUrl: string | null = null;
|
|
259
|
+
|
|
260
|
+
// Action state tracking (for useAction hook)
|
|
261
|
+
// Maps action function ID to its tracked state
|
|
262
|
+
const actionStates = new Map<string, TrackedActionState>();
|
|
263
|
+
|
|
264
|
+
// Action state listeners (per action ID)
|
|
265
|
+
// Maps action function ID to set of listeners
|
|
266
|
+
const actionListeners = new Map<string, Set<ActionStateListener>>();
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Create a debounced function that batches rapid calls
|
|
270
|
+
*/
|
|
271
|
+
function createDebouncedNotifier<T extends (...args: any[]) => void>(
|
|
272
|
+
fn: T,
|
|
273
|
+
ms: number = 20
|
|
274
|
+
): T {
|
|
275
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
276
|
+
return ((...args: Parameters<T>) => {
|
|
277
|
+
if (timeout !== null) clearTimeout(timeout);
|
|
278
|
+
timeout = setTimeout(() => {
|
|
279
|
+
timeout = null;
|
|
280
|
+
fn(...args);
|
|
281
|
+
}, ms);
|
|
282
|
+
}) as T;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Create a keyed debounced function (separate timers per key)
|
|
287
|
+
*/
|
|
288
|
+
function createKeyedDebouncedNotifier<
|
|
289
|
+
T extends (key: string, ...args: any[]) => void,
|
|
290
|
+
>(fn: T, ms: number = 20): T {
|
|
291
|
+
const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
292
|
+
return ((key: string, ...args: any[]) => {
|
|
293
|
+
const existing = timeouts.get(key);
|
|
294
|
+
if (existing !== undefined) clearTimeout(existing);
|
|
295
|
+
timeouts.set(
|
|
296
|
+
key,
|
|
297
|
+
setTimeout(() => {
|
|
298
|
+
timeouts.delete(key);
|
|
299
|
+
fn(key, ...args);
|
|
300
|
+
}, ms)
|
|
301
|
+
);
|
|
302
|
+
}) as T;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const notifyStateListeners = createDebouncedNotifier(() => {
|
|
306
|
+
stateListeners.forEach((listener) => listener());
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const notifyActionListeners = createKeyedDebouncedNotifier(
|
|
310
|
+
(actionId: string, state: TrackedActionState) => {
|
|
311
|
+
const listeners = actionListeners.get(actionId);
|
|
312
|
+
if (listeners) {
|
|
313
|
+
listeners.forEach((listener) => listener(state));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Clear the history cache (internal - does not broadcast)
|
|
320
|
+
*/
|
|
321
|
+
function clearCacheInternal(): void {
|
|
322
|
+
historyCache.length = 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Mark all cache entries as stale (internal - does not broadcast)
|
|
327
|
+
*/
|
|
328
|
+
function markCacheAsStaleInternal(): void {
|
|
329
|
+
for (let i = 0; i < historyCache.length; i++) {
|
|
330
|
+
historyCache[i][2] = true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Clear the history cache and broadcast to other tabs
|
|
336
|
+
*/
|
|
337
|
+
function clearCacheAndBroadcast(): void {
|
|
338
|
+
console.log("[Browser] Clearing cache and broadcasting to other tabs");
|
|
339
|
+
clearCacheInternal();
|
|
340
|
+
broadcastInvalidation();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Mark cache as stale and broadcast to other tabs
|
|
345
|
+
*/
|
|
346
|
+
function markStaleAndBroadcast(): void {
|
|
347
|
+
console.log(
|
|
348
|
+
"[Browser] Marking cache as stale and broadcasting to other tabs"
|
|
349
|
+
);
|
|
350
|
+
markCacheAsStaleInternal();
|
|
351
|
+
broadcastInvalidation();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Broadcast cache invalidation to other tabs without clearing local cache
|
|
356
|
+
* Used after consolidation fetch where local cache has fresh data
|
|
357
|
+
*/
|
|
358
|
+
function broadcastInvalidation(): void {
|
|
359
|
+
// Only broadcast if cross-tab sync is enabled
|
|
360
|
+
if (!crossTabSync) return;
|
|
361
|
+
|
|
362
|
+
const channel = getCacheInvalidationChannel();
|
|
363
|
+
if (channel) {
|
|
364
|
+
// Broadcast path and segment IDs - receiver checks for shared segments
|
|
365
|
+
const currentPath = window.location.pathname;
|
|
366
|
+
const currentSegmentIds = segmentState.currentSegmentIds;
|
|
367
|
+
channel.postMessage({
|
|
368
|
+
type: "invalidate",
|
|
369
|
+
path: currentPath,
|
|
370
|
+
segmentIds: currentSegmentIds,
|
|
371
|
+
});
|
|
372
|
+
console.log(
|
|
373
|
+
"[Browser] Broadcast sent for path:",
|
|
374
|
+
currentPath,
|
|
375
|
+
"segments:",
|
|
376
|
+
currentSegmentIds.join(", ")
|
|
377
|
+
);
|
|
378
|
+
} else {
|
|
379
|
+
console.warn("[Browser] No BroadcastChannel available");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Set up cross-tab cache invalidation listener (only if enabled)
|
|
384
|
+
if (crossTabSync) {
|
|
385
|
+
const channel = getCacheInvalidationChannel();
|
|
386
|
+
if (channel) {
|
|
387
|
+
channel.onmessage = (event) => {
|
|
388
|
+
if (event.data?.type === "invalidate") {
|
|
389
|
+
const mutatedPath = event.data.path;
|
|
390
|
+
const mutatedSegmentIds: string[] = event.data.segmentIds ?? [];
|
|
391
|
+
const currentSegmentIds = segmentState.currentSegmentIds;
|
|
392
|
+
|
|
393
|
+
// Check for shared segments between tabs
|
|
394
|
+
// Routes sharing any segment (layout, loader, etc.) should invalidate together
|
|
395
|
+
const hasSharedSegment = mutatedSegmentIds.some((id) =>
|
|
396
|
+
currentSegmentIds.includes(id)
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
if (!hasSharedSegment) {
|
|
400
|
+
// No shared segments - routes are unrelated, ignore invalidation
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
console.log(
|
|
405
|
+
"[Browser] Cache marked stale by another tab, shared segments:",
|
|
406
|
+
mutatedSegmentIds
|
|
407
|
+
.filter((id) => currentSegmentIds.includes(id))
|
|
408
|
+
.join(", ")
|
|
409
|
+
);
|
|
410
|
+
markCacheAsStaleInternal();
|
|
411
|
+
|
|
412
|
+
// Auto-refresh if enabled and callback is registered
|
|
413
|
+
if (crossTabAutoRefresh && crossTabRefreshCallback) {
|
|
414
|
+
// If idle, refresh immediately. If loading, wait for idle then refresh.
|
|
415
|
+
if (navState.state === "idle") {
|
|
416
|
+
console.log("[Browser] Cross-tab refresh triggered (idle)");
|
|
417
|
+
crossTabRefreshCallback();
|
|
418
|
+
} else if (!pendingCrossTabRefresh) {
|
|
419
|
+
// Only queue one refresh, ignore subsequent events while loading
|
|
420
|
+
pendingCrossTabRefresh = true;
|
|
421
|
+
console.log(
|
|
422
|
+
"[Browser] Navigation in progress, deferring cross-tab refresh"
|
|
423
|
+
);
|
|
424
|
+
// Subscribe to state changes, refresh when idle
|
|
425
|
+
const listener: StateListener = () => {
|
|
426
|
+
if (navState.state === "idle") {
|
|
427
|
+
stateListeners.delete(listener);
|
|
428
|
+
pendingCrossTabRefresh = false;
|
|
429
|
+
console.log(
|
|
430
|
+
"[Browser] Cross-tab refresh triggered (deferred)"
|
|
431
|
+
);
|
|
432
|
+
crossTabRefreshCallback?.();
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
stateListeners.add(listener);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
// ========================================================================
|
|
445
|
+
// Public State (for useNavigation hook)
|
|
446
|
+
// ========================================================================
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Get current navigation state
|
|
450
|
+
*/
|
|
451
|
+
getState(): NavigationState {
|
|
452
|
+
return navState;
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Update navigation state and notify listeners
|
|
457
|
+
*/
|
|
458
|
+
setState(partial: Partial<NavigationState>): void {
|
|
459
|
+
navState = { ...navState, ...partial };
|
|
460
|
+
notifyStateListeners();
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Subscribe to state changes
|
|
465
|
+
* Returns unsubscribe function
|
|
466
|
+
*/
|
|
467
|
+
subscribe(listener: StateListener): () => void {
|
|
468
|
+
stateListeners.add(listener);
|
|
469
|
+
return () => {
|
|
470
|
+
stateListeners.delete(listener);
|
|
471
|
+
};
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
// ========================================================================
|
|
475
|
+
// Inflight Action Management
|
|
476
|
+
// ========================================================================
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Add an inflight action to the list
|
|
480
|
+
*/
|
|
481
|
+
addInflightAction(action: InflightAction): void {
|
|
482
|
+
navState = {
|
|
483
|
+
...navState,
|
|
484
|
+
inflightActions: [...navState.inflightActions, action],
|
|
485
|
+
};
|
|
486
|
+
notifyStateListeners();
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Remove an inflight action by ID
|
|
491
|
+
*/
|
|
492
|
+
removeInflightAction(id: string): void {
|
|
493
|
+
navState = {
|
|
494
|
+
...navState,
|
|
495
|
+
inflightActions: navState.inflightActions.filter((a) => a.id !== id),
|
|
496
|
+
};
|
|
497
|
+
notifyStateListeners();
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
// ========================================================================
|
|
501
|
+
// Action State (for controlling update behavior during server actions)
|
|
502
|
+
// ========================================================================
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Check if a server action is currently in progress
|
|
506
|
+
*/
|
|
507
|
+
isActionInProgress(): boolean {
|
|
508
|
+
return actionInProgress;
|
|
509
|
+
},
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Set the action in progress flag
|
|
513
|
+
*/
|
|
514
|
+
setActionInProgress(value: boolean): void {
|
|
515
|
+
actionInProgress = value;
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
// ========================================================================
|
|
519
|
+
// Internal Segment State (for bridges)
|
|
520
|
+
// ========================================================================
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Get internal segment state
|
|
524
|
+
*/
|
|
525
|
+
getSegmentState(): SegmentState {
|
|
526
|
+
return segmentState;
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Set current path
|
|
531
|
+
*/
|
|
532
|
+
setPath(path: string): void {
|
|
533
|
+
segmentState.path = path;
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Set current URL
|
|
538
|
+
*/
|
|
539
|
+
setCurrentUrl(url: string): void {
|
|
540
|
+
segmentState.currentUrl = url;
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Set current segment IDs
|
|
545
|
+
*/
|
|
546
|
+
setSegmentIds(ids: string[]): void {
|
|
547
|
+
segmentState.currentSegmentIds = ids;
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
// ========================================================================
|
|
551
|
+
// History-based Segment Cache (for back/forward navigation and partial merging)
|
|
552
|
+
// ========================================================================
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Get the current history key
|
|
556
|
+
*/
|
|
557
|
+
getHistoryKey(): string {
|
|
558
|
+
return currentHistoryKey;
|
|
559
|
+
},
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Set the current history key (called when navigating to a new entry)
|
|
563
|
+
*/
|
|
564
|
+
setHistoryKey(key: string): void {
|
|
565
|
+
currentHistoryKey = key;
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Store segments for a history entry
|
|
570
|
+
* Updates existing entry if key exists, otherwise adds new entry
|
|
571
|
+
* Removes oldest entries (from front) when over configured cacheSize
|
|
572
|
+
* Fresh data is always stored as not stale (stale=false)
|
|
573
|
+
*/
|
|
574
|
+
cacheSegmentsForHistory(
|
|
575
|
+
historyKey: string,
|
|
576
|
+
segments: ResolvedSegment[],
|
|
577
|
+
handleData?: HandleData
|
|
578
|
+
): void {
|
|
579
|
+
// Shallow clone handleData arrays to avoid reference sharing between cache entries
|
|
580
|
+
// We only clone the structure (objects and arrays), not the data items themselves,
|
|
581
|
+
// since mutations happen at the array level, not on individual data objects
|
|
582
|
+
const clonedHandleData = handleData
|
|
583
|
+
? cloneHandleData(handleData)
|
|
584
|
+
: undefined;
|
|
585
|
+
|
|
586
|
+
// Check if entry already exists and update it
|
|
587
|
+
const existingIndex = historyCache.findIndex(
|
|
588
|
+
([key]) => key === historyKey
|
|
589
|
+
);
|
|
590
|
+
if (existingIndex !== -1) {
|
|
591
|
+
historyCache[existingIndex] = [historyKey, segments, false, clonedHandleData];
|
|
592
|
+
} else {
|
|
593
|
+
// Add new entry at the end (not stale)
|
|
594
|
+
historyCache.push([historyKey, segments, false, clonedHandleData]);
|
|
595
|
+
// Remove oldest entries if over limit
|
|
596
|
+
while (historyCache.length > cacheSize) {
|
|
597
|
+
historyCache.shift();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Get cached segments for a history entry
|
|
604
|
+
* Returns { segments, stale, handleData } or undefined if not cached
|
|
605
|
+
*/
|
|
606
|
+
getCachedSegments(
|
|
607
|
+
historyKey: string
|
|
608
|
+
): { segments: ResolvedSegment[]; stale: boolean; handleData?: HandleData } | undefined {
|
|
609
|
+
const entry = historyCache.find(([key]) => key === historyKey);
|
|
610
|
+
if (!entry) return undefined;
|
|
611
|
+
return { segments: entry[1], stale: entry[2], handleData: entry[3] };
|
|
612
|
+
},
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Check if segments are cached for a history entry
|
|
616
|
+
*/
|
|
617
|
+
hasHistoryCache(historyKey: string): boolean {
|
|
618
|
+
return historyCache.some(([key]) => key === historyKey);
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Update only the handleData for an existing cache entry
|
|
623
|
+
* Does nothing if the cache entry doesn't exist
|
|
624
|
+
* This is used to fix stale handleData after async handles processing
|
|
625
|
+
*/
|
|
626
|
+
updateCacheHandleData(historyKey: string, handleData: HandleData): void {
|
|
627
|
+
const existingIndex = historyCache.findIndex(
|
|
628
|
+
([key]) => key === historyKey
|
|
629
|
+
);
|
|
630
|
+
if (existingIndex !== -1) {
|
|
631
|
+
const entry = historyCache[existingIndex];
|
|
632
|
+
// Shallow clone handleData arrays to avoid reference sharing
|
|
633
|
+
const clonedHandleData = cloneHandleData(handleData);
|
|
634
|
+
historyCache[existingIndex] = [entry[0], entry[1], entry[2], clonedHandleData];
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Mark all cache entries as stale
|
|
640
|
+
* Called after server actions to indicate data may be outdated
|
|
641
|
+
*/
|
|
642
|
+
markCacheAsStale(): void {
|
|
643
|
+
for (let i = 0; i < historyCache.length; i++) {
|
|
644
|
+
historyCache[i][2] = true;
|
|
645
|
+
}
|
|
646
|
+
console.log(
|
|
647
|
+
"[Browser] Marked",
|
|
648
|
+
historyCache.length,
|
|
649
|
+
"cache entries as stale"
|
|
650
|
+
);
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Clear the history cache and broadcast to other tabs
|
|
655
|
+
* Use this for hard invalidation when data is definitely stale
|
|
656
|
+
*/
|
|
657
|
+
clearHistoryCache(): void {
|
|
658
|
+
clearCacheAndBroadcast();
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Mark cache as stale and broadcast to other tabs
|
|
663
|
+
* Called after server actions - allows SWR pattern for popstate
|
|
664
|
+
*/
|
|
665
|
+
markCacheAsStaleAndBroadcast(): void {
|
|
666
|
+
markStaleAndBroadcast();
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Broadcast cache invalidation to other tabs without clearing local cache
|
|
671
|
+
* Used after consolidation fetch where local cache has fresh data
|
|
672
|
+
*/
|
|
673
|
+
broadcastCacheInvalidation(): void {
|
|
674
|
+
broadcastInvalidation();
|
|
675
|
+
},
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Set the callback to invoke when cross-tab refresh is triggered
|
|
679
|
+
* Called by navigation bridge during initialization
|
|
680
|
+
*/
|
|
681
|
+
setCrossTabRefreshCallback(callback: () => void): void {
|
|
682
|
+
crossTabRefreshCallback = callback;
|
|
683
|
+
},
|
|
684
|
+
|
|
685
|
+
// ========================================================================
|
|
686
|
+
// Intercept Context Tracking
|
|
687
|
+
// ========================================================================
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Get the intercept source URL
|
|
691
|
+
* This is the URL where the intercept was triggered from (e.g., /shop)
|
|
692
|
+
* Used to maintain intercept context during action revalidation
|
|
693
|
+
*/
|
|
694
|
+
getInterceptSourceUrl(): string | null {
|
|
695
|
+
return interceptSourceUrl;
|
|
696
|
+
},
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Set the intercept source URL
|
|
700
|
+
* Called when an intercept navigation is detected
|
|
701
|
+
* Set to null when leaving intercept context (e.g., closing modal)
|
|
702
|
+
*/
|
|
703
|
+
setInterceptSourceUrl(url: string | null): void {
|
|
704
|
+
interceptSourceUrl = url;
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
// ========================================================================
|
|
708
|
+
// UI Update Notifications
|
|
709
|
+
// ========================================================================
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Subscribe to UI updates (when root needs to re-render)
|
|
713
|
+
*/
|
|
714
|
+
onUpdate(callback: UpdateSubscriber): () => void {
|
|
715
|
+
updateSubscribers.add(callback);
|
|
716
|
+
return () => {
|
|
717
|
+
updateSubscribers.delete(callback);
|
|
718
|
+
};
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Emit a UI update to all subscribers
|
|
723
|
+
*/
|
|
724
|
+
emitUpdate(update: NavigationUpdate): void {
|
|
725
|
+
updateSubscribers.forEach((callback) => {
|
|
726
|
+
callback(update);
|
|
727
|
+
});
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
// ========================================================================
|
|
731
|
+
// Action State Tracking (for useAction hook)
|
|
732
|
+
// ========================================================================
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Get the current state for a tracked action
|
|
736
|
+
* Returns default idle state if action hasn't been tracked
|
|
737
|
+
*/
|
|
738
|
+
getActionState(actionId: string): TrackedActionState {
|
|
739
|
+
return actionStates.get(actionId) ?? { ...DEFAULT_ACTION_STATE };
|
|
740
|
+
},
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Update the state for a tracked action
|
|
744
|
+
* Merges partial state with existing state and notifies listeners
|
|
745
|
+
*/
|
|
746
|
+
setActionState(
|
|
747
|
+
actionId: string,
|
|
748
|
+
partial: Partial<TrackedActionState>
|
|
749
|
+
): void {
|
|
750
|
+
const current = actionStates.get(actionId) ?? { ...DEFAULT_ACTION_STATE };
|
|
751
|
+
const updated: TrackedActionState = {
|
|
752
|
+
...current,
|
|
753
|
+
...partial,
|
|
754
|
+
actionId, // Always set the actionId
|
|
755
|
+
};
|
|
756
|
+
actionStates.set(actionId, updated);
|
|
757
|
+
notifyActionListeners(actionId, updated);
|
|
758
|
+
},
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Subscribe to state changes for a specific action
|
|
762
|
+
* Returns unsubscribe function
|
|
763
|
+
*/
|
|
764
|
+
subscribeToAction(
|
|
765
|
+
actionId: string,
|
|
766
|
+
listener: ActionStateListener
|
|
767
|
+
): () => void {
|
|
768
|
+
let listeners = actionListeners.get(actionId);
|
|
769
|
+
if (!listeners) {
|
|
770
|
+
listeners = new Set();
|
|
771
|
+
actionListeners.set(actionId, listeners);
|
|
772
|
+
}
|
|
773
|
+
listeners.add(listener);
|
|
774
|
+
|
|
775
|
+
return () => {
|
|
776
|
+
listeners!.delete(listener);
|
|
777
|
+
// Clean up empty listener sets
|
|
778
|
+
if (listeners!.size === 0) {
|
|
779
|
+
actionListeners.delete(actionId);
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
},
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Singleton store instance
|
|
787
|
+
let storeInstance: NavigationStore | null = null;
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Initialize the global navigation store
|
|
791
|
+
*
|
|
792
|
+
* Should be called once during app initialization.
|
|
793
|
+
* Subsequent calls return the existing instance.
|
|
794
|
+
*/
|
|
795
|
+
export function initNavigationStore(
|
|
796
|
+
config?: NavigationStoreConfig
|
|
797
|
+
): NavigationStore {
|
|
798
|
+
if (!storeInstance) {
|
|
799
|
+
storeInstance = createNavigationStore(config);
|
|
800
|
+
}
|
|
801
|
+
return storeInstance;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Get the global navigation store
|
|
806
|
+
*
|
|
807
|
+
* Throws if store hasn't been initialized.
|
|
808
|
+
*/
|
|
809
|
+
export function getNavigationStore(): NavigationStore {
|
|
810
|
+
if (!storeInstance) {
|
|
811
|
+
throw new Error(
|
|
812
|
+
"Navigation store not initialized. Call initNavigationStore first."
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
return storeInstance;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Reset the store instance (for testing)
|
|
820
|
+
*/
|
|
821
|
+
export function resetNavigationStore(): void {
|
|
822
|
+
storeInstance = null;
|
|
823
|
+
}
|