@rangojs/router 0.0.0-experimental.10

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 (172) hide show
  1. package/CLAUDE.md +43 -0
  2. package/README.md +19 -0
  3. package/dist/bin/rango.js +227 -0
  4. package/dist/vite/index.js +3039 -0
  5. package/package.json +171 -0
  6. package/skills/caching/SKILL.md +191 -0
  7. package/skills/debug-manifest/SKILL.md +108 -0
  8. package/skills/document-cache/SKILL.md +180 -0
  9. package/skills/fonts/SKILL.md +165 -0
  10. package/skills/hooks/SKILL.md +442 -0
  11. package/skills/intercept/SKILL.md +190 -0
  12. package/skills/layout/SKILL.md +213 -0
  13. package/skills/links/SKILL.md +180 -0
  14. package/skills/loader/SKILL.md +246 -0
  15. package/skills/middleware/SKILL.md +202 -0
  16. package/skills/mime-routes/SKILL.md +124 -0
  17. package/skills/parallel/SKILL.md +228 -0
  18. package/skills/prerender/SKILL.md +283 -0
  19. package/skills/rango/SKILL.md +54 -0
  20. package/skills/response-routes/SKILL.md +358 -0
  21. package/skills/route/SKILL.md +173 -0
  22. package/skills/router-setup/SKILL.md +346 -0
  23. package/skills/tailwind/SKILL.md +129 -0
  24. package/skills/theme/SKILL.md +78 -0
  25. package/skills/typesafety/SKILL.md +394 -0
  26. package/src/__internal.ts +175 -0
  27. package/src/bin/rango.ts +24 -0
  28. package/src/browser/event-controller.ts +876 -0
  29. package/src/browser/index.ts +18 -0
  30. package/src/browser/link-interceptor.ts +121 -0
  31. package/src/browser/lru-cache.ts +69 -0
  32. package/src/browser/merge-segment-loaders.ts +126 -0
  33. package/src/browser/navigation-bridge.ts +913 -0
  34. package/src/browser/navigation-client.ts +165 -0
  35. package/src/browser/navigation-store.ts +823 -0
  36. package/src/browser/partial-update.ts +600 -0
  37. package/src/browser/react/Link.tsx +248 -0
  38. package/src/browser/react/NavigationProvider.tsx +346 -0
  39. package/src/browser/react/ScrollRestoration.tsx +94 -0
  40. package/src/browser/react/context.ts +53 -0
  41. package/src/browser/react/index.ts +52 -0
  42. package/src/browser/react/location-state-shared.ts +120 -0
  43. package/src/browser/react/location-state.ts +62 -0
  44. package/src/browser/react/mount-context.ts +32 -0
  45. package/src/browser/react/use-action.ts +240 -0
  46. package/src/browser/react/use-client-cache.ts +56 -0
  47. package/src/browser/react/use-handle.ts +203 -0
  48. package/src/browser/react/use-href.tsx +40 -0
  49. package/src/browser/react/use-link-status.ts +134 -0
  50. package/src/browser/react/use-mount.ts +31 -0
  51. package/src/browser/react/use-navigation.ts +140 -0
  52. package/src/browser/react/use-segments.ts +188 -0
  53. package/src/browser/request-controller.ts +164 -0
  54. package/src/browser/rsc-router.tsx +352 -0
  55. package/src/browser/scroll-restoration.ts +324 -0
  56. package/src/browser/segment-structure-assert.ts +67 -0
  57. package/src/browser/server-action-bridge.ts +762 -0
  58. package/src/browser/shallow.ts +35 -0
  59. package/src/browser/types.ts +478 -0
  60. package/src/build/generate-manifest.ts +377 -0
  61. package/src/build/generate-route-types.ts +828 -0
  62. package/src/build/index.ts +36 -0
  63. package/src/build/route-trie.ts +239 -0
  64. package/src/cache/cache-scope.ts +563 -0
  65. package/src/cache/cf/cf-cache-store.ts +428 -0
  66. package/src/cache/cf/index.ts +19 -0
  67. package/src/cache/document-cache.ts +340 -0
  68. package/src/cache/index.ts +58 -0
  69. package/src/cache/memory-segment-store.ts +150 -0
  70. package/src/cache/memory-store.ts +253 -0
  71. package/src/cache/types.ts +392 -0
  72. package/src/client.rsc.tsx +83 -0
  73. package/src/client.tsx +643 -0
  74. package/src/component-utils.ts +76 -0
  75. package/src/components/DefaultDocument.tsx +23 -0
  76. package/src/debug.ts +233 -0
  77. package/src/default-error-boundary.tsx +88 -0
  78. package/src/deps/browser.ts +8 -0
  79. package/src/deps/html-stream-client.ts +2 -0
  80. package/src/deps/html-stream-server.ts +2 -0
  81. package/src/deps/rsc.ts +10 -0
  82. package/src/deps/ssr.ts +2 -0
  83. package/src/errors.ts +295 -0
  84. package/src/handle.ts +130 -0
  85. package/src/handles/MetaTags.tsx +193 -0
  86. package/src/handles/index.ts +6 -0
  87. package/src/handles/meta.ts +247 -0
  88. package/src/host/cookie-handler.ts +159 -0
  89. package/src/host/errors.ts +97 -0
  90. package/src/host/index.ts +56 -0
  91. package/src/host/pattern-matcher.ts +214 -0
  92. package/src/host/router.ts +330 -0
  93. package/src/host/testing.ts +79 -0
  94. package/src/host/types.ts +138 -0
  95. package/src/host/utils.ts +25 -0
  96. package/src/href-client.ts +202 -0
  97. package/src/href-context.ts +33 -0
  98. package/src/index.rsc.ts +121 -0
  99. package/src/index.ts +165 -0
  100. package/src/loader.rsc.ts +207 -0
  101. package/src/loader.ts +47 -0
  102. package/src/network-error-thrower.tsx +21 -0
  103. package/src/outlet-context.ts +15 -0
  104. package/src/prerender/param-hash.ts +35 -0
  105. package/src/prerender/store.ts +40 -0
  106. package/src/prerender.ts +156 -0
  107. package/src/reverse.ts +267 -0
  108. package/src/root-error-boundary.tsx +277 -0
  109. package/src/route-content-wrapper.tsx +193 -0
  110. package/src/route-definition.ts +1431 -0
  111. package/src/route-map-builder.ts +242 -0
  112. package/src/route-types.ts +220 -0
  113. package/src/router/error-handling.ts +287 -0
  114. package/src/router/handler-context.ts +158 -0
  115. package/src/router/intercept-resolution.ts +387 -0
  116. package/src/router/loader-resolution.ts +327 -0
  117. package/src/router/manifest.ts +216 -0
  118. package/src/router/match-api.ts +621 -0
  119. package/src/router/match-context.ts +264 -0
  120. package/src/router/match-middleware/background-revalidation.ts +236 -0
  121. package/src/router/match-middleware/cache-lookup.ts +382 -0
  122. package/src/router/match-middleware/cache-store.ts +276 -0
  123. package/src/router/match-middleware/index.ts +81 -0
  124. package/src/router/match-middleware/intercept-resolution.ts +281 -0
  125. package/src/router/match-middleware/segment-resolution.ts +184 -0
  126. package/src/router/match-pipelines.ts +214 -0
  127. package/src/router/match-result.ts +213 -0
  128. package/src/router/metrics.ts +62 -0
  129. package/src/router/middleware.ts +791 -0
  130. package/src/router/pattern-matching.ts +407 -0
  131. package/src/router/revalidation.ts +190 -0
  132. package/src/router/router-context.ts +301 -0
  133. package/src/router/segment-resolution.ts +1315 -0
  134. package/src/router/trie-matching.ts +172 -0
  135. package/src/router/types.ts +163 -0
  136. package/src/router.gen.ts +6 -0
  137. package/src/router.ts +2423 -0
  138. package/src/rsc/handler.ts +1443 -0
  139. package/src/rsc/helpers.ts +64 -0
  140. package/src/rsc/index.ts +56 -0
  141. package/src/rsc/nonce.ts +18 -0
  142. package/src/rsc/types.ts +236 -0
  143. package/src/segment-system.tsx +442 -0
  144. package/src/server/context.ts +466 -0
  145. package/src/server/handle-store.ts +229 -0
  146. package/src/server/loader-registry.ts +174 -0
  147. package/src/server/request-context.ts +554 -0
  148. package/src/server/root-layout.tsx +10 -0
  149. package/src/server/tsconfig.json +14 -0
  150. package/src/server.ts +171 -0
  151. package/src/ssr/index.tsx +296 -0
  152. package/src/theme/ThemeProvider.tsx +291 -0
  153. package/src/theme/ThemeScript.tsx +61 -0
  154. package/src/theme/constants.ts +59 -0
  155. package/src/theme/index.ts +58 -0
  156. package/src/theme/theme-context.ts +70 -0
  157. package/src/theme/theme-script.ts +152 -0
  158. package/src/theme/types.ts +182 -0
  159. package/src/theme/use-theme.ts +44 -0
  160. package/src/types.ts +1757 -0
  161. package/src/urls.gen.ts +8 -0
  162. package/src/urls.ts +1282 -0
  163. package/src/use-loader.tsx +346 -0
  164. package/src/vite/expose-action-id.ts +344 -0
  165. package/src/vite/expose-handle-id.ts +209 -0
  166. package/src/vite/expose-loader-id.ts +426 -0
  167. package/src/vite/expose-location-state-id.ts +177 -0
  168. package/src/vite/expose-prerender-handler-id.ts +429 -0
  169. package/src/vite/index.ts +2068 -0
  170. package/src/vite/package-resolution.ts +125 -0
  171. package/src/vite/version.d.ts +12 -0
  172. package/src/vite/virtual-entries.ts +114 -0
@@ -0,0 +1,913 @@
1
+ import type {
2
+ NavigationBridge,
3
+ NavigationBridgeConfig,
4
+ NavigateOptions,
5
+ NavigationStore,
6
+ ResolvedSegment,
7
+ } from "./types.js";
8
+ import {
9
+ isLocationStateEntry,
10
+ resolveLocationStateEntries,
11
+ } from "./react/location-state-shared.js";
12
+
13
+ /**
14
+ * Check if state is from typed LocationStateEntry[] (has __rsc_ls_ keys)
15
+ */
16
+ function isTypedLocationState(
17
+ state: unknown
18
+ ): state is Record<string, unknown> {
19
+ if (state === null || typeof state !== "object") return false;
20
+ return Object.keys(state).some((key) => key.startsWith("__rsc_ls_"));
21
+ }
22
+
23
+ /**
24
+ * Resolve navigation state - handles both LocationStateEntry[] and legacy formats
25
+ */
26
+ function resolveNavigationState(state: unknown): unknown {
27
+ // Check if it's an array of LocationStateEntry
28
+ if (
29
+ Array.isArray(state) &&
30
+ state.length > 0 &&
31
+ isLocationStateEntry(state[0])
32
+ ) {
33
+ return resolveLocationStateEntries(state);
34
+ }
35
+ // Return as-is for legacy formats
36
+ return state;
37
+ }
38
+
39
+ /**
40
+ * Build history state object from user state
41
+ * - Typed state: spread directly into history.state
42
+ * - Legacy state: store in history.state.state
43
+ */
44
+ function buildHistoryState(
45
+ userState: unknown,
46
+ routerState?: { intercept?: boolean; sourceUrl?: string }
47
+ ): Record<string, unknown> | null {
48
+ const result: Record<string, unknown> = {};
49
+
50
+ // Add router internal state
51
+ if (routerState?.intercept) {
52
+ result.intercept = true;
53
+ if (routerState.sourceUrl) {
54
+ result.sourceUrl = routerState.sourceUrl;
55
+ }
56
+ }
57
+
58
+ // Add user state
59
+ if (userState !== undefined) {
60
+ if (isTypedLocationState(userState)) {
61
+ // Typed state: spread directly
62
+ Object.assign(result, userState);
63
+ } else {
64
+ // Legacy state: store in .state
65
+ result.state = userState;
66
+ }
67
+ }
68
+
69
+ return Object.keys(result).length > 0 ? result : null;
70
+ }
71
+ import { setupLinkInterception } from "./link-interceptor.js";
72
+ import { createPartialUpdater } from "./partial-update.js";
73
+ import { generateHistoryKey } from "./navigation-store.js";
74
+ import {
75
+ handleNavigationStart,
76
+ handleNavigationEnd,
77
+ ensureHistoryKey,
78
+ } from "./scroll-restoration.js";
79
+ import type { EventController, NavigationHandle } from "./event-controller.js";
80
+ import { NetworkError, isNetworkError } from "../errors.js";
81
+ import { NetworkErrorThrower } from "../network-error-thrower.js";
82
+ import { createElement, startTransition } from "react";
83
+
84
+ // Polyfill Symbol.dispose for Safari and older browsers
85
+ if (typeof Symbol.dispose === "undefined") {
86
+ (Symbol as any).dispose = Symbol("Symbol.dispose");
87
+ }
88
+
89
+ /**
90
+ * Check if a segment is an intercept segment
91
+ * Intercept segments have namespace starting with "intercept:" or ID containing .@
92
+ */
93
+ function isInterceptSegment(s: ResolvedSegment): boolean {
94
+ return (
95
+ s.namespace?.startsWith("intercept:") ||
96
+ (s.type === "parallel" && s.id.includes(".@"))
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Check if cached segments are intercept-only (no main route segments)
102
+ * Intercept responses shouldn't be used for optimistic rendering since
103
+ * whether interception happens depends on the current page context
104
+ */
105
+ function isInterceptOnlyCache(segments: ResolvedSegment[]): boolean {
106
+ return segments.some(isInterceptSegment);
107
+ }
108
+
109
+ /**
110
+ * Options for committing a navigation transaction
111
+ */
112
+ interface CommitOptions {
113
+ url: string;
114
+ segmentIds: string[];
115
+ segments: ResolvedSegment[];
116
+ replace?: boolean;
117
+ scroll?: boolean;
118
+ /** User-provided state to store in history.state */
119
+ state?: unknown;
120
+ /** If true, only update store without changing URL/history (for server actions) */
121
+ storeOnly?: boolean;
122
+ /** If true, this is an intercept route - store in history.state for popstate handling */
123
+ intercept?: boolean;
124
+ /** Source URL where the intercept was triggered from (stored in history.state) */
125
+ interceptSourceUrl?: string;
126
+ /** If true, only update cache without touching store or history (for background stale revalidation) */
127
+ cacheOnly?: boolean;
128
+ }
129
+
130
+ /**
131
+ * Options that can override the pre-configured commit settings
132
+ */
133
+ interface BoundCommitOverrides {
134
+ /** Override scroll behavior (e.g., disable for intercepts) */
135
+ scroll?: boolean;
136
+ /** Override replace behavior (e.g., force replace for intercepts) */
137
+ replace?: boolean;
138
+ /** Override user-provided state */
139
+ state?: unknown;
140
+ /** Mark this as an intercept route */
141
+ intercept?: boolean;
142
+ /** Source URL where intercept was triggered from */
143
+ interceptSourceUrl?: string;
144
+ /** If true, only update cache (for stale revalidation) */
145
+ cacheOnly?: boolean;
146
+ }
147
+
148
+ /**
149
+ * Token for tracking an active stream - call end() when stream completes
150
+ */
151
+ export interface StreamingToken {
152
+ end(): void;
153
+ }
154
+
155
+ /**
156
+ * Bound transaction with pre-configured commit options (without segmentIds/segments)
157
+ */
158
+ export interface BoundTransaction {
159
+ readonly currentUrl: string;
160
+ /** Start streaming and get a token to end it when the stream completes */
161
+ startStreaming(): StreamingToken;
162
+ commit(
163
+ segmentIds: string[],
164
+ segments: ResolvedSegment[],
165
+ overrides?: BoundCommitOverrides
166
+ ): void;
167
+ }
168
+
169
+ /**
170
+ * Navigation transaction for managing state during navigation
171
+ * Uses the event controller handle for lifecycle management
172
+ */
173
+ interface NavigationTransaction extends Disposable {
174
+ /** Optimistically commit from cache - instant render before revalidation */
175
+ optimisticCommit(options: CommitOptions): void;
176
+ /** Final commit with server data (or reconciliation after optimistic) */
177
+ commit(options: CommitOptions): void;
178
+ with(
179
+ options: Omit<CommitOptions, "segmentIds" | "segments">
180
+ ): BoundTransaction;
181
+ /** The navigation handle from the event controller */
182
+ handle: NavigationHandle;
183
+ }
184
+
185
+ /**
186
+ * Creates a navigation transaction that coordinates with the event controller.
187
+ * Handles loading state transitions and cleanup on completion/abort.
188
+ *
189
+ * Supports optimistic navigation: render from cache immediately,
190
+ * then revalidate in background and reconcile if data changed.
191
+ */
192
+ function createNavigationTransaction(
193
+ store: NavigationStore,
194
+ eventController: EventController,
195
+ url: string,
196
+ options?: NavigateOptions & { skipLoadingState?: boolean }
197
+ ): NavigationTransaction {
198
+ let committed = false;
199
+ let optimisticallyCommitted = false;
200
+ let earlyStatePushed = false;
201
+ const currentUrl = window.location.href;
202
+
203
+ // Start navigation in event controller (this sets loading state)
204
+ const handle = eventController.startNavigation(url, options);
205
+
206
+ // If state is provided, push it to history immediately so loading UI can access it
207
+ // This enables "optimistic state" - showing product names in skeletons etc.
208
+ if (options?.state !== undefined && !options?.replace) {
209
+ const earlyHistoryState = buildHistoryState(options.state);
210
+ window.history.pushState(earlyHistoryState, "", url);
211
+ earlyStatePushed = true;
212
+ }
213
+
214
+ /**
215
+ * Optimistically commit from cache - renders immediately before revalidation
216
+ * Sets optimisticallyCommitted flag so final commit() knows to reconcile
217
+ */
218
+ function optimisticCommit(opts: CommitOptions): void {
219
+ optimisticallyCommitted = true;
220
+
221
+ const { url, segmentIds, segments, replace, scroll } = opts;
222
+ const parsedUrl = new URL(url, window.location.origin);
223
+
224
+ // Save current scroll position before navigating
225
+ handleNavigationStart();
226
+
227
+ // Update segment state
228
+ store.setSegmentIds(segmentIds);
229
+ store.setCurrentUrl(url);
230
+ store.setPath(parsedUrl.pathname);
231
+
232
+ // Generate history key from URL
233
+ const historyKey = generateHistoryKey(url);
234
+ store.setHistoryKey(historyKey);
235
+
236
+ // Cache segments with current handleData (will be overwritten by fresh data on final commit)
237
+ const currentHandleData = eventController.getHandleState().data;
238
+ store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
239
+
240
+ // Build history state with user state if provided
241
+ const historyState = buildHistoryState(opts.state);
242
+
243
+ // Update browser URL
244
+ // Use replaceState if we already pushed early (for optimistic state access)
245
+ if (replace || earlyStatePushed) {
246
+ window.history.replaceState(historyState, "", url);
247
+ } else {
248
+ window.history.pushState(historyState, "", url);
249
+ }
250
+
251
+ // Ensure new history entry has a scroll restoration key
252
+ ensureHistoryKey();
253
+
254
+ // Complete the navigation in event controller (sets idle state)
255
+ handle.complete(parsedUrl);
256
+
257
+ // Handle scroll after navigation
258
+ handleNavigationEnd({ scroll });
259
+
260
+ console.log(
261
+ "[Browser] Optimistic commit from cache, historyKey:",
262
+ historyKey
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Commit the navigation - updates store and URL atomically
268
+ * If optimisticCommit was called, this becomes a reconciliation
269
+ */
270
+ function commit(opts: CommitOptions): void {
271
+ committed = true;
272
+
273
+ // If optimistic commit already done, adjust options for reconciliation
274
+ const isReconciliation = optimisticallyCommitted;
275
+ const {
276
+ url,
277
+ segmentIds,
278
+ segments,
279
+ storeOnly,
280
+ intercept,
281
+ interceptSourceUrl,
282
+ cacheOnly,
283
+ } = opts;
284
+ // For reconciliation: always replace (URL already pushed), no scroll
285
+ const replace = isReconciliation ? true : opts.replace;
286
+ const scroll = isReconciliation ? false : opts.scroll;
287
+
288
+ const parsedUrl = new URL(url, window.location.origin);
289
+
290
+ // Generate history key from URL (with intercept suffix for separate caching)
291
+ const historyKey = generateHistoryKey(url, { intercept });
292
+
293
+ // For cache-only commits (stale revalidation), only update cache and return
294
+ // Don't touch store state or history - user may have navigated elsewhere
295
+ if (cacheOnly) {
296
+ const currentHandleData = eventController.getHandleState().data;
297
+ store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
298
+ console.log("[Browser] Cache-only commit, historyKey:", historyKey);
299
+ return;
300
+ }
301
+
302
+ // Save current scroll position before navigating (only for non-reconciliation)
303
+ if (!isReconciliation) {
304
+ handleNavigationStart();
305
+ }
306
+
307
+ // Update segment state atomically
308
+ store.setSegmentIds(segmentIds);
309
+ store.setCurrentUrl(url);
310
+ store.setPath(parsedUrl.pathname);
311
+
312
+ store.setHistoryKey(historyKey);
313
+
314
+ // Cache segments with current handleData for this history entry (fresh data overwrites optimistic)
315
+ const currentHandleData = eventController.getHandleState().data;
316
+ store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
317
+
318
+ // For server actions, skip URL/history updates but still complete navigation
319
+ if (storeOnly) {
320
+ console.log("[Browser] Store updated (action)");
321
+ // Complete navigation to clear loading state
322
+ handle.complete(parsedUrl);
323
+ return;
324
+ }
325
+
326
+ // Build history state - include user state and intercept info for popstate handling
327
+ const historyState = buildHistoryState(opts.state, {
328
+ intercept,
329
+ sourceUrl: interceptSourceUrl,
330
+ });
331
+
332
+ // Update browser URL (skip if reconciliation - already done in optimisticCommit)
333
+ if (!isReconciliation) {
334
+ // Use replaceState if we already pushed early (for optimistic state access) or replace requested
335
+ if (replace || earlyStatePushed) {
336
+ window.history.replaceState(historyState, "", url);
337
+ } else {
338
+ window.history.pushState(historyState, "", url);
339
+ }
340
+ // Ensure new history entry has a scroll restoration key
341
+ ensureHistoryKey();
342
+ }
343
+
344
+ // Complete the navigation in event controller (sets idle state, updates location)
345
+ handle.complete(parsedUrl);
346
+
347
+ // Handle scroll after navigation (skip if reconciliation)
348
+ if (!isReconciliation) {
349
+ handleNavigationEnd({ scroll });
350
+ }
351
+
352
+ if (isReconciliation) {
353
+ console.log("[Browser] Reconciliation commit, historyKey:", historyKey);
354
+ } else {
355
+ console.log(
356
+ "[Browser] Navigation committed, historyKey:",
357
+ historyKey,
358
+ intercept ? "(intercept)" : ""
359
+ );
360
+ }
361
+ }
362
+
363
+ return {
364
+ handle,
365
+ optimisticCommit,
366
+ commit,
367
+
368
+ /**
369
+ * Create a bound transaction with pre-configured URL options
370
+ * segmentIds and segments provided at commit time (after they're resolved)
371
+ */
372
+ with(
373
+ opts: Omit<CommitOptions, "segmentIds" | "segments">
374
+ ): BoundTransaction {
375
+ return {
376
+ get currentUrl() {
377
+ return currentUrl;
378
+ },
379
+ startStreaming() {
380
+ return handle.startStreaming();
381
+ },
382
+ commit: (
383
+ segmentIds: string[],
384
+ segments: ResolvedSegment[],
385
+ overrides?: BoundCommitOverrides
386
+ ) => {
387
+ // Allow overrides to disable scroll (e.g., for intercepts)
388
+ const finalScroll =
389
+ overrides?.scroll !== undefined ? overrides.scroll : opts.scroll;
390
+ // Allow overrides to force replace (e.g., for intercepts)
391
+ const finalReplace =
392
+ overrides?.replace !== undefined ? overrides.replace : opts.replace;
393
+ // Intercept info: overrides take precedence, fallback to opts
394
+ const intercept =
395
+ overrides?.intercept !== undefined
396
+ ? overrides.intercept
397
+ : opts.intercept;
398
+ const interceptSourceUrl =
399
+ overrides?.interceptSourceUrl !== undefined
400
+ ? overrides.interceptSourceUrl
401
+ : opts.interceptSourceUrl;
402
+ // Cache-only mode: overrides take precedence, fallback to opts
403
+ const cacheOnly =
404
+ overrides?.cacheOnly !== undefined
405
+ ? overrides.cacheOnly
406
+ : opts.cacheOnly;
407
+ // User state: overrides take precedence, fallback to opts
408
+ const state =
409
+ overrides?.state !== undefined ? overrides.state : opts.state;
410
+ commit({
411
+ ...opts,
412
+ segmentIds,
413
+ segments,
414
+ scroll: finalScroll,
415
+ replace: finalReplace,
416
+ state,
417
+ intercept,
418
+ interceptSourceUrl,
419
+ cacheOnly,
420
+ });
421
+ },
422
+ };
423
+ },
424
+
425
+ [Symbol.dispose]() {
426
+ // If aborted, another navigation took over - don't touch state
427
+ if (handle.signal.aborted) return;
428
+
429
+ // If not committed (and not optimistically committed), the handle's dispose
430
+ // will reset state to idle via the event controller
431
+ if (!committed && !optimisticallyCommitted) {
432
+ handle[Symbol.dispose]();
433
+ // The NavigationHandle's [Symbol.dispose] handles this
434
+ }
435
+ },
436
+ };
437
+ }
438
+
439
+ // Export for use by server-action-bridge
440
+ export { createNavigationTransaction };
441
+
442
+ /**
443
+ * Extended configuration for navigation bridge with event controller
444
+ */
445
+ export interface NavigationBridgeConfigWithController extends NavigationBridgeConfig {
446
+ eventController: EventController;
447
+ /** RSC version from initial payload metadata */
448
+ version?: string;
449
+ }
450
+
451
+ /**
452
+ * Create a navigation bridge for handling client-side navigation
453
+ *
454
+ * The bridge coordinates all navigation operations:
455
+ * - Link click interception
456
+ * - Browser back/forward (popstate)
457
+ * - Programmatic navigation
458
+ *
459
+ * Uses the event controller for reactive state management.
460
+ *
461
+ * @param config - Bridge configuration
462
+ * @returns NavigationBridge instance
463
+ */
464
+ export function createNavigationBridge(
465
+ config: NavigationBridgeConfigWithController
466
+ ): NavigationBridge {
467
+ const { store, client, eventController, onUpdate, renderSegments, version } = config;
468
+
469
+ // Create shared partial updater
470
+ const fetchPartialUpdate = createPartialUpdater({
471
+ store,
472
+ client,
473
+ onUpdate,
474
+ renderSegments,
475
+ version,
476
+ });
477
+
478
+ return {
479
+ /**
480
+ * Navigate to a URL
481
+ * Uses optimistic rendering from cache when available (SWR pattern)
482
+ */
483
+ async navigate(url: string, options?: NavigateOptions): Promise<void> {
484
+ // Resolve LocationStateEntry[] to flat object if needed
485
+ const resolvedState =
486
+ options?.state !== undefined
487
+ ? resolveNavigationState(options.state)
488
+ : undefined;
489
+
490
+ // Only abort pending requests when navigating to a different route
491
+ // Same-route navigation (e.g., /todos -> /todos) should not cancel in-flight actions
492
+ const currentPath = new URL(window.location.href).pathname;
493
+ const targetPath = new URL(url, window.location.origin).pathname;
494
+ if (currentPath !== targetPath) {
495
+ eventController.abortNavigation();
496
+ }
497
+
498
+ // Check if we're "leaving intercept" - navigating from intercept to same URL without intercept
499
+ // This happens when clicking "View Full Details" in an intercept modal
500
+ const currentHistoryState = window.history.state;
501
+ const isCurrentlyIntercept = currentHistoryState?.intercept === true;
502
+ const isSamePathNavigation = currentPath === targetPath;
503
+ const isLeavingIntercept = isCurrentlyIntercept && isSamePathNavigation;
504
+
505
+ if (isLeavingIntercept) {
506
+ console.log(`[Browser] Leaving intercept - same URL navigation from intercept`);
507
+ // Clear intercept source URL to ensure server doesn't treat this as intercept
508
+ store.setInterceptSourceUrl(null);
509
+ }
510
+
511
+ // Before navigating away, update the source page's cache with the latest handleData.
512
+ // This ensures the cache has correct handleData even if handles were streaming.
513
+ const sourceHistoryKey = store.getHistoryKey();
514
+ const sourceCached = store.getCachedSegments(sourceHistoryKey);
515
+ if (sourceCached?.segments && sourceCached.segments.length > 0) {
516
+ const currentHandleData = eventController.getHandleState().data;
517
+ store.cacheSegmentsForHistory(
518
+ sourceHistoryKey,
519
+ sourceCached.segments,
520
+ currentHandleData
521
+ );
522
+ }
523
+
524
+ // Check if we have cached segments for target URL
525
+ const historyKey = generateHistoryKey(url);
526
+ const cached = store.getCachedSegments(historyKey);
527
+
528
+ // For shared segments (same ID on current and target), use current page's version
529
+ // since it may have fresher data after an action revalidation.
530
+ // This avoids unnecessary server round-trips for shared layout loaders.
531
+ let cachedSegments = cached?.segments;
532
+ const cachedHandleData = cached?.handleData;
533
+ if (cachedSegments && sourceCached?.segments) {
534
+ const sourceSegmentMap = new Map(
535
+ sourceCached.segments.map((s) => [s.id, s])
536
+ );
537
+ cachedSegments = cachedSegments.map((targetSeg) => {
538
+ const sourceSeg = sourceSegmentMap.get(targetSeg.id);
539
+ // Use source (current page) version for shared segments - it's fresher
540
+ return sourceSeg || targetSeg;
541
+ });
542
+ }
543
+
544
+ // Also check if there's an intercept cache entry for this URL
545
+ // If so, this URL CAN be intercepted, and we shouldn't use the non-intercept cache
546
+ // because the navigation might result in an intercept (depending on source URL)
547
+ const interceptHistoryKey = generateHistoryKey(url, { intercept: true });
548
+ const hasInterceptCache = store.hasHistoryCache(interceptHistoryKey);
549
+
550
+ // Skip optimistic rendering for:
551
+ // 1. intercept caches - interception depends on source page context
552
+ // 2. routes that CAN be intercepted - we don't know if this navigation will intercept
553
+ // 3. when leaving intercept - we need fresh non-intercept segments from server
554
+ const hasUsableCache =
555
+ cachedSegments &&
556
+ cachedSegments.length > 0 &&
557
+ !isInterceptOnlyCache(cachedSegments) &&
558
+ !hasInterceptCache &&
559
+ !isLeavingIntercept;
560
+
561
+ using tx = createNavigationTransaction(store, eventController, url, {
562
+ ...options,
563
+ state: resolvedState,
564
+ skipLoadingState: hasUsableCache,
565
+ });
566
+
567
+ // REVALIDATE: Fetch fresh data from server
568
+ try {
569
+ await fetchPartialUpdate(
570
+ url,
571
+ hasUsableCache ? cachedSegments!.map((s) => s.id) : undefined,
572
+ false,
573
+ tx.handle.signal,
574
+ tx.with({
575
+ url,
576
+ replace: options?.replace,
577
+ scroll: options?.scroll,
578
+ state: resolvedState,
579
+ }),
580
+ // Pass cached segments (merged with current page's fresh segments for shared IDs)
581
+ // so the segment map is consistent with what we tell the server we have.
582
+ // Server decides what needs revalidation based on route matching and custom functions.
583
+ // No need for staleRevalidation flag - we're sending the freshest segments we have.
584
+ // Also pass cached handle data for restoring breadcrumbs when server returns empty diff.
585
+ // When leaving intercept, pass the flag so fetchPartialUpdate knows to filter segments.
586
+ hasUsableCache
587
+ ? { targetCacheSegments: cachedSegments, targetCacheHandleData: cachedHandleData }
588
+ : isLeavingIntercept
589
+ ? { leavingIntercept: true }
590
+ : undefined
591
+ );
592
+ } catch (error) {
593
+ // Ignore AbortError - navigation was cancelled by a newer navigation
594
+ if (error instanceof DOMException && error.name === "AbortError") {
595
+ console.log("[Browser] Navigation aborted by newer navigation");
596
+ return;
597
+ }
598
+
599
+ // Handle network errors by triggering root error boundary
600
+ if (error instanceof NetworkError || isNetworkError(error)) {
601
+ const networkError =
602
+ error instanceof NetworkError
603
+ ? error
604
+ : new NetworkError(
605
+ "Unable to connect to server. Please check your connection.",
606
+ { cause: error, url, operation: "navigation" }
607
+ );
608
+
609
+ console.error(
610
+ "[Browser] Network error during navigation:",
611
+ networkError
612
+ );
613
+
614
+ // Emit update with NetworkErrorThrower to trigger root error boundary
615
+ startTransition(() => {
616
+ onUpdate({
617
+ root: createElement(NetworkErrorThrower, { error: networkError }),
618
+ metadata: {
619
+ pathname: url,
620
+ segments: [],
621
+ isError: true,
622
+ },
623
+ });
624
+ });
625
+ return;
626
+ }
627
+
628
+ throw error;
629
+ }
630
+ },
631
+
632
+ /**
633
+ * Refresh current route
634
+ */
635
+ async refresh(): Promise<void> {
636
+ eventController.abortNavigation();
637
+
638
+ using tx = createNavigationTransaction(
639
+ store,
640
+ eventController,
641
+ window.location.href,
642
+ { replace: true }
643
+ );
644
+
645
+ try {
646
+ // Refetch with empty segments to get everything fresh
647
+ await fetchPartialUpdate(
648
+ window.location.href,
649
+ [],
650
+ false,
651
+ tx.handle.signal,
652
+ tx.with({ url: window.location.href, replace: true, scroll: false })
653
+ );
654
+ } catch (error) {
655
+ // Handle network errors by triggering root error boundary
656
+ if (error instanceof NetworkError || isNetworkError(error)) {
657
+ const networkError =
658
+ error instanceof NetworkError
659
+ ? error
660
+ : new NetworkError(
661
+ "Unable to connect to server. Please check your connection.",
662
+ {
663
+ cause: error,
664
+ url: window.location.href,
665
+ operation: "revalidation",
666
+ }
667
+ );
668
+
669
+ console.error(
670
+ "[Browser] Network error during refresh:",
671
+ networkError
672
+ );
673
+
674
+ startTransition(() => {
675
+ onUpdate({
676
+ root: createElement(NetworkErrorThrower, { error: networkError }),
677
+ metadata: {
678
+ pathname: window.location.href,
679
+ segments: [],
680
+ isError: true,
681
+ },
682
+ });
683
+ });
684
+ return;
685
+ }
686
+ throw error;
687
+ }
688
+ },
689
+
690
+ /**
691
+ * Handle browser back/forward navigation
692
+ * Uses cached segments when available for instant restoration
693
+ */
694
+ async handlePopstate(): Promise<void> {
695
+ // Abort any pending navigation to prevent race conditions
696
+ eventController.abortNavigation();
697
+
698
+ const url = window.location.href;
699
+
700
+ // Check if this history entry is an intercept
701
+ const historyState = window.history.state;
702
+ const isIntercept = historyState?.intercept === true;
703
+ const interceptSourceUrl = historyState?.sourceUrl;
704
+
705
+ // Check if intercept context is changing (same URL, different intercept state)
706
+ // If so, abort in-flight actions - their results would be for wrong context
707
+ const currentInterceptSource = store.getInterceptSourceUrl();
708
+ const newInterceptSource = interceptSourceUrl ?? null;
709
+ if (currentInterceptSource !== newInterceptSource) {
710
+ console.log(
711
+ `[Browser] Intercept context changing (${currentInterceptSource} -> ${newInterceptSource}), aborting in-flight actions`
712
+ );
713
+ eventController.abortAllActions();
714
+ }
715
+
716
+ // Compute history key from URL (with intercept suffix if applicable)
717
+ const historyKey = generateHistoryKey(url, { intercept: isIntercept });
718
+
719
+ console.log(
720
+ "[Browser] Popstate -",
721
+ isIntercept ? "intercept" : "normal",
722
+ "key:",
723
+ historyKey
724
+ );
725
+
726
+ // Update location in event controller
727
+ eventController.setLocation(new URL(url));
728
+
729
+ // If this is an intercept, restore the intercept context
730
+ if (isIntercept && interceptSourceUrl) {
731
+ store.setInterceptSourceUrl(interceptSourceUrl);
732
+ } else {
733
+ store.setInterceptSourceUrl(null);
734
+ }
735
+
736
+ // Helper to check if streaming is in progress
737
+ const isStreaming = () => eventController.getState().isStreaming;
738
+
739
+ // Check if we can restore from history cache
740
+ const cached = store.getCachedSegments(historyKey);
741
+ const cachedSegments = cached?.segments;
742
+ const cachedHandleData = cached?.handleData;
743
+ const isStale = cached?.stale ?? false;
744
+
745
+ if (cachedSegments && cachedSegments.length > 0) {
746
+ // Update store to point to this history entry
747
+ store.setHistoryKey(historyKey);
748
+ store.setSegmentIds(cachedSegments.map((s) => s.id));
749
+ store.setCurrentUrl(url);
750
+ store.setPath(new URL(url).pathname);
751
+
752
+ // Render from cache - force await to skip loading fallbacks
753
+ try {
754
+ const root = renderSegments(cachedSegments, {
755
+ forceAwait: true,
756
+ });
757
+ onUpdate({
758
+ root,
759
+ metadata: {
760
+ pathname: new URL(url).pathname,
761
+ segments: cachedSegments,
762
+ isPartial: true,
763
+ matched: cachedSegments.map((s) => s.id),
764
+ diff: [],
765
+ cachedHandleData,
766
+ },
767
+ });
768
+
769
+ // Restore scroll position for back/forward navigation
770
+ handleNavigationEnd({ restore: true, isStreaming });
771
+
772
+ // SWR: If stale, trigger background revalidation
773
+ if (isStale) {
774
+ console.log("[Browser] Cache is stale, background revalidating...");
775
+ // Background revalidation - don't await, just fire and forget
776
+ const segmentIds = cachedSegments.map((s) => s.id);
777
+
778
+ using tx = createNavigationTransaction(
779
+ store,
780
+ eventController,
781
+ url,
782
+ { skipLoadingState: true, replace: true }
783
+ );
784
+
785
+ fetchPartialUpdate(
786
+ url,
787
+ segmentIds,
788
+ false,
789
+ tx.handle.signal,
790
+ tx.with({
791
+ url,
792
+ replace: true,
793
+ scroll: false,
794
+ intercept: isIntercept,
795
+ interceptSourceUrl,
796
+ cacheOnly: true,
797
+ }),
798
+ { staleRevalidation: true, interceptSourceUrl }
799
+ ).catch((error) => {
800
+ if (
801
+ error instanceof DOMException &&
802
+ error.name === "AbortError"
803
+ ) {
804
+ console.log("[Browser] Background revalidation aborted");
805
+ return;
806
+ }
807
+ // For background revalidation, network errors are logged but don't trigger error boundary
808
+ // since the user is already seeing cached content
809
+ if (error instanceof NetworkError || isNetworkError(error)) {
810
+ console.warn(
811
+ "[Browser] Background revalidation network error (cached content preserved):",
812
+ error.message
813
+ );
814
+ return;
815
+ }
816
+ console.error("[Browser] Background revalidation failed:", error);
817
+ });
818
+ }
819
+ return;
820
+ } catch (error) {
821
+ console.warn(
822
+ "[Browser] Failed to render from cache, fetching:",
823
+ error
824
+ );
825
+ // Fall through to fetch
826
+ }
827
+ } else {
828
+ console.log("[Browser] History cache miss for key:", historyKey);
829
+ }
830
+
831
+ // Fetch if not cached
832
+ using tx = createNavigationTransaction(store, eventController, url, {
833
+ replace: true,
834
+ });
835
+
836
+ try {
837
+ await fetchPartialUpdate(
838
+ url,
839
+ undefined,
840
+ false,
841
+ tx.handle.signal,
842
+ tx.with({ url, replace: true, scroll: false })
843
+ );
844
+ // Restore scroll position after fetch completes
845
+ handleNavigationEnd({ restore: true, isStreaming });
846
+ } catch (error) {
847
+ if (error instanceof DOMException && error.name === "AbortError") {
848
+ console.log("[Browser] Popstate navigation aborted");
849
+ return;
850
+ }
851
+
852
+ // Handle network errors by triggering root error boundary
853
+ if (error instanceof NetworkError || isNetworkError(error)) {
854
+ const networkError =
855
+ error instanceof NetworkError
856
+ ? error
857
+ : new NetworkError(
858
+ "Unable to connect to server. Please check your connection.",
859
+ { cause: error, url, operation: "navigation" }
860
+ );
861
+
862
+ console.error(
863
+ "[Browser] Network error during popstate navigation:",
864
+ networkError
865
+ );
866
+
867
+ startTransition(() => {
868
+ onUpdate({
869
+ root: createElement(NetworkErrorThrower, { error: networkError }),
870
+ metadata: {
871
+ pathname: url,
872
+ segments: [],
873
+ isError: true,
874
+ },
875
+ });
876
+ });
877
+ return;
878
+ }
879
+
880
+ throw error;
881
+ }
882
+ },
883
+
884
+ /**
885
+ * Register link interception
886
+ * @returns Cleanup function
887
+ */
888
+ registerLinkInterception(): () => void {
889
+ const cleanupLinks = setupLinkInterception((url, options) => {
890
+ this.navigate(url, options);
891
+ });
892
+
893
+ const handlePopstate = () => {
894
+ this.handlePopstate();
895
+ };
896
+
897
+ // Register cross-tab refresh callback with the store
898
+ store.setCrossTabRefreshCallback(() => {
899
+ this.refresh();
900
+ });
901
+
902
+ window.addEventListener("popstate", handlePopstate);
903
+ console.log("[Browser] Navigation bridge ready");
904
+
905
+ return () => {
906
+ cleanupLinks();
907
+ window.removeEventListener("popstate", handlePopstate);
908
+ };
909
+ },
910
+ };
911
+ }
912
+
913
+ export { createNavigationBridge as default };