@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,876 @@
1
+ import type { ReactNode } from "react";
2
+ import type {
3
+ NavigationLocation,
4
+ NavigateOptions,
5
+ TrackedActionState,
6
+ ActionLifecycleState,
7
+ InflightAction,
8
+ ResolvedSegment,
9
+ RscMetadata,
10
+ HandleData,
11
+ } from "./types.js";
12
+
13
+ // Polyfill Symbol.dispose for Safari and older browsers
14
+ if (typeof Symbol.dispose === "undefined") {
15
+ (Symbol as any).dispose = Symbol("Symbol.dispose");
16
+ }
17
+ if (typeof Symbol.asyncDispose === "undefined") {
18
+ (Symbol as any).asyncDispose = Symbol("Symbol.asyncDispose");
19
+ }
20
+
21
+ // ============================================================================
22
+ // Types
23
+ // ============================================================================
24
+
25
+ /**
26
+ * Phase of a navigation operation
27
+ */
28
+ export type NavigationPhase = "fetching" | "streaming";
29
+
30
+ /**
31
+ * Phase of an action operation
32
+ */
33
+ export type ActionPhase = "fetching" | "streaming" | "settling";
34
+
35
+ /**
36
+ * Entry tracking an in-flight navigation
37
+ */
38
+ export interface NavigationEntry {
39
+ url: string;
40
+ abort: AbortController;
41
+ phase: NavigationPhase;
42
+ startedAt: number;
43
+ options?: NavigateOptions;
44
+ }
45
+
46
+ /**
47
+ * Entry tracking an in-flight action
48
+ */
49
+ export interface ActionEntry {
50
+ /** Unique instance ID for this action invocation */
51
+ id: string;
52
+ /** Server action function ID (normalized name like "addToCart") */
53
+ actionId: string;
54
+ /** Abort controller for this action */
55
+ abort: AbortController;
56
+ /** Current phase of the action */
57
+ phase: ActionPhase;
58
+ /** Action arguments */
59
+ payload: unknown[];
60
+ /** Result from action (set on completion) */
61
+ result?: unknown;
62
+ /** Error from action (set on failure) */
63
+ error?: unknown;
64
+ /** Segment IDs that were revalidated by this action */
65
+ revalidatedSegments: string[];
66
+ /** Timestamp when action started */
67
+ startedAt: number;
68
+ /** Whether action processing is complete (may still be streaming) */
69
+ completed?: boolean;
70
+ }
71
+
72
+ /**
73
+ * Derived navigation state (computed from source of truth)
74
+ */
75
+ export interface DerivedNavigationState {
76
+ /** Navigation lifecycle state */
77
+ state: "idle" | "loading";
78
+ /** Whether any operation is streaming */
79
+ isStreaming: boolean;
80
+ /** Current committed location */
81
+ location: NavigationLocation;
82
+ /** URL being navigated to (null if idle) */
83
+ pendingUrl: string | null;
84
+ /** List of inflight actions (for compatibility) */
85
+ inflightActions: InflightAction[];
86
+ }
87
+
88
+ /**
89
+ * Callback for UI updates when root should re-render
90
+ */
91
+ export type UpdateCallback = (update: {
92
+ root: ReactNode | Promise<ReactNode>;
93
+ metadata: RscMetadata;
94
+ }) => void;
95
+
96
+ /**
97
+ * State change listener
98
+ */
99
+ export type StateListener = () => void;
100
+
101
+ /**
102
+ * Action state listener
103
+ */
104
+ export type ActionStateListener = (state: TrackedActionState) => void;
105
+
106
+ /**
107
+ * Handle state listener
108
+ */
109
+ export type HandleListener = () => void;
110
+
111
+ /**
112
+ * Internal handle state stored in controller
113
+ */
114
+ export interface HandleState {
115
+ data: HandleData;
116
+ segmentOrder: string[];
117
+ }
118
+
119
+ /**
120
+ * Token for tracking an active stream
121
+ * Call end() when the stream completes
122
+ */
123
+ export interface StreamingToken {
124
+ /** End this streaming operation */
125
+ end(): void;
126
+ }
127
+
128
+ /**
129
+ * Result from starting a navigation
130
+ * Implements Disposable for use with `using` keyword
131
+ */
132
+ export interface NavigationHandle extends Disposable {
133
+ /** Abort controller for this navigation */
134
+ abort: AbortController;
135
+ /** Signal for this navigation */
136
+ signal: AbortSignal;
137
+ /** Start streaming and get a token to end it later */
138
+ startStreaming(): StreamingToken;
139
+ /** Complete the navigation successfully */
140
+ complete(location: NavigationLocation): void;
141
+ /** Whether navigation was completed successfully */
142
+ readonly completed: boolean;
143
+ }
144
+
145
+ /**
146
+ * Result from starting an action
147
+ * Implements Disposable for use with `using` keyword
148
+ */
149
+ export interface ActionHandle extends Disposable {
150
+ /** Unique instance ID */
151
+ id: string;
152
+ /** Abort controller for this action */
153
+ abort: AbortController;
154
+ /** Signal for this action */
155
+ signal: AbortSignal;
156
+ /** Start streaming and get a token to end it later */
157
+ startStreaming(): StreamingToken;
158
+ /** Record segments that were revalidated */
159
+ recordRevalidatedSegments(segmentIds: string[]): void;
160
+ /** Complete the action with result */
161
+ complete(result?: unknown): void;
162
+ /** Fail the action with error */
163
+ fail(error: unknown): void;
164
+ /** Whether action was completed (success or failure) */
165
+ readonly settled: boolean;
166
+ /** Check if any concurrent actions were started */
167
+ hadConcurrentActions: boolean;
168
+ /** Get segments to consolidate (only valid when this is the last action) */
169
+ getConsolidationSegments(): string[] | null;
170
+ /** Clear consolidation tracking */
171
+ clearConsolidation(): void;
172
+ }
173
+
174
+ /**
175
+ * Event controller interface
176
+ */
177
+ export interface EventController {
178
+ // Navigation operations
179
+ startNavigation(url: string, options?: NavigateOptions): NavigationHandle;
180
+ abortNavigation(): void;
181
+
182
+ // Action operations
183
+ startAction(actionId: string, args: unknown[]): ActionHandle;
184
+ abortAllActions(): void;
185
+
186
+ // State access
187
+ getState(): DerivedNavigationState;
188
+ getActionState(actionId: string): TrackedActionState;
189
+
190
+ // Location updates (for popstate where navigation doesn't go through startNavigation)
191
+ setLocation(location: NavigationLocation): void;
192
+
193
+ // Subscriptions
194
+ subscribe(listener: StateListener): () => void;
195
+ subscribeToAction(
196
+ actionId: string,
197
+ listener: ActionStateListener
198
+ ): () => void;
199
+ subscribeToHandles(listener: HandleListener): () => void;
200
+
201
+ // Handle operations
202
+ setHandleData(
203
+ data: HandleData,
204
+ matched?: string[],
205
+ isPartial?: boolean
206
+ ): void;
207
+ getHandleState(): HandleState;
208
+
209
+ // Direct state access for advanced use
210
+ getCurrentNavigation(): NavigationEntry | null;
211
+ getInflightActions(): Map<string, ActionEntry>;
212
+ }
213
+
214
+ // ============================================================================
215
+ // Default States
216
+ // ============================================================================
217
+
218
+ const DEFAULT_ACTION_STATE: TrackedActionState = {
219
+ state: "idle",
220
+ actionId: null,
221
+ payload: null,
222
+ error: null,
223
+ result: null,
224
+ };
225
+
226
+ /**
227
+ * Check if a subscription ID matches an action's full ID.
228
+ *
229
+ * When subscriptionId contains '#', it's a full ID and requires exact match.
230
+ * When subscriptionId has no '#', it's just an action name and matches by suffix.
231
+ * This allows useAction("addToCart") to match "hash#addToCart" or "src/file.ts#addToCart".
232
+ */
233
+ function matchesActionId(subscriptionId: string, entryActionId: string): boolean {
234
+ if (subscriptionId.includes("#")) {
235
+ // Full ID: exact match
236
+ return subscriptionId === entryActionId;
237
+ }
238
+ // Action name only: suffix match (matches "anything#actionName")
239
+ return entryActionId.endsWith(`#${subscriptionId}`);
240
+ }
241
+
242
+ // ============================================================================
243
+ // Implementation
244
+ // ============================================================================
245
+
246
+ /**
247
+ * Configuration for creating an event controller
248
+ */
249
+ export interface EventControllerConfig {
250
+ initialLocation?: NavigationLocation;
251
+ }
252
+
253
+ /**
254
+ * Create an event controller for managing navigation and action state
255
+ *
256
+ * The controller uses a reactive model where:
257
+ * - Source of truth: currentNavigation, inflightActions, location
258
+ * - Derived state: navState, isStreaming computed from source
259
+ *
260
+ * Navigation uses switchMap semantics (new nav cancels previous).
261
+ * Actions use mergeMap semantics (all run concurrently, consolidate at end).
262
+ */
263
+ export function createEventController(
264
+ config?: EventControllerConfig
265
+ ): EventController {
266
+ // ========================================================================
267
+ // Source of Truth
268
+ // ========================================================================
269
+
270
+ // Current navigation in progress (null = idle)
271
+ let currentNavigation: NavigationEntry | null = null;
272
+
273
+ // All in-flight actions (keyed by unique instance ID)
274
+ const inflightActions = new Map<string, ActionEntry>();
275
+
276
+ // Committed location (updated when navigation completes)
277
+ let location: NavigationLocation =
278
+ config?.initialLocation ??
279
+ (typeof window !== "undefined"
280
+ ? new URL(window.location.href)
281
+ : new URL("/", "http://localhost"));
282
+
283
+ // Track if any concurrent actions occurred (for consolidation)
284
+ let hadAnyConcurrentActions = false;
285
+
286
+ // Track segments revalidated by concurrent actions
287
+ const concurrentRevalidatedSegments = new Set<string>();
288
+
289
+ // Active streaming count (independent of navigation/action lifecycle)
290
+ let activeStreamCount = 0;
291
+
292
+ // Handle data from RSC payload
293
+ let handleData: HandleData = {};
294
+ let handleSegmentOrder: string[] = [];
295
+
296
+ // ========================================================================
297
+ // Listeners
298
+ // ========================================================================
299
+
300
+ const stateListeners = new Set<StateListener>();
301
+ const actionListeners = new Map<string, Set<ActionStateListener>>();
302
+ const handleListeners = new Set<HandleListener>();
303
+
304
+ // Debounce state notifications to batch rapid updates
305
+ let notifyTimeout: ReturnType<typeof setTimeout> | null = null;
306
+
307
+ function notify() {
308
+ if (notifyTimeout !== null) {
309
+ clearTimeout(notifyTimeout);
310
+ }
311
+ notifyTimeout = setTimeout(() => {
312
+ notifyTimeout = null;
313
+ stateListeners.forEach((listener) => listener());
314
+ }, 0);
315
+ }
316
+
317
+ // Debounce per-action notifications
318
+ const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
319
+
320
+ function notifyAction(actionId: string) {
321
+ const existing = actionNotifyTimeouts.get(actionId);
322
+ if (existing !== undefined) {
323
+ clearTimeout(existing);
324
+ }
325
+ actionNotifyTimeouts.set(
326
+ actionId,
327
+ setTimeout(() => {
328
+ actionNotifyTimeouts.delete(actionId);
329
+ // Notify all listeners whose subscription ID matches this action
330
+ // This includes exact matches and suffix matches (e.g., "addToCart" matches "hash#addToCart")
331
+ for (const [subscriptionId, listeners] of actionListeners) {
332
+ if (matchesActionId(subscriptionId, actionId)) {
333
+ const state = getActionState(subscriptionId);
334
+ listeners.forEach((listener) => listener(state));
335
+ }
336
+ }
337
+ }, 0)
338
+ );
339
+ }
340
+
341
+ // Debounce handle notifications
342
+ let handleNotifyTimeout: ReturnType<typeof setTimeout> | null = null;
343
+
344
+ function notifyHandles() {
345
+ if (handleNotifyTimeout !== null) {
346
+ clearTimeout(handleNotifyTimeout);
347
+ }
348
+ handleNotifyTimeout = setTimeout(() => {
349
+ handleNotifyTimeout = null;
350
+ handleListeners.forEach((listener) => listener());
351
+ }, 0);
352
+ }
353
+
354
+ // ========================================================================
355
+ // Derived State
356
+ // ========================================================================
357
+
358
+ function getState(): DerivedNavigationState {
359
+ // Build inflight actions list (for compatibility with existing API)
360
+ const inflightActionsList: InflightAction[] = [...inflightActions.values()]
361
+ .filter((a) => a.phase !== "settling")
362
+ .map((a) => ({
363
+ id: a.id,
364
+ actionId: a.actionId,
365
+ payload: a.payload,
366
+ startedAt: a.startedAt,
367
+ }));
368
+
369
+ // State: loading if navigation OR actions are in progress
370
+ const hasActiveActions = inflightActionsList.length > 0;
371
+ const state =
372
+ currentNavigation !== null || hasActiveActions ? "loading" : "idle";
373
+
374
+ // Streaming: true if any active streams (navigation or action) or loading
375
+ const isStreaming = activeStreamCount > 0 || state === "loading";
376
+
377
+ return {
378
+ state,
379
+ isStreaming,
380
+ location,
381
+ // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending
382
+ pendingUrl: currentNavigation?.phase === "fetching" ? currentNavigation.url : null,
383
+ inflightActions: inflightActionsList,
384
+ };
385
+ }
386
+
387
+ function getActionState(actionId: string): TrackedActionState {
388
+ // Find the most recent action with this ID that's not settling
389
+ // Uses suffix matching when actionId is just a name (no #)
390
+ const activeEntry = [...inflightActions.values()]
391
+ .filter((a) => matchesActionId(actionId, a.actionId) && a.phase !== "settling")
392
+ .sort((a, b) => b.startedAt - a.startedAt)[0];
393
+
394
+ // Also check for settling entries to get result/error
395
+ const settlingEntry = [...inflightActions.values()]
396
+ .filter((a) => matchesActionId(actionId, a.actionId) && a.phase === "settling")
397
+ .sort((a, b) => b.startedAt - a.startedAt)[0];
398
+
399
+ const entry = activeEntry || settlingEntry;
400
+
401
+ if (!entry) {
402
+ return { ...DEFAULT_ACTION_STATE };
403
+ }
404
+
405
+ // Derive state from phase
406
+ let state: ActionLifecycleState;
407
+ switch (entry.phase) {
408
+ case "fetching":
409
+ state = "loading";
410
+ break;
411
+ case "streaming":
412
+ state = "streaming";
413
+ break;
414
+ case "settling":
415
+ state = "idle";
416
+ break;
417
+ }
418
+
419
+ return {
420
+ state,
421
+ actionId: entry.actionId,
422
+ payload: entry.payload,
423
+ error: entry.error ?? null,
424
+ result: entry.result ?? null,
425
+ };
426
+ }
427
+
428
+ // ========================================================================
429
+ // Navigation Operations
430
+ // ========================================================================
431
+
432
+ function startNavigation(
433
+ url: string,
434
+ options?: NavigateOptions
435
+ ): NavigationHandle {
436
+ // Cancel existing navigation (switchMap semantics)
437
+ if (currentNavigation) {
438
+ currentNavigation.abort.abort();
439
+ currentNavigation = null;
440
+ }
441
+
442
+ const abort = new AbortController();
443
+ const entry: NavigationEntry = {
444
+ url,
445
+ abort,
446
+ phase: "fetching",
447
+ startedAt: Date.now(),
448
+ options,
449
+ };
450
+
451
+ currentNavigation = entry;
452
+ notify();
453
+
454
+ let completed = false;
455
+
456
+ return {
457
+ abort,
458
+ signal: abort.signal,
459
+
460
+ get completed() {
461
+ return completed;
462
+ },
463
+
464
+ startStreaming(): StreamingToken {
465
+ let ended = false;
466
+ activeStreamCount++;
467
+ notify();
468
+
469
+ return {
470
+ end() {
471
+ if (ended) return;
472
+ ended = true;
473
+ activeStreamCount = Math.max(0, activeStreamCount - 1);
474
+ notify();
475
+ },
476
+ };
477
+ },
478
+
479
+ complete(newLocation: NavigationLocation) {
480
+ if (currentNavigation === entry) {
481
+ completed = true;
482
+ location = newLocation;
483
+ currentNavigation = null;
484
+ notify();
485
+ }
486
+ },
487
+
488
+ // Disposable: cleanup if not completed (e.g., error thrown)
489
+ [Symbol.dispose]() {
490
+ // If aborted by another navigation, don't touch state
491
+ if (abort.signal.aborted) return;
492
+
493
+ // If not completed, reset to idle
494
+ if (!completed && currentNavigation === entry) {
495
+ currentNavigation = null;
496
+ notify();
497
+ }
498
+ },
499
+ };
500
+ }
501
+
502
+ function abortNavigation() {
503
+ if (currentNavigation) {
504
+ currentNavigation.abort.abort();
505
+ currentNavigation = null;
506
+ notify();
507
+ }
508
+ }
509
+
510
+ function setLocation(newLocation: NavigationLocation) {
511
+ location = newLocation;
512
+ notify();
513
+ }
514
+
515
+ // ========================================================================
516
+ // Action Operations
517
+ // ========================================================================
518
+
519
+ function startAction(actionId: string, args: unknown[]): ActionHandle {
520
+ const id = `${actionId}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
521
+ const abort = new AbortController();
522
+
523
+ // Track if this action started while others were pending (concurrent)
524
+ const hadConcurrent = inflightActions.size > 0;
525
+ if (hadConcurrent) {
526
+ hadAnyConcurrentActions = true;
527
+ }
528
+
529
+ const entry: ActionEntry = {
530
+ id,
531
+ actionId,
532
+ abort,
533
+ phase: "fetching",
534
+ payload: args,
535
+ revalidatedSegments: [],
536
+ startedAt: Date.now(),
537
+ };
538
+
539
+ inflightActions.set(id, entry);
540
+ notify();
541
+ notifyAction(actionId);
542
+
543
+ let settled = false;
544
+ let streamingEnded = false;
545
+ let actionCompleted = false;
546
+ let pendingResult:
547
+ | { type: "success"; value?: unknown }
548
+ | { type: "error"; value: unknown }
549
+ | null = null;
550
+
551
+ function doSettle() {
552
+ if (settled) return;
553
+ settled = true;
554
+
555
+ // Cleanup after brief delay (allow useAction to read result)
556
+ setTimeout(() => {
557
+ inflightActions.delete(id);
558
+ // Check for consolidation
559
+ if (inflightActions.size === 0) {
560
+ // All actions done - reset tracking
561
+ hadAnyConcurrentActions = false;
562
+ concurrentRevalidatedSegments.clear();
563
+ }
564
+ notify();
565
+ notifyAction(actionId);
566
+ }, 100);
567
+ }
568
+
569
+ // Called when both action is done AND streaming has ended
570
+ function tryFinalize() {
571
+ if (!actionCompleted || !streamingEnded) return;
572
+ if (settled) return;
573
+
574
+ // Apply the pending result
575
+ if (pendingResult?.type === "error") {
576
+ entry.error = pendingResult.value;
577
+ } else if (pendingResult?.type === "success") {
578
+ entry.result = pendingResult.value;
579
+ }
580
+ entry.phase = "settling";
581
+ notify();
582
+ notifyAction(actionId);
583
+ doSettle();
584
+ }
585
+
586
+ return {
587
+ id,
588
+ abort,
589
+ signal: abort.signal,
590
+ hadConcurrentActions: hadConcurrent,
591
+
592
+ get settled() {
593
+ return settled;
594
+ },
595
+
596
+ startStreaming(): StreamingToken {
597
+ let ended = false;
598
+ activeStreamCount++;
599
+ entry.phase = "streaming";
600
+ notify();
601
+ notifyAction(actionId);
602
+
603
+ return {
604
+ end() {
605
+ if (ended) return;
606
+ ended = true;
607
+ streamingEnded = true;
608
+ activeStreamCount = Math.max(0, activeStreamCount - 1);
609
+ notify();
610
+ // Try to finalize if action was already completed
611
+ tryFinalize();
612
+ },
613
+ };
614
+ },
615
+
616
+ recordRevalidatedSegments(segmentIds: string[]) {
617
+ entry.revalidatedSegments.push(...segmentIds);
618
+ segmentIds.forEach((id) => concurrentRevalidatedSegments.add(id));
619
+ },
620
+
621
+ complete(result?: unknown) {
622
+ if (!inflightActions.has(id) || settled) return;
623
+
624
+ actionCompleted = true;
625
+ entry.completed = true;
626
+ pendingResult = { type: "success", value: result };
627
+
628
+ // If streaming never started or already ended, finalize immediately
629
+ // Otherwise wait for streaming to end
630
+ if (entry.phase === "fetching" || streamingEnded) {
631
+ streamingEnded = true; // Mark as ended if never started
632
+ tryFinalize();
633
+ }
634
+ // If streaming is in progress, tryFinalize() will be called when streaming ends
635
+ },
636
+
637
+ fail(error: unknown) {
638
+ if (!inflightActions.has(id) || settled) return;
639
+
640
+ actionCompleted = true;
641
+ entry.completed = true;
642
+ pendingResult = { type: "error", value: error };
643
+
644
+ // If streaming never started or already ended, finalize immediately
645
+ // Otherwise wait for streaming to end
646
+ if (entry.phase === "fetching" || streamingEnded) {
647
+ streamingEnded = true; // Mark as ended if never started
648
+ tryFinalize();
649
+ }
650
+ // If streaming is in progress, tryFinalize() will be called when streaming ends
651
+ },
652
+
653
+ getConsolidationSegments(): string[] | null {
654
+ // Only consolidate if all actions have at least received their response
655
+ // We don't need to wait for streaming to complete since we're refetching anyway
656
+ // Count actions that are still fetching (waiting for server response)
657
+ const stillFetchingCount = [...inflightActions.values()].filter(
658
+ (a) => a.phase === "fetching"
659
+ ).length;
660
+
661
+ if (stillFetchingCount > 0) {
662
+ return null; // Some actions still waiting for server response
663
+ }
664
+ if (!hadAnyConcurrentActions) {
665
+ return null; // No concurrent actions occurred
666
+ }
667
+ if (concurrentRevalidatedSegments.size === 0) {
668
+ return null; // No segments to consolidate
669
+ }
670
+ return Array.from(concurrentRevalidatedSegments);
671
+ },
672
+
673
+ clearConsolidation() {
674
+ concurrentRevalidatedSegments.clear();
675
+ hadAnyConcurrentActions = false;
676
+ },
677
+
678
+ // Disposable: cleanup if not settled (e.g., error thrown without calling fail)
679
+ [Symbol.dispose]() {
680
+ // If aborted, another navigation/error took over - don't touch state
681
+ if (abort.signal.aborted) {
682
+ inflightActions.delete(id);
683
+ notify();
684
+ notifyAction(actionId);
685
+ return;
686
+ }
687
+
688
+ // If action was already completed, let the streaming token handle finalization
689
+ // The action is legitimately waiting for streaming to end
690
+ if (actionCompleted) {
691
+ return;
692
+ }
693
+
694
+ // If not settled and not completed, this is an error case - force finalize
695
+ if (!settled && inflightActions.has(id)) {
696
+ actionCompleted = true;
697
+ streamingEnded = true;
698
+ tryFinalize();
699
+ }
700
+ },
701
+ };
702
+ }
703
+
704
+ function abortAllActions() {
705
+ for (const entry of inflightActions.values()) {
706
+ entry.abort.abort();
707
+ }
708
+ inflightActions.clear();
709
+ hadAnyConcurrentActions = false;
710
+ concurrentRevalidatedSegments.clear();
711
+ notify();
712
+ // Notify all action listeners
713
+ for (const actionId of actionListeners.keys()) {
714
+ notifyAction(actionId);
715
+ }
716
+ }
717
+
718
+ // ========================================================================
719
+ // Handle Operations
720
+ // ========================================================================
721
+
722
+ /**
723
+ * Filter segment IDs to only include routes and layouts.
724
+ * Excludes parallels (contain .@) and loaders (contain D followed by digit).
725
+ */
726
+ function filterSegmentOrder(matched: string[]): string[] {
727
+ return matched.filter((id) => {
728
+ if (id.includes(".@")) return false;
729
+ if (/D\d+\./.test(id)) return false;
730
+ return true;
731
+ });
732
+ }
733
+
734
+ function setHandleData(
735
+ data: HandleData,
736
+ matched?: string[],
737
+ isPartial?: boolean
738
+ ): void {
739
+ const newSegmentOrder = filterSegmentOrder(matched ?? []);
740
+
741
+ if (isPartial && newSegmentOrder.length > 0) {
742
+ // Partial update: merge new data with existing
743
+ for (const handleName of Object.keys(data)) {
744
+ if (!handleData[handleName]) {
745
+ handleData[handleName] = {};
746
+ }
747
+ for (const segmentId of Object.keys(data[handleName])) {
748
+ handleData[handleName][segmentId] = data[handleName][segmentId];
749
+ }
750
+ }
751
+ // Clean up data from segments no longer in the matched list
752
+ for (const handleName of Object.keys(handleData)) {
753
+ for (const segmentId of Object.keys(handleData[handleName])) {
754
+ if (!newSegmentOrder.includes(segmentId)) {
755
+ delete handleData[handleName][segmentId];
756
+ }
757
+ }
758
+ }
759
+ } else {
760
+ // Full update: replace all data
761
+ handleData = data;
762
+ }
763
+ handleSegmentOrder = newSegmentOrder;
764
+
765
+ notifyHandles();
766
+ }
767
+
768
+ function getHandleState(): HandleState {
769
+ return {
770
+ data: handleData,
771
+ segmentOrder: handleSegmentOrder,
772
+ };
773
+ }
774
+
775
+ // ========================================================================
776
+ // Subscriptions
777
+ // ========================================================================
778
+
779
+ function subscribe(listener: StateListener): () => void {
780
+ stateListeners.add(listener);
781
+ return () => stateListeners.delete(listener);
782
+ }
783
+
784
+ function subscribeToAction(
785
+ actionId: string,
786
+ listener: ActionStateListener
787
+ ): () => void {
788
+ let listeners = actionListeners.get(actionId);
789
+ if (!listeners) {
790
+ listeners = new Set();
791
+ actionListeners.set(actionId, listeners);
792
+ }
793
+ listeners.add(listener);
794
+
795
+ return () => {
796
+ listeners!.delete(listener);
797
+ if (listeners!.size === 0) {
798
+ actionListeners.delete(actionId);
799
+ }
800
+ };
801
+ }
802
+
803
+ function subscribeToHandles(listener: HandleListener): () => void {
804
+ handleListeners.add(listener);
805
+ return () => handleListeners.delete(listener);
806
+ }
807
+
808
+ // ========================================================================
809
+ // Return Controller
810
+ // ========================================================================
811
+
812
+ return {
813
+ // Navigation
814
+ startNavigation,
815
+ abortNavigation,
816
+
817
+ // Actions
818
+ startAction,
819
+ abortAllActions,
820
+
821
+ // State
822
+ getState,
823
+ getActionState,
824
+ setLocation,
825
+
826
+ // Handles
827
+ setHandleData,
828
+ getHandleState,
829
+
830
+ // Subscriptions
831
+ subscribe,
832
+ subscribeToAction,
833
+ subscribeToHandles,
834
+
835
+ // Direct access
836
+ getCurrentNavigation: () => currentNavigation,
837
+ getInflightActions: () => inflightActions,
838
+ };
839
+ }
840
+
841
+ // ============================================================================
842
+ // Singleton
843
+ // ============================================================================
844
+
845
+ let controllerInstance: EventController | null = null;
846
+
847
+ /**
848
+ * Initialize the global event controller
849
+ */
850
+ export function initEventController(
851
+ config?: EventControllerConfig
852
+ ): EventController {
853
+ if (!controllerInstance) {
854
+ controllerInstance = createEventController(config);
855
+ }
856
+ return controllerInstance;
857
+ }
858
+
859
+ /**
860
+ * Get the global event controller
861
+ */
862
+ export function getEventController(): EventController {
863
+ if (!controllerInstance) {
864
+ throw new Error(
865
+ "Event controller not initialized. Call initEventController first."
866
+ );
867
+ }
868
+ return controllerInstance;
869
+ }
870
+
871
+ /**
872
+ * Reset the controller instance (for testing)
873
+ */
874
+ export function resetEventController(): void {
875
+ controllerInstance = null;
876
+ }