@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,134 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useState,
7
+ useEffect,
8
+ useRef,
9
+ useOptimistic,
10
+ startTransition,
11
+ type Context,
12
+ } from "react";
13
+ import { NavigationStoreContext } from "./context.js";
14
+
15
+ /**
16
+ * Context for Link component to provide its destination URL
17
+ * Used by useLinkStatus to determine if this specific link is pending
18
+ */
19
+ export const LinkContext: Context<string | null> = createContext<string | null>(null);
20
+
21
+ /**
22
+ * Link status returned by useLinkStatus hook
23
+ */
24
+ export interface LinkStatus {
25
+ /** Whether navigation to this link's destination is in progress */
26
+ pending: boolean;
27
+ }
28
+
29
+ /**
30
+ * Normalize URL for comparison
31
+ * Handles relative URLs and ensures consistent format
32
+ */
33
+ function normalizeUrl(url: string, origin: string): string {
34
+ try {
35
+ const parsed = new URL(url, origin);
36
+ // Return pathname + search + hash for comparison
37
+ return parsed.pathname + parsed.search + parsed.hash;
38
+ } catch {
39
+ return url;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Check if this link's destination matches the pending navigation URL
45
+ */
46
+ function isPendingFor(
47
+ linkTo: string | null,
48
+ pendingUrl: string | null,
49
+ origin: string
50
+ ): boolean {
51
+ if (linkTo === null || pendingUrl === null) {
52
+ return false;
53
+ }
54
+ return normalizeUrl(pendingUrl, origin) === normalizeUrl(linkTo, origin);
55
+ }
56
+
57
+ /**
58
+ * Hook to track the pending state of a Link component
59
+ *
60
+ * Must be used inside a Link component. Returns `{ pending: true }`
61
+ * when navigation to this link's destination is in progress.
62
+ *
63
+ * Useful for showing inline loading indicators on individual links.
64
+ *
65
+ * @example
66
+ * ```tsx
67
+ * function LoadingIndicator() {
68
+ * const { pending } = useLinkStatus();
69
+ * return pending ? <Spinner /> : null;
70
+ * }
71
+ *
72
+ * // In your component:
73
+ * <Link to="/dashboard">
74
+ * Dashboard
75
+ * <LoadingIndicator />
76
+ * </Link>
77
+ * ```
78
+ */
79
+ export function useLinkStatus(): LinkStatus {
80
+ const linkTo = useContext(LinkContext);
81
+ const ctx = useContext(NavigationStoreContext);
82
+
83
+ // Get origin for URL normalization (stable across renders)
84
+ const origin = typeof window !== "undefined"
85
+ ? window.location.origin
86
+ : "http://localhost";
87
+
88
+ // Base state for useOptimistic
89
+ const [basePending, setBasePending] = useState<boolean>(() => {
90
+ if (!ctx || linkTo === null) {
91
+ return false;
92
+ }
93
+ const state = ctx.eventController.getState();
94
+ return isPendingFor(linkTo, state.pendingUrl, origin);
95
+ });
96
+
97
+ const prevPending = useRef(basePending);
98
+
99
+ // useOptimistic allows immediate updates during transitions
100
+ const [pending, setOptimisticPending] = useOptimistic(basePending);
101
+
102
+ useEffect(() => {
103
+ if (!ctx || linkTo === null) {
104
+ return;
105
+ }
106
+
107
+ // Subscribe to navigation state changes
108
+ return ctx.eventController.subscribe(() => {
109
+ const state = ctx.eventController.getState();
110
+ const isPending = isPendingFor(linkTo, state.pendingUrl, origin);
111
+
112
+ if (isPending !== prevPending.current) {
113
+ prevPending.current = isPending;
114
+
115
+ // Use optimistic update for immediate feedback during navigation
116
+ if (state.state !== "idle") {
117
+ startTransition(() => {
118
+ setOptimisticPending(isPending);
119
+ });
120
+ }
121
+
122
+ // Always update base state
123
+ setBasePending(isPending);
124
+ }
125
+ });
126
+ }, [linkTo, origin]);
127
+
128
+ // If not inside a Link, return not pending
129
+ if (linkTo === null) {
130
+ return { pending: false };
131
+ }
132
+
133
+ return { pending };
134
+ }
@@ -0,0 +1,150 @@
1
+ "use client";
2
+
3
+ import {
4
+ useContext,
5
+ useState,
6
+ useEffect,
7
+ useOptimistic,
8
+ startTransition,
9
+ useRef,
10
+ } from "react";
11
+ import { NavigationStoreContext } from "./context.js";
12
+ import type { PublicNavigationState, NavigateOptions } from "../types.js";
13
+ import type { DerivedNavigationState } from "../event-controller.js";
14
+
15
+ /**
16
+ * Shallow equality check for selector results
17
+ */
18
+ function shallowEqual<T>(a: T, b: T): boolean {
19
+ if (Object.is(a, b)) return true;
20
+ if (
21
+ typeof a !== "object" ||
22
+ a === null ||
23
+ typeof b !== "object" ||
24
+ b === null
25
+ ) {
26
+ return false;
27
+ }
28
+ const keysA = Object.keys(a);
29
+ const keysB = Object.keys(b);
30
+ if (keysA.length !== keysB.length) return false;
31
+ for (const key of keysA) {
32
+ if (
33
+ !Object.hasOwn(b, key) ||
34
+ !Object.is((a as any)[key], (b as any)[key])
35
+ ) {
36
+ return false;
37
+ }
38
+ }
39
+ return true;
40
+ }
41
+
42
+ // SSR-safe default state (public version without internal properties)
43
+ const SSR_DEFAULT_STATE: PublicNavigationState = {
44
+ state: "idle",
45
+ isStreaming: false,
46
+ location: new URL("/", "http://localhost"),
47
+ pendingUrl: null,
48
+ };
49
+
50
+ /**
51
+ * Convert derived state to public version (strips inflightActions)
52
+ */
53
+ function toPublicState(state: DerivedNavigationState): PublicNavigationState {
54
+ const { inflightActions: _, ...publicState } = state;
55
+ return publicState;
56
+ }
57
+
58
+ // No-op functions for SSR
59
+ const noopNavigate = async () => {};
60
+ const noopRefresh = async () => {};
61
+
62
+ /**
63
+ * Navigation methods returned by useNavigation
64
+ */
65
+ export interface NavigationMethods {
66
+ navigate: (url: string, options?: NavigateOptions) => Promise<void>;
67
+ refresh: () => Promise<void>;
68
+ }
69
+
70
+ /**
71
+ * Full value returned when no selector is provided
72
+ */
73
+ export type NavigationValue = PublicNavigationState & NavigationMethods;
74
+
75
+ /**
76
+ * Hook to access navigation state with optional selector for performance
77
+ *
78
+ * Uses the event controller for reactive state management.
79
+ * State is derived from source of truth (currentNavigation, inflightActions).
80
+ *
81
+ * @example
82
+ * ```tsx
83
+ * const state = useNavigation(nav => nav.state);
84
+ * const isLoading = useNavigation(nav => nav.state === 'loading');
85
+ * ```
86
+ */
87
+ export function useNavigation(): NavigationValue;
88
+ export function useNavigation<T>(
89
+ selector: (state: PublicNavigationState) => T
90
+ ): T;
91
+ export function useNavigation<T>(
92
+ selector?: (state: PublicNavigationState) => T
93
+ ): T | NavigationValue {
94
+ const ctx = useContext(NavigationStoreContext);
95
+
96
+ // Base state for useOptimistic
97
+ const [baseValue, setBaseValue] = useState<T | PublicNavigationState>(() => {
98
+ if (typeof document === "undefined" || !ctx) {
99
+ return selector ? selector(SSR_DEFAULT_STATE) : SSR_DEFAULT_STATE;
100
+ }
101
+ const publicState = toPublicState(ctx.eventController.getState());
102
+ return selector ? selector(publicState) : publicState;
103
+ });
104
+ const prevState = useRef(baseValue);
105
+
106
+ // useOptimistic allows immediate updates during transitions/actions
107
+ const [value, setOptimisticValue] = useOptimistic(baseValue);
108
+
109
+ // Subscribe to event controller state changes (only runs on client)
110
+ useEffect(() => {
111
+ if (!ctx) return;
112
+
113
+ // Subscribe to updates from event controller
114
+ return ctx.eventController.subscribe(() => {
115
+ const currentState = ctx.eventController.getState();
116
+ const publicState = toPublicState(currentState);
117
+ const nextSelected = selector ? selector(publicState) : publicState;
118
+
119
+ // Check if selected value has changed
120
+ if (!shallowEqual(nextSelected, prevState.current)) {
121
+ prevState.current = nextSelected;
122
+
123
+ // Check if any actions are in progress for optimistic updates
124
+ const hasInflightActions =
125
+ ctx.eventController.getInflightActions().size > 0;
126
+
127
+ if (hasInflightActions || publicState.state !== "idle") {
128
+ // Use optimistic update for immediate feedback during transitions
129
+ startTransition(() => {
130
+ setOptimisticValue(nextSelected);
131
+ });
132
+ }
133
+
134
+ // Always update base state so UI reflects current state
135
+ setBaseValue(nextSelected);
136
+ }
137
+ });
138
+ }, [selector]);
139
+
140
+ // If no selector, include navigation methods
141
+ if (!selector) {
142
+ return {
143
+ ...(value as PublicNavigationState),
144
+ navigate: ctx?.navigate ?? noopNavigate,
145
+ refresh: ctx?.refresh ?? noopRefresh,
146
+ };
147
+ }
148
+
149
+ return value as T;
150
+ }
@@ -0,0 +1,188 @@
1
+ "use client";
2
+
3
+ import { useContext, useState, useEffect, useRef } from "react";
4
+ import { NavigationStoreContext } from "./context.js";
5
+
6
+ /**
7
+ * Segments state returned by useSegments hook
8
+ */
9
+ export interface SegmentsState {
10
+ /** URL path segments (e.g., /shop/products/123 → ["shop", "products", "123"]) */
11
+ path: readonly string[];
12
+ /** Matched segment IDs in order (layouts and routes only, e.g., ["L0", "L0L1", "L0L1R0"]) */
13
+ segmentIds: readonly string[];
14
+ /** Current URL location */
15
+ location: URL;
16
+ }
17
+
18
+ /**
19
+ * SSR module-level state.
20
+ * Populated by initSegmentsSync before React renders.
21
+ * Used by useState initializer during SSR.
22
+ */
23
+ let ssrSegmentOrder: string[] = [];
24
+ let ssrPathname: string = "/";
25
+
26
+ /**
27
+ * Filter segment IDs to only include routes and layouts.
28
+ * Excludes parallels (contain .@) and loaders (contain D followed by digit).
29
+ */
30
+ function filterSegmentOrder(matched: string[]): string[] {
31
+ return matched.filter((id) => {
32
+ if (id.includes(".@")) return false;
33
+ if (/D\d+\./.test(id)) return false;
34
+ return true;
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Initialize segments data synchronously for SSR.
40
+ * Called before rendering to populate state for useState initializer.
41
+ *
42
+ * @param matched - Segment order from RSC metadata
43
+ * @param pathname - Current pathname
44
+ */
45
+ export function initSegmentsSync(matched?: string[], pathname?: string): void {
46
+ ssrSegmentOrder = filterSegmentOrder(matched ?? []);
47
+ ssrPathname = pathname ?? "/";
48
+ }
49
+
50
+ /**
51
+ * Shallow equality check for selector results
52
+ */
53
+ function shallowEqual<T>(a: T, b: T): boolean {
54
+ if (Object.is(a, b)) return true;
55
+ if (
56
+ typeof a !== "object" ||
57
+ a === null ||
58
+ typeof b !== "object" ||
59
+ b === null
60
+ ) {
61
+ return false;
62
+ }
63
+ const keysA = Object.keys(a);
64
+ const keysB = Object.keys(b);
65
+ if (keysA.length !== keysB.length) return false;
66
+ for (const key of keysA) {
67
+ if (
68
+ !Object.hasOwn(b, key) ||
69
+ !Object.is((a as any)[key], (b as any)[key])
70
+ ) {
71
+ return false;
72
+ }
73
+ }
74
+ return true;
75
+ }
76
+
77
+ /**
78
+ * Parse pathname into path segments
79
+ * /shop/products/123 → ["shop", "products", "123"]
80
+ */
81
+ function parsePathname(pathname: string): string[] {
82
+ return pathname.split("/").filter(Boolean);
83
+ }
84
+
85
+ /**
86
+ * Build segments state from event controller
87
+ */
88
+ function buildSegmentsState(
89
+ location: URL,
90
+ segmentOrder: string[]
91
+ ): SegmentsState {
92
+ return {
93
+ path: parsePathname(location.pathname),
94
+ segmentIds: segmentOrder,
95
+ location,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Build SSR state from module-level variables
101
+ */
102
+ function buildSsrState(): SegmentsState {
103
+ const location = new URL(ssrPathname, "http://localhost");
104
+ return {
105
+ path: parsePathname(ssrPathname),
106
+ segmentIds: ssrSegmentOrder,
107
+ location,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Hook to access current route segments with optional selector for performance
113
+ *
114
+ * Provides information about the current URL path and matched route segments.
115
+ * Uses the event controller for reactive state management.
116
+ *
117
+ * @example
118
+ * ```tsx
119
+ * // Get full segments state
120
+ * const { path, segmentIds, location } = useSegments();
121
+ *
122
+ * // Use selector for specific values (better performance)
123
+ * const path = useSegments(s => s.path);
124
+ * const isShopRoute = useSegments(s => s.path[0] === "shop");
125
+ * ```
126
+ */
127
+ export function useSegments(): SegmentsState;
128
+ export function useSegments<T>(selector: (state: SegmentsState) => T): T;
129
+ export function useSegments<T>(
130
+ selector?: (state: SegmentsState) => T
131
+ ): T | SegmentsState {
132
+ const ctx = useContext(NavigationStoreContext);
133
+
134
+ // Build initial state from SSR module state or event controller
135
+ const [state, setState] = useState<T | SegmentsState>(() => {
136
+ // During SSR or when no context, use module-level SSR state
137
+ if (typeof document === "undefined" || !ctx) {
138
+ const ssrState = buildSsrState();
139
+ return selector ? selector(ssrState) : ssrState;
140
+ }
141
+ // On client with context, use event controller state
142
+ const navState = ctx.eventController.getState();
143
+ const handleState = ctx.eventController.getHandleState();
144
+ const segmentsState = buildSegmentsState(
145
+ navState.location as URL,
146
+ handleState.segmentOrder
147
+ );
148
+ return selector ? selector(segmentsState) : segmentsState;
149
+ });
150
+
151
+ const prevState = useRef(state);
152
+
153
+ // Subscribe to both navigation state and handle state changes
154
+ useEffect(() => {
155
+ if (!ctx) {
156
+ return;
157
+ }
158
+
159
+ const updateState = () => {
160
+ const navState = ctx.eventController.getState();
161
+ const handleState = ctx.eventController.getHandleState();
162
+ const segmentsState = buildSegmentsState(
163
+ navState.location as URL,
164
+ handleState.segmentOrder
165
+ );
166
+ const nextSelected = selector ? selector(segmentsState) : segmentsState;
167
+
168
+ if (!shallowEqual(nextSelected, prevState.current)) {
169
+ prevState.current = nextSelected;
170
+ setState(nextSelected);
171
+ }
172
+ };
173
+
174
+ // Initial update in case SSR state differs from client state
175
+ updateState();
176
+
177
+ // Subscribe to both state sources
178
+ const unsubscribeNav = ctx.eventController.subscribe(updateState);
179
+ const unsubscribeHandles = ctx.eventController.subscribeToHandles(updateState);
180
+
181
+ return () => {
182
+ unsubscribeNav();
183
+ unsubscribeHandles();
184
+ };
185
+ }, [selector]);
186
+
187
+ return state as T | SegmentsState;
188
+ }
@@ -0,0 +1,149 @@
1
+ import type { RequestController, DisposableAbortController } from "./types.js";
2
+
3
+ // Polyfill Symbol.dispose for Safari and older browsers
4
+ if (typeof Symbol.dispose === "undefined") {
5
+ (Symbol as any).dispose = Symbol("Symbol.dispose");
6
+ }
7
+
8
+ /**
9
+ * Create a request controller for managing concurrent abort controllers
10
+ *
11
+ * This utility helps manage concurrent navigation requests by providing
12
+ * a way to abort all pending requests when a new navigation starts.
13
+ *
14
+ * @returns RequestController instance
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * const controller = createRequestController();
19
+ *
20
+ * // Start a new request
21
+ * const abortController = controller.create();
22
+ * fetch(url, { signal: abortController.signal });
23
+ *
24
+ * // Abort all pending requests (e.g., when starting new navigation)
25
+ * controller.abortAll();
26
+ *
27
+ * // Clean up completed request
28
+ * controller.remove(abortController);
29
+ * ```
30
+ */
31
+ export function createRequestController(): RequestController {
32
+ // Navigation controllers - aborted on new navigation
33
+ const controllers: AbortController[] = [];
34
+ // Action controllers - NOT aborted by navigation, only by errors
35
+ const actionControllers: AbortController[] = [];
36
+
37
+ return {
38
+ /**
39
+ * Create a new abort controller and track it for navigation
40
+ *
41
+ * @returns A new AbortController
42
+ */
43
+ create(): AbortController {
44
+ const controller = new AbortController();
45
+ controllers.push(controller);
46
+ console.log(
47
+ `[Browser] Created abort controller, total: ${controllers.length}`
48
+ );
49
+ return controller;
50
+ },
51
+
52
+ /**
53
+ * Create a disposable abort controller for navigation use with `using` keyword
54
+ *
55
+ * The controller will be automatically removed from tracking when
56
+ * it goes out of scope, regardless of how the scope is exited.
57
+ *
58
+ * @returns A DisposableAbortController
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * async function handleNavigation() {
63
+ * requestController.abortAll();
64
+ * using { controller } = requestController.createDisposable();
65
+ * // ... use controller.signal ...
66
+ * // controller is automatically removed on scope exit
67
+ * }
68
+ * ```
69
+ */
70
+ createDisposable(): DisposableAbortController {
71
+ const controller = this.create();
72
+ return {
73
+ controller,
74
+ [Symbol.dispose]: () => {
75
+ this.remove(controller);
76
+ },
77
+ };
78
+ },
79
+
80
+ /**
81
+ * Create a disposable abort controller for actions
82
+ *
83
+ * Action controllers are NOT aborted by navigation - they complete
84
+ * independently. Only aborted by abortAllActions() on error.
85
+ *
86
+ * @returns A DisposableAbortController
87
+ */
88
+ createActionDisposable(): DisposableAbortController {
89
+ const controller = new AbortController();
90
+ actionControllers.push(controller);
91
+ console.log(
92
+ `[Browser] Created action controller, total: ${actionControllers.length}`
93
+ );
94
+ return {
95
+ controller,
96
+ [Symbol.dispose]: () => {
97
+ const index = actionControllers.indexOf(controller);
98
+ if (index !== -1) {
99
+ actionControllers.splice(index, 1);
100
+ console.log(
101
+ `[Browser] Removed action controller, remaining: ${actionControllers.length}`
102
+ );
103
+ }
104
+ },
105
+ };
106
+ },
107
+
108
+ /**
109
+ * Abort all navigation controllers (NOT actions)
110
+ *
111
+ * Called when starting new navigation. Actions continue
112
+ * to complete in the background.
113
+ */
114
+ abortAll(): void {
115
+ controllers.forEach((controller) => controller.abort());
116
+ controllers.length = 0;
117
+ console.log(`[Browser] Aborted all navigation controllers`);
118
+ },
119
+
120
+ /**
121
+ * Abort all action controllers
122
+ *
123
+ * Called when an action error occurs - prevents other actions
124
+ * from completing and overwriting the error UI.
125
+ */
126
+ abortAllActions(): void {
127
+ actionControllers.forEach((controller) => controller.abort());
128
+ actionControllers.length = 0;
129
+ console.log(`[Browser] Aborted all action controllers`);
130
+ },
131
+
132
+ /**
133
+ * Remove a specific controller from tracking
134
+ *
135
+ * Call this when a request completes successfully.
136
+ *
137
+ * @param controller - The controller to remove
138
+ */
139
+ remove(controller: AbortController): void {
140
+ const index = controllers.indexOf(controller);
141
+ if (index !== -1) {
142
+ controllers.splice(index, 1);
143
+ console.log(
144
+ `[Browser] Removed abort controller, remaining: ${controllers.length}`
145
+ );
146
+ }
147
+ },
148
+ };
149
+ }