@ivogt/rsc-router 0.0.0-experimental.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/package.json +131 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +891 -0
- package/src/browser/navigation-client.ts +155 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +545 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +228 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +149 -0
- package/src/browser/rsc-router.tsx +310 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +443 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
- package/src/cache/cf/cf-cache-store.ts +274 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/index.ts +52 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +366 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +609 -0
- package/src/components/DefaultDocument.tsx +20 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +178 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href.ts +139 -0
- package/src/index.rsc.ts +69 -0
- package/src/index.ts +84 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1333 -0
- package/src/route-map-builder.ts +140 -0
- package/src/route-types.ts +148 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +60 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +116 -0
- package/src/router/match-context.ts +261 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +250 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +212 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +271 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3484 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +942 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +225 -0
- package/src/segment-system.tsx +405 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +340 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +470 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +126 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +215 -0
- package/src/types.ts +1473 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +608 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +109 -0
|
@@ -0,0 +1,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
|
+
}
|