@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.
Files changed (123) hide show
  1. package/README.md +19 -0
  2. package/package.json +131 -0
  3. package/src/__mocks__/version.ts +6 -0
  4. package/src/__tests__/route-definition.test.ts +63 -0
  5. package/src/browser/event-controller.ts +876 -0
  6. package/src/browser/index.ts +18 -0
  7. package/src/browser/link-interceptor.ts +121 -0
  8. package/src/browser/lru-cache.ts +69 -0
  9. package/src/browser/merge-segment-loaders.ts +126 -0
  10. package/src/browser/navigation-bridge.ts +891 -0
  11. package/src/browser/navigation-client.ts +155 -0
  12. package/src/browser/navigation-store.ts +823 -0
  13. package/src/browser/partial-update.ts +545 -0
  14. package/src/browser/react/Link.tsx +248 -0
  15. package/src/browser/react/NavigationProvider.tsx +228 -0
  16. package/src/browser/react/ScrollRestoration.tsx +94 -0
  17. package/src/browser/react/context.ts +53 -0
  18. package/src/browser/react/index.ts +52 -0
  19. package/src/browser/react/location-state-shared.ts +120 -0
  20. package/src/browser/react/location-state.ts +62 -0
  21. package/src/browser/react/use-action.ts +240 -0
  22. package/src/browser/react/use-client-cache.ts +56 -0
  23. package/src/browser/react/use-handle.ts +178 -0
  24. package/src/browser/react/use-link-status.ts +134 -0
  25. package/src/browser/react/use-navigation.ts +150 -0
  26. package/src/browser/react/use-segments.ts +188 -0
  27. package/src/browser/request-controller.ts +149 -0
  28. package/src/browser/rsc-router.tsx +310 -0
  29. package/src/browser/scroll-restoration.ts +324 -0
  30. package/src/browser/server-action-bridge.ts +747 -0
  31. package/src/browser/shallow.ts +35 -0
  32. package/src/browser/types.ts +443 -0
  33. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  34. package/src/cache/__tests__/memory-store.test.ts +484 -0
  35. package/src/cache/cache-scope.ts +565 -0
  36. package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
  37. package/src/cache/cf/cf-cache-store.ts +274 -0
  38. package/src/cache/cf/index.ts +19 -0
  39. package/src/cache/index.ts +52 -0
  40. package/src/cache/memory-segment-store.ts +150 -0
  41. package/src/cache/memory-store.ts +253 -0
  42. package/src/cache/types.ts +366 -0
  43. package/src/client.rsc.tsx +88 -0
  44. package/src/client.tsx +609 -0
  45. package/src/components/DefaultDocument.tsx +20 -0
  46. package/src/default-error-boundary.tsx +88 -0
  47. package/src/deps/browser.ts +8 -0
  48. package/src/deps/html-stream-client.ts +2 -0
  49. package/src/deps/html-stream-server.ts +2 -0
  50. package/src/deps/rsc.ts +10 -0
  51. package/src/deps/ssr.ts +2 -0
  52. package/src/errors.ts +259 -0
  53. package/src/handle.ts +120 -0
  54. package/src/handles/MetaTags.tsx +178 -0
  55. package/src/handles/index.ts +6 -0
  56. package/src/handles/meta.ts +247 -0
  57. package/src/href-client.ts +128 -0
  58. package/src/href.ts +139 -0
  59. package/src/index.rsc.ts +69 -0
  60. package/src/index.ts +84 -0
  61. package/src/loader.rsc.ts +204 -0
  62. package/src/loader.ts +47 -0
  63. package/src/network-error-thrower.tsx +21 -0
  64. package/src/outlet-context.ts +15 -0
  65. package/src/root-error-boundary.tsx +277 -0
  66. package/src/route-content-wrapper.tsx +198 -0
  67. package/src/route-definition.ts +1333 -0
  68. package/src/route-map-builder.ts +140 -0
  69. package/src/route-types.ts +148 -0
  70. package/src/route-utils.ts +89 -0
  71. package/src/router/__tests__/match-context.test.ts +104 -0
  72. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  73. package/src/router/__tests__/match-result.test.ts +566 -0
  74. package/src/router/__tests__/on-error.test.ts +935 -0
  75. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  76. package/src/router/error-handling.ts +287 -0
  77. package/src/router/handler-context.ts +60 -0
  78. package/src/router/loader-resolution.ts +326 -0
  79. package/src/router/manifest.ts +116 -0
  80. package/src/router/match-context.ts +261 -0
  81. package/src/router/match-middleware/background-revalidation.ts +236 -0
  82. package/src/router/match-middleware/cache-lookup.ts +261 -0
  83. package/src/router/match-middleware/cache-store.ts +250 -0
  84. package/src/router/match-middleware/index.ts +81 -0
  85. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  86. package/src/router/match-middleware/segment-resolution.ts +174 -0
  87. package/src/router/match-pipelines.ts +214 -0
  88. package/src/router/match-result.ts +212 -0
  89. package/src/router/metrics.ts +62 -0
  90. package/src/router/middleware.test.ts +1355 -0
  91. package/src/router/middleware.ts +748 -0
  92. package/src/router/pattern-matching.ts +271 -0
  93. package/src/router/revalidation.ts +190 -0
  94. package/src/router/router-context.ts +299 -0
  95. package/src/router/types.ts +96 -0
  96. package/src/router.ts +3484 -0
  97. package/src/rsc/__tests__/helpers.test.ts +175 -0
  98. package/src/rsc/handler.ts +942 -0
  99. package/src/rsc/helpers.ts +64 -0
  100. package/src/rsc/index.ts +56 -0
  101. package/src/rsc/nonce.ts +18 -0
  102. package/src/rsc/types.ts +225 -0
  103. package/src/segment-system.tsx +405 -0
  104. package/src/server/__tests__/request-context.test.ts +171 -0
  105. package/src/server/context.ts +340 -0
  106. package/src/server/handle-store.ts +230 -0
  107. package/src/server/loader-registry.ts +174 -0
  108. package/src/server/request-context.ts +470 -0
  109. package/src/server/root-layout.tsx +10 -0
  110. package/src/server/tsconfig.json +14 -0
  111. package/src/server.ts +126 -0
  112. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  113. package/src/ssr/index.tsx +215 -0
  114. package/src/types.ts +1473 -0
  115. package/src/use-loader.tsx +346 -0
  116. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  117. package/src/vite/expose-action-id.ts +344 -0
  118. package/src/vite/expose-handle-id.ts +209 -0
  119. package/src/vite/expose-loader-id.ts +357 -0
  120. package/src/vite/expose-location-state-id.ts +177 -0
  121. package/src/vite/index.ts +608 -0
  122. package/src/vite/version.d.ts +12 -0
  123. 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
+ }