@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1fa245e2

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 (160) hide show
  1. package/{CLAUDE.md → AGENTS.md} +4 -0
  2. package/README.md +122 -30
  3. package/dist/bin/rango.js +245 -63
  4. package/dist/vite/index.js +859 -418
  5. package/package.json +3 -3
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +32 -0
  8. package/skills/caching/SKILL.md +49 -8
  9. package/skills/document-cache/SKILL.md +2 -2
  10. package/skills/hooks/SKILL.md +33 -31
  11. package/skills/host-router/SKILL.md +218 -0
  12. package/skills/links/SKILL.md +3 -1
  13. package/skills/loader/SKILL.md +72 -22
  14. package/skills/middleware/SKILL.md +2 -0
  15. package/skills/parallel/SKILL.md +126 -0
  16. package/skills/prerender/SKILL.md +112 -70
  17. package/skills/rango/SKILL.md +0 -1
  18. package/skills/route/SKILL.md +34 -4
  19. package/skills/router-setup/SKILL.md +95 -5
  20. package/skills/typesafety/SKILL.md +35 -23
  21. package/src/__internal.ts +92 -0
  22. package/src/bin/rango.ts +18 -0
  23. package/src/browser/app-version.ts +14 -0
  24. package/src/browser/event-controller.ts +5 -0
  25. package/src/browser/link-interceptor.ts +4 -0
  26. package/src/browser/navigation-bridge.ts +114 -18
  27. package/src/browser/navigation-client.ts +126 -44
  28. package/src/browser/navigation-store.ts +43 -8
  29. package/src/browser/navigation-transaction.ts +11 -9
  30. package/src/browser/partial-update.ts +80 -15
  31. package/src/browser/prefetch/cache.ts +166 -27
  32. package/src/browser/prefetch/fetch.ts +52 -39
  33. package/src/browser/prefetch/policy.ts +6 -0
  34. package/src/browser/prefetch/queue.ts +92 -20
  35. package/src/browser/prefetch/resource-ready.ts +77 -0
  36. package/src/browser/react/Link.tsx +70 -14
  37. package/src/browser/react/NavigationProvider.tsx +40 -4
  38. package/src/browser/react/context.ts +7 -2
  39. package/src/browser/react/use-handle.ts +9 -58
  40. package/src/browser/react/use-router.ts +21 -8
  41. package/src/browser/rsc-router.tsx +143 -59
  42. package/src/browser/scroll-restoration.ts +41 -42
  43. package/src/browser/segment-reconciler.ts +6 -1
  44. package/src/browser/server-action-bridge.ts +454 -436
  45. package/src/browser/types.ts +60 -5
  46. package/src/build/generate-manifest.ts +6 -6
  47. package/src/build/generate-route-types.ts +5 -0
  48. package/src/build/route-trie.ts +19 -3
  49. package/src/build/route-types/include-resolution.ts +8 -1
  50. package/src/build/route-types/router-processing.ts +346 -87
  51. package/src/build/route-types/scan-filter.ts +8 -1
  52. package/src/cache/cache-runtime.ts +15 -11
  53. package/src/cache/cache-scope.ts +48 -7
  54. package/src/cache/cf/cf-cache-store.ts +453 -11
  55. package/src/cache/cf/index.ts +5 -1
  56. package/src/cache/document-cache.ts +17 -7
  57. package/src/cache/index.ts +1 -0
  58. package/src/cache/taint.ts +55 -0
  59. package/src/client.rsc.tsx +2 -1
  60. package/src/client.tsx +3 -102
  61. package/src/context-var.ts +72 -2
  62. package/src/debug.ts +2 -2
  63. package/src/handle.ts +40 -0
  64. package/src/handles/breadcrumbs.ts +66 -0
  65. package/src/handles/index.ts +1 -0
  66. package/src/host/index.ts +0 -3
  67. package/src/index.rsc.ts +8 -37
  68. package/src/index.ts +40 -66
  69. package/src/prerender/store.ts +57 -15
  70. package/src/prerender.ts +138 -77
  71. package/src/reverse.ts +22 -1
  72. package/src/route-definition/dsl-helpers.ts +73 -25
  73. package/src/route-definition/helpers-types.ts +10 -6
  74. package/src/route-definition/index.ts +3 -3
  75. package/src/route-definition/redirect.ts +11 -3
  76. package/src/route-definition/resolve-handler-use.ts +149 -0
  77. package/src/route-map-builder.ts +7 -1
  78. package/src/route-types.ts +11 -0
  79. package/src/router/content-negotiation.ts +100 -1
  80. package/src/router/find-match.ts +4 -2
  81. package/src/router/handler-context.ts +108 -25
  82. package/src/router/intercept-resolution.ts +11 -4
  83. package/src/router/lazy-includes.ts +4 -1
  84. package/src/router/loader-resolution.ts +123 -11
  85. package/src/router/logging.ts +5 -2
  86. package/src/router/manifest.ts +9 -3
  87. package/src/router/match-api.ts +125 -190
  88. package/src/router/match-middleware/background-revalidation.ts +30 -2
  89. package/src/router/match-middleware/cache-lookup.ts +88 -16
  90. package/src/router/match-middleware/cache-store.ts +53 -10
  91. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  92. package/src/router/match-middleware/segment-resolution.ts +61 -5
  93. package/src/router/match-result.ts +22 -15
  94. package/src/router/metrics.ts +238 -13
  95. package/src/router/middleware-types.ts +53 -12
  96. package/src/router/middleware.ts +172 -85
  97. package/src/router/navigation-snapshot.ts +182 -0
  98. package/src/router/pattern-matching.ts +20 -5
  99. package/src/router/prerender-match.ts +114 -10
  100. package/src/router/preview-match.ts +30 -102
  101. package/src/router/request-classification.ts +310 -0
  102. package/src/router/revalidation.ts +27 -7
  103. package/src/router/route-snapshot.ts +245 -0
  104. package/src/router/router-context.ts +6 -1
  105. package/src/router/router-interfaces.ts +50 -5
  106. package/src/router/router-options.ts +50 -19
  107. package/src/router/segment-resolution/fresh.ts +200 -19
  108. package/src/router/segment-resolution/helpers.ts +30 -25
  109. package/src/router/segment-resolution/loader-cache.ts +1 -0
  110. package/src/router/segment-resolution/revalidation.ts +429 -301
  111. package/src/router/segment-wrappers.ts +2 -0
  112. package/src/router/trie-matching.ts +20 -2
  113. package/src/router/types.ts +1 -0
  114. package/src/router.ts +88 -15
  115. package/src/rsc/handler.ts +546 -359
  116. package/src/rsc/index.ts +0 -20
  117. package/src/rsc/manifest-init.ts +5 -1
  118. package/src/rsc/progressive-enhancement.ts +25 -8
  119. package/src/rsc/rsc-rendering.ts +35 -43
  120. package/src/rsc/server-action.ts +16 -10
  121. package/src/rsc/ssr-setup.ts +128 -0
  122. package/src/rsc/types.ts +10 -1
  123. package/src/search-params.ts +16 -13
  124. package/src/segment-system.tsx +140 -4
  125. package/src/server/context.ts +148 -16
  126. package/src/server/loader-registry.ts +9 -8
  127. package/src/server/request-context.ts +182 -34
  128. package/src/server.ts +6 -0
  129. package/src/ssr/index.tsx +4 -0
  130. package/src/static-handler.ts +18 -6
  131. package/src/theme/index.ts +4 -13
  132. package/src/types/cache-types.ts +4 -4
  133. package/src/types/handler-context.ts +149 -49
  134. package/src/types/loader-types.ts +36 -9
  135. package/src/types/route-config.ts +17 -8
  136. package/src/types/route-entry.ts +8 -1
  137. package/src/types/segments.ts +2 -5
  138. package/src/urls/path-helper-types.ts +9 -2
  139. package/src/urls/path-helper.ts +48 -13
  140. package/src/urls/pattern-types.ts +12 -0
  141. package/src/urls/response-types.ts +16 -6
  142. package/src/use-loader.tsx +73 -4
  143. package/src/vite/discovery/bundle-postprocess.ts +61 -89
  144. package/src/vite/discovery/discover-routers.ts +23 -5
  145. package/src/vite/discovery/prerender-collection.ts +48 -15
  146. package/src/vite/discovery/state.ts +17 -13
  147. package/src/vite/index.ts +8 -3
  148. package/src/vite/plugin-types.ts +51 -79
  149. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  150. package/src/vite/plugins/expose-action-id.ts +1 -3
  151. package/src/vite/plugins/performance-tracks.ts +88 -0
  152. package/src/vite/plugins/refresh-cmd.ts +127 -0
  153. package/src/vite/plugins/version-plugin.ts +13 -1
  154. package/src/vite/rango.ts +174 -211
  155. package/src/vite/router-discovery.ts +169 -42
  156. package/src/vite/utils/banner.ts +3 -3
  157. package/src/vite/utils/prerender-utils.ts +78 -0
  158. package/src/vite/utils/shared-utils.ts +3 -2
  159. package/skills/testing/SKILL.md +0 -226
  160. package/src/route-definition/route-function.ts +0 -119
@@ -29,6 +29,7 @@ import {
29
29
  } from "./response-adapter.js";
30
30
  import { mergeLocationState } from "./history-state.js";
31
31
  import { classifyActionOutcome } from "./action-coordinator.js";
32
+ import { getAppVersion } from "./app-version.js";
32
33
 
33
34
  // Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
34
35
  if (typeof Symbol.dispose === "undefined") {
@@ -43,8 +44,6 @@ if (typeof Symbol.asyncDispose === "undefined") {
43
44
  */
44
45
  export interface ServerActionBridgeConfigWithController extends ServerActionBridgeConfig {
45
46
  eventController: EventController;
46
- /** RSC version from initial payload metadata */
47
- version?: string;
48
47
  /** Callback to trigger SPA navigation (for action redirects) */
49
48
  onNavigate?: (
50
49
  url: string,
@@ -75,7 +74,6 @@ export function createServerActionBridge(
75
74
  deps,
76
75
  onUpdate,
77
76
  renderSegments,
78
- version,
79
77
  onNavigate,
80
78
  } = config;
81
79
 
@@ -86,7 +84,7 @@ export function createServerActionBridge(
86
84
  client,
87
85
  onUpdate,
88
86
  renderSegments,
89
- version,
87
+ getVersion: getAppVersion,
90
88
  });
91
89
 
92
90
  /**
@@ -99,27 +97,31 @@ export function createServerActionBridge(
99
97
  interceptSourceUrl?: string | null;
100
98
  }): Promise<void> {
101
99
  const src = opts?.interceptSourceUrl ?? null;
102
- using navTx = createNavigationTransaction(
100
+ const navTx = createNavigationTransaction(
103
101
  store,
104
102
  eventController,
105
103
  window.location.href,
106
104
  { replace: true, skipLoadingState: true },
107
105
  );
108
- await fetchPartialUpdate(
109
- window.location.href,
110
- opts?.segments ?? [],
111
- false,
112
- navTx.handle.signal,
113
- navTx.with({
114
- url: window.location.href,
115
- storeOnly: true,
116
- ...(src ? { intercept: true, interceptSourceUrl: src } : {}),
117
- }),
118
- {
119
- type: "action" as const,
120
- ...(src ? { interceptSourceUrl: src } : {}),
121
- },
122
- );
106
+ try {
107
+ await fetchPartialUpdate(
108
+ window.location.href,
109
+ opts?.segments ?? [],
110
+ false,
111
+ navTx.handle.signal,
112
+ navTx.with({
113
+ url: window.location.href,
114
+ storeOnly: true,
115
+ ...(src ? { intercept: true, interceptSourceUrl: src } : {}),
116
+ }),
117
+ {
118
+ type: "action" as const,
119
+ ...(src ? { interceptSourceUrl: src } : {}),
120
+ },
121
+ );
122
+ } finally {
123
+ navTx[Symbol.dispose]();
124
+ }
123
125
  }
124
126
 
125
127
  /**
@@ -137,440 +139,499 @@ export function createServerActionBridge(
137
139
  log("action start", { id, argsCount: args.length });
138
140
 
139
141
  // Start action in event controller - handles lifecycle tracking
140
- using handle = eventController.startAction(id, args);
142
+ const handle = eventController.startAction(id, args);
143
+ try {
144
+ const segmentState = store.getSegmentState();
145
+
146
+ // Mark cache as stale immediately when action starts
147
+ // This ensures SWR pattern kicks in if user navigates away during action
148
+ store.markCacheAsStaleAndBroadcast();
149
+
150
+ // Create temporary references for serialization
151
+ const temporaryReferences = deps.createTemporaryReferenceSet();
152
+
153
+ // Capture URL pathname at action start to detect navigation during action
154
+ // Must use window.location (not store.path) because intercepts change URL
155
+ // without changing store.path (e.g., /kanban -> /kanban/card/1)
156
+ const actionStartPathname = window.location.pathname;
157
+
158
+ // Build action request URL with current segments
159
+ const url = new URL(window.location.href);
160
+ url.searchParams.set("_rsc_action", id);
161
+ url.searchParams.set(
162
+ "_rsc_segments",
163
+ segmentState.currentSegmentIds.join(","),
164
+ );
165
+ // Add version param for version mismatch detection
166
+ const version = getAppVersion();
167
+ if (version) {
168
+ url.searchParams.set("_rsc_v", version);
169
+ }
170
+ // Add router ID for app switch detection
171
+ const rid = store.getRouterId?.();
172
+ if (rid) {
173
+ url.searchParams.set("_rsc_rid", rid);
174
+ }
175
+
176
+ // Encode arguments
177
+ const encodedBody = await deps.encodeReply(args, { temporaryReferences });
141
178
 
142
- const segmentState = store.getSegmentState();
179
+ log("sending action request", {
180
+ url: url.href,
181
+ bodyType: typeof encodedBody,
182
+ isFormData: encodedBody instanceof FormData,
183
+ segmentCount: segmentState.currentSegmentIds.length,
184
+ });
143
185
 
144
- // Mark cache as stale immediately when action starts
145
- // This ensures SWR pattern kicks in if user navigates away during action
146
- store.markCacheAsStaleAndBroadcast();
186
+ // Track when the stream completes
187
+ let resolveStreamComplete: () => void;
188
+ const streamComplete = new Promise<void>((resolve) => {
189
+ resolveStreamComplete = resolve;
190
+ });
147
191
 
148
- // Create temporary references for serialization
149
- const temporaryReferences = deps.createTemporaryReferenceSet();
192
+ // Get intercept source URL if in intercept context
193
+ const interceptSourceUrl = store.getInterceptSourceUrl();
194
+
195
+ // Track streaming token - will be set when response arrives
196
+ let streamingToken: { end(): void } | null = null;
197
+
198
+ // Use a dedicated abort controller for the fetch so we can cancel network
199
+ // I/O without disrupting the Flight stream once the response has arrived.
200
+ // Aborting a response mid-stream causes React's Flight decoder to throw
201
+ // asynchronous unhandled errors (BodyStreamBuffer was aborted).
202
+ const fetchAbort = new AbortController();
203
+ const onHandleAbort = () => fetchAbort.abort();
204
+ handle.signal.addEventListener("abort", onHandleAbort, { once: true });
205
+
206
+ // Send action request with stream tracking
207
+ const responsePromise = fetch(url, {
208
+ method: "POST",
209
+ headers: {
210
+ "rsc-action": id,
211
+ "X-RSC-Router-Client-Path": segmentState.currentUrl,
212
+ ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
213
+ ...(interceptSourceUrl && {
214
+ "X-RSC-Router-Intercept-Source": interceptSourceUrl,
215
+ }),
216
+ },
217
+ body: encodedBody,
218
+ signal: fetchAbort.signal,
219
+ }).then(async (response) => {
220
+ // Response arrived — disconnect fetch abort from handle abort so
221
+ // abortAllActions() doesn't disrupt the in-progress Flight stream.
222
+ handle.signal.removeEventListener("abort", onHandleAbort);
223
+
224
+ // Check for version mismatch - server wants us to reload
225
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
226
+ if (reload === "blocked") {
227
+ resolveStreamComplete();
228
+ return emptyResponse();
229
+ }
230
+ if (reload) {
231
+ log("version mismatch on action, reloading", {
232
+ reloadUrl: reload.url,
233
+ });
234
+ window.location.href = reload.url;
235
+ return new Promise<Response>(() => {});
236
+ }
150
237
 
151
- // Capture URL pathname at action start to detect navigation during action
152
- // Must use window.location (not store.path) because intercepts change URL
153
- // without changing store.path (e.g., /kanban -> /kanban/card/1)
154
- const actionStartPathname = window.location.pathname;
238
+ // Simple redirect from action (no state, no RSC payload).
239
+ // Short-circuits before createFromFetch no Flight deserialization needed.
240
+ // Check handle.signal.aborted to avoid redirecting from a stale action
241
+ // when the user has already navigated away.
242
+ const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
243
+ if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
244
+ log("action simple redirect", { url: redirect.url });
245
+ handle.complete(undefined);
246
+ if (onNavigate) {
247
+ await onNavigate(redirect.url, {
248
+ replace: true,
249
+ _skipCache: true,
250
+ });
251
+ } else {
252
+ window.location.href = redirect.url;
253
+ }
254
+ return new Promise<Response>(() => {});
255
+ }
256
+ if (redirect === "blocked") {
257
+ resolveStreamComplete();
258
+ return emptyResponse();
259
+ }
155
260
 
156
- // Build action request URL with current segments
157
- const url = new URL(window.location.href);
158
- url.searchParams.set("_rsc_action", id);
159
- url.searchParams.set(
160
- "_rsc_segments",
161
- segmentState.currentSegmentIds.join(","),
162
- );
163
- // Add version param for version mismatch detection
164
- if (version) {
165
- url.searchParams.set("_rsc_v", version);
166
- }
261
+ // Start streaming immediately when response arrives
262
+ if (!handle.signal.aborted) {
263
+ streamingToken = handle.startStreaming();
264
+ }
167
265
 
168
- // Encode arguments
169
- const encodedBody = await deps.encodeReply(args, { temporaryReferences });
170
-
171
- log("sending action request", {
172
- url: url.href,
173
- bodyType: typeof encodedBody,
174
- isFormData: encodedBody instanceof FormData,
175
- segmentCount: segmentState.currentSegmentIds.length,
176
- });
177
-
178
- // Track when the stream completes
179
- let resolveStreamComplete: () => void;
180
- const streamComplete = new Promise<void>((resolve) => {
181
- resolveStreamComplete = resolve;
182
- });
183
-
184
- // Get intercept source URL if in intercept context
185
- const interceptSourceUrl = store.getInterceptSourceUrl();
186
-
187
- // Track streaming token - will be set when response arrives
188
- let streamingToken: { end(): void } | null = null;
189
-
190
- // Use a dedicated abort controller for the fetch so we can cancel network
191
- // I/O without disrupting the Flight stream once the response has arrived.
192
- // Aborting a response mid-stream causes React's Flight decoder to throw
193
- // asynchronous unhandled errors (BodyStreamBuffer was aborted).
194
- const fetchAbort = new AbortController();
195
- const onHandleAbort = () => fetchAbort.abort();
196
- handle.signal.addEventListener("abort", onHandleAbort, { once: true });
197
-
198
- // Send action request with stream tracking
199
- const responsePromise = fetch(url, {
200
- method: "POST",
201
- headers: {
202
- "rsc-action": id,
203
- "X-RSC-Router-Client-Path": segmentState.currentUrl,
204
- ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
205
- // Send intercept source URL so server can maintain intercept context
206
- ...(interceptSourceUrl && {
207
- "X-RSC-Router-Intercept-Source": interceptSourceUrl,
208
- }),
209
- },
210
- body: encodedBody,
211
- signal: fetchAbort.signal,
212
- }).then(async (response) => {
213
- // Response arrived — disconnect fetch abort from handle abort so
214
- // abortAllActions() doesn't disrupt the in-progress Flight stream.
215
- handle.signal.removeEventListener("abort", onHandleAbort);
216
-
217
- // Check for version mismatch - server wants us to reload
218
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
219
- if (reload === "blocked") {
220
- resolveStreamComplete();
221
- return emptyResponse();
266
+ return teeWithCompletion(response, () => {
267
+ log("stream complete");
268
+ streamingToken?.end();
269
+ resolveStreamComplete();
270
+ });
271
+ });
272
+
273
+ // Deserialize response (MUST use same temporaryReferences)
274
+ let payload: RscPayload;
275
+ try {
276
+ payload = await deps.createFromFetch<RscPayload>(responsePromise, {
277
+ temporaryReferences,
278
+ });
279
+ } catch (error) {
280
+ // Clean up streaming token on error (may be null if fetch failed before .then() ran)
281
+ // The token is assigned in .then() callback which runs before this catch block,
282
+ // but TypeScript doesn't track cross-async assignments, so use type assertion
283
+ (streamingToken as { end(): void } | null)?.end();
284
+ // resolveStreamComplete is assigned in the Promise constructor so it's safe to call
285
+ resolveStreamComplete!();
286
+
287
+ // Silently swallow abort errors — the action was intentionally cancelled
288
+ // (e.g., user navigated away or abortAllActions was called).
289
+ // Return undefined instead of throwing to avoid surfacing as a page error.
290
+ // Check both DOMException AbortError and stream-level abort messages
291
+ // (BodyStreamBuffer was aborted) that propagate from the aborted fetch.
292
+ if (handle.signal.aborted) {
293
+ return undefined;
294
+ }
295
+
296
+ // Convert network-level errors to NetworkError for proper handling
297
+ const networkError = toNetworkError(error, {
298
+ url: url.toString(),
299
+ operation: "action",
300
+ });
301
+ if (networkError) {
302
+ handle.fail(networkError);
303
+ emitNetworkError(onUpdate, networkError, segmentState.currentUrl);
304
+ throw networkError;
305
+ }
306
+ throw error;
222
307
  }
223
- if (reload) {
224
- log("version mismatch on action, reloading", { reloadUrl: reload.url });
225
- window.location.href = reload.url;
226
- return new Promise<Response>(() => {});
308
+
309
+ log("action response received", {
310
+ isPartial: payload.metadata?.isPartial,
311
+ isError: payload.metadata?.isError,
312
+ matchedCount: payload.metadata?.matched?.length ?? 0,
313
+ diffCount: payload.metadata?.diff?.length ?? 0,
314
+ });
315
+ // Guard: if the action was aborted while streaming (e.g., user navigated
316
+ // away or abortAllActions fired), bail out before any reconcile/render/cache
317
+ // writes to avoid overwriting the current UI with stale action results.
318
+ if (handle.signal.aborted) {
319
+ log("action aborted after response, skipping reconciliation");
320
+ return undefined;
227
321
  }
228
322
 
229
- // Simple redirect from action (no state, no RSC payload).
230
- // Short-circuits before createFromFetch no Flight deserialization needed.
323
+ // Process response
324
+ const { metadata, returnValue } = payload;
325
+
326
+ // Handle action redirect: server converted the redirect to a Flight payload
327
+ // so we can perform SPA navigation instead of a full page reload.
231
328
  // Check handle.signal.aborted to avoid redirecting from a stale action
232
329
  // when the user has already navigated away.
233
- const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
234
- if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
235
- log("action simple redirect", { url: redirect.url });
236
- handle.complete(undefined);
330
+ if (metadata?.redirect && !handle.signal.aborted) {
331
+ const redirectUrl = validateRedirectOrigin(
332
+ metadata.redirect.url,
333
+ window.location.origin,
334
+ );
335
+ if (!redirectUrl) {
336
+ log("blocked action redirect payload", {
337
+ url: metadata.redirect.url,
338
+ });
339
+ handle.complete(returnValue?.data);
340
+ return returnValue?.data;
341
+ }
342
+ const redirectState = metadata.locationState;
343
+ log("action redirect", { url: redirectUrl });
344
+ handle.complete(returnValue?.data);
237
345
  if (onNavigate) {
238
- await onNavigate(redirect.url, {
346
+ await onNavigate(redirectUrl, {
347
+ state: redirectState,
239
348
  replace: true,
240
349
  _skipCache: true,
241
350
  });
242
351
  } else {
243
- window.location.href = redirect.url;
352
+ window.location.href = redirectUrl;
244
353
  }
245
- return new Promise<Response>(() => {});
246
- }
247
- if (redirect === "blocked") {
248
- resolveStreamComplete();
249
- return emptyResponse();
250
- }
251
-
252
- // Start streaming immediately when response arrives
253
- if (!handle.signal.aborted) {
254
- streamingToken = handle.startStreaming();
354
+ return returnValue?.data;
255
355
  }
256
356
 
257
- return teeWithCompletion(response, () => {
258
- log("stream complete");
259
- streamingToken?.end();
260
- resolveStreamComplete();
261
- });
262
- });
263
-
264
- // Deserialize response (MUST use same temporaryReferences)
265
- let payload: RscPayload;
266
- try {
267
- payload = await deps.createFromFetch<RscPayload>(responsePromise, {
268
- temporaryReferences,
269
- });
270
- } catch (error) {
271
- // Clean up streaming token on error (may be null if fetch failed before .then() ran)
272
- // The token is assigned in .then() callback which runs before this catch block,
273
- // but TypeScript doesn't track cross-async assignments, so use type assertion
274
- (streamingToken as { end(): void } | null)?.end();
275
- // resolveStreamComplete is assigned in the Promise constructor so it's safe to call
276
- resolveStreamComplete!();
277
-
278
- // Silently swallow abort errors — the action was intentionally cancelled
279
- // (e.g., user navigated away or abortAllActions was called).
280
- // Return undefined instead of throwing to avoid surfacing as a page error.
281
- // Check both DOMException AbortError and stream-level abort messages
282
- // (BodyStreamBuffer was aborted) that propagate from the aborted fetch.
357
+ // Bail out if the action was aborted after deserialization (e.g. user
358
+ // navigated away or abortAllActions was called while the Flight stream
359
+ // was being consumed). Without this check the code below would mutate
360
+ // the store / UI for a stale action.
283
361
  if (handle.signal.aborted) {
284
- return undefined;
285
- }
286
-
287
- // Convert network-level errors to NetworkError for proper handling
288
- const networkError = toNetworkError(error, {
289
- url: url.toString(),
290
- operation: "action",
291
- });
292
- if (networkError) {
293
- handle.fail(networkError);
294
- emitNetworkError(onUpdate, networkError, segmentState.currentUrl);
295
- throw networkError;
362
+ log("action aborted after deserialization, skipping mutations");
363
+ return returnValue?.data;
296
364
  }
297
- throw error;
298
- }
299
365
 
300
- log("action response received", {
301
- isPartial: payload.metadata?.isPartial,
302
- isError: payload.metadata?.isError,
303
- matchedCount: payload.metadata?.matched?.length ?? 0,
304
- diffCount: payload.metadata?.diff?.length ?? 0,
305
- });
306
-
307
- // Guard: if the action was aborted while streaming (e.g., user navigated
308
- // away or abortAllActions fired), bail out before any reconcile/render/cache
309
- // writes to avoid overwriting the current UI with stale action results.
310
- if (handle.signal.aborted) {
311
- log("action aborted after response, skipping reconciliation");
312
- return undefined;
313
- }
366
+ const { matched, diff, segments, isPartial, isError } = metadata || {};
314
367
 
315
- // Process response
316
- const { metadata, returnValue } = payload;
317
-
318
- // Handle action redirect: server converted the redirect to a Flight payload
319
- // so we can perform SPA navigation instead of a full page reload.
320
- // Check handle.signal.aborted to avoid redirecting from a stale action
321
- // when the user has already navigated away.
322
- if (metadata?.redirect && !handle.signal.aborted) {
323
- const redirectUrl = validateRedirectOrigin(
324
- metadata.redirect.url,
325
- window.location.origin,
326
- );
327
- if (!redirectUrl) {
328
- log("blocked action redirect payload", { url: metadata.redirect.url });
329
- handle.complete(returnValue?.data);
330
- return returnValue?.data;
331
- }
332
- const redirectState = metadata.locationState;
333
- log("action redirect", { url: redirectUrl });
334
- handle.complete(returnValue?.data);
335
- if (onNavigate) {
336
- await onNavigate(redirectUrl, {
337
- state: redirectState,
338
- replace: true,
339
- _skipCache: true,
340
- });
341
- } else {
342
- window.location.href = redirectUrl;
368
+ // Log action result
369
+ if (returnValue && !returnValue.ok) {
370
+ console.error(`[Browser] Action failed:`, returnValue.data);
343
371
  }
344
- return returnValue?.data;
345
- }
346
372
 
347
- // Bail out if the action was aborted after deserialization (e.g. user
348
- // navigated away or abortAllActions was called while the Flight stream
349
- // was being consumed). Without this check the code below would mutate
350
- // the store / UI for a stale action.
351
- if (handle.signal.aborted) {
352
- log("action aborted after deserialization, skipping mutations");
353
- return returnValue?.data;
354
- }
373
+ // Handle error responses with error boundary UI
374
+ if (isError && isPartial && segments && diff) {
375
+ log("processing error boundary response");
355
376
 
356
- const { matched, diff, segments, isPartial, isError } = metadata || {};
377
+ // Fail current handle BEFORE aborting all actions so the event controller
378
+ // records the error state (abortAllActions clears inflight entries)
379
+ if (returnValue && !returnValue.ok) {
380
+ handle.fail(returnValue.data);
381
+ }
357
382
 
358
- // Log action result
359
- if (returnValue && !returnValue.ok) {
360
- console.error(`[Browser] Action failed:`, returnValue.data);
361
- }
383
+ // Abort all other pending action requests - error takes precedence
384
+ // This prevents other actions from completing and overwriting the error UI
385
+ eventController.abortAllActions();
362
386
 
363
- // Handle error responses with error boundary UI
364
- if (isError && isPartial && segments && diff) {
365
- log("processing error boundary response");
387
+ // Clear concurrent action tracking - no consolidation needed when showing error
388
+ handle.clearConsolidation();
366
389
 
367
- // Fail current handle BEFORE aborting all actions so the event controller
368
- // records the error state (abortAllActions clears inflight entries)
369
- if (returnValue && !returnValue.ok) {
370
- handle.fail(returnValue.data);
371
- }
390
+ // Get current page's cached segments
391
+ const currentKey = store.getHistoryKey();
392
+ const cached = store.getCachedSegments(currentKey);
393
+ const cachedSegments = cached?.segments || [];
372
394
 
373
- // Abort all other pending action requests - error takes precedence
374
- // This prevents other actions from completing and overwriting the error UI
375
- eventController.abortAllActions();
395
+ // Reconcile error segments with cached tree
396
+ const errorResult = reconcileErrorSegments(cachedSegments, segments);
376
397
 
377
- // Clear concurrent action tracking - no consolidation needed when showing error
378
- handle.clearConsolidation();
398
+ // Render the full tree with error segment merged with parent layouts
399
+ const errorTree = await renderSegments(errorResult.mainSegments, {
400
+ isAction: true,
401
+ interceptSegments:
402
+ errorResult.interceptSegments.length > 0
403
+ ? errorResult.interceptSegments
404
+ : undefined,
405
+ });
379
406
 
380
- // Get current page's cached segments
381
- const currentKey = store.getHistoryKey();
382
- const cached = store.getCachedSegments(currentKey);
383
- const cachedSegments = cached?.segments || [];
407
+ // Re-check route stability after async renderSegments — user may have
408
+ // navigated away while the error tree was being prepared.
409
+ if (window.location.pathname !== actionStartPathname) {
410
+ log("user navigated during error render, skipping");
411
+ if (returnValue && !returnValue.ok) {
412
+ throw returnValue.data;
413
+ }
414
+ handle.complete(undefined);
415
+ return undefined;
416
+ }
417
+ const currentKeyNow = store.getHistoryKey();
418
+ if (currentKeyNow !== currentKey) {
419
+ log("history key changed during error render, skipping cache update");
420
+ if (returnValue && !returnValue.ok) {
421
+ throw returnValue.data;
422
+ }
423
+ handle.complete(undefined);
424
+ return undefined;
425
+ }
384
426
 
385
- // Reconcile error segments with cached tree
386
- const errorResult = reconcileErrorSegments(cachedSegments, segments);
427
+ // Update UI with error boundary
428
+ startTransition(() => {
429
+ onUpdate({ root: errorTree, metadata: metadata! });
430
+ });
387
431
 
388
- // Render the full tree with error segment merged with parent layouts
389
- const errorTree = await renderSegments(errorResult.mainSegments, {
390
- isAction: true,
391
- interceptSegments:
392
- errorResult.interceptSegments.length > 0
393
- ? errorResult.interceptSegments
394
- : undefined,
395
- });
432
+ // Update segment tracking to exclude error segment IDs
433
+ const errorSegmentIds = new Set(diff);
434
+ const segmentIdsAfterError = segmentState.currentSegmentIds.filter(
435
+ (id) => !errorSegmentIds.has(id),
436
+ );
396
437
 
397
- // Re-check route stability after async renderSegments — user may have
398
- // navigated away while the error tree was being prepared.
399
- if (window.location.pathname !== actionStartPathname) {
400
- log("user navigated during error render, skipping");
438
+ // Update store state
439
+ store.setSegmentIds(segmentIdsAfterError);
440
+ const currentHandleData = eventController.getHandleState().data;
441
+ store.cacheSegmentsForHistory(
442
+ currentKey,
443
+ errorResult.segments,
444
+ currentHandleData,
445
+ );
446
+
447
+ // Throw the error so the action promise rejects
401
448
  if (returnValue && !returnValue.ok) {
402
449
  throw returnValue.data;
403
450
  }
451
+
452
+ // No error in returnValue (shouldn't happen with isError: true)
404
453
  handle.complete(undefined);
405
454
  return undefined;
406
455
  }
407
- const currentKeyNow = store.getHistoryKey();
408
- if (currentKeyNow !== currentKey) {
409
- log("history key changed during error render, skipping cache update");
410
- if (returnValue && !returnValue.ok) {
411
- throw returnValue.data;
412
- }
413
- handle.complete(undefined);
414
- return undefined;
456
+
457
+ if (!isPartial) {
458
+ // Protocol invariant: action revalidation responses MUST be partial.
459
+ // The server always sends isPartial: true for successful revalidation
460
+ // and isPartial: true + isError: true for error boundary responses.
461
+ // A non-partial payload here indicates a server-side bug.
462
+ throw new Error(
463
+ `[Browser] Action response missing isPartial — the server must ` +
464
+ `always send partial payloads for action revalidation.`,
465
+ );
415
466
  }
416
467
 
417
- // Update UI with error boundary
418
- startTransition(() => {
419
- onUpdate({ root: errorTree, metadata: metadata! });
468
+ log("processing partial update", {
469
+ serverSegments: segments?.length ?? 0,
470
+ diff: diff?.join(", ") ?? "",
471
+ matched: matched?.join(", ") ?? "",
420
472
  });
421
473
 
422
- // Update segment tracking to exclude error segment IDs
423
- const errorSegmentIds = new Set(diff);
424
- const segmentIdsAfterError = segmentState.currentSegmentIds.filter(
425
- (id) => !errorSegmentIds.has(id),
426
- );
474
+ // Record revalidated segments for concurrent action tracking
475
+ if (diff) {
476
+ handle.recordRevalidatedSegments(diff);
477
+ }
427
478
 
428
- // Update store state
429
- store.setSegmentIds(segmentIdsAfterError);
430
- const currentHandleData = eventController.getHandleState().data;
431
- store.cacheSegmentsForHistory(
432
- currentKey,
433
- errorResult.segments,
434
- currentHandleData,
435
- );
479
+ // Get current page's cached segments for merging
480
+ const currentKey = store.getHistoryKey();
481
+ const cached = store.getCachedSegments(currentKey);
482
+ const cachedSegments = cached?.segments || [];
483
+
484
+ if (!matched) {
485
+ throw new Error("No matched segments in response");
486
+ }
487
+
488
+ // Reconcile server segments with cached segments (single source of truth)
489
+ const reconciled = reconcileSegments({
490
+ actor: "action",
491
+ matched,
492
+ diff: diff || [],
493
+ serverSegments: segments || [],
494
+ cachedSegments,
495
+ });
496
+ const fullSegments = reconciled.segments;
497
+
498
+ const returnData = returnValue?.data;
436
499
 
437
- // Throw the error so the action promise rejects
438
500
  if (returnValue && !returnValue.ok) {
501
+ handle.fail(returnValue.data);
439
502
  throw returnValue.data;
440
503
  }
441
504
 
442
- // No error in returnValue (shouldn't happen with isError: true)
443
- handle.complete(undefined);
444
- return undefined;
445
- }
446
-
447
- if (!isPartial) {
448
- // Protocol invariant: action revalidation responses MUST be partial.
449
- // The server always sends isPartial: true for successful revalidation
450
- // and isPartial: true + isError: true for error boundary responses.
451
- // A non-partial payload here indicates a server-side bug.
452
- throw new Error(
453
- `[Browser] Action response missing isPartial — the server must ` +
454
- `always send partial payloads for action revalidation.`,
455
- );
456
- }
505
+ // Classify the post-reconciliation scenario
506
+ const scenario = classifyActionOutcome({
507
+ handleId: handle.id,
508
+ inflightActions: eventController.getInflightActions(),
509
+ hadAnyConcurrentActions: eventController.hadAnyConcurrentActions(),
510
+ revalidatedSegments: handle.getRevalidatedSegments(),
511
+ actionStartPathname,
512
+ currentPathname: window.location.pathname,
513
+ actionStartLocationKey: locationKey,
514
+ currentLocationKey: window.history.state?.key,
515
+ reconciledSegmentCount: fullSegments.length,
516
+ matchedCount: matched.length,
517
+ currentInterceptSource: store.getInterceptSourceUrl(),
518
+ });
457
519
 
458
- log("processing partial update", {
459
- serverSegments: segments?.length ?? 0,
460
- diff: diff?.join(", ") ?? "",
461
- matched: matched?.join(", ") ?? "",
462
- });
520
+ switch (scenario.type) {
521
+ case "navigated-away": {
522
+ log("user navigated away during action", {
523
+ from: actionStartPathname,
524
+ to: window.location.pathname,
525
+ historyKeyChanged: scenario.historyKeyChanged,
526
+ });
527
+ // Clear concurrent action tracking - don't consolidate for old route's segments
528
+ handle.clearConsolidation();
529
+
530
+ if (scenario.historyKeyChanged) {
531
+ if (!scenario.onInterceptRoute) {
532
+ store.markCacheAsStaleAndBroadcast();
533
+ refetchRoute().catch((error) => {
534
+ if (isBackgroundSuppressible(error)) return;
535
+ console.error(
536
+ "[Browser] Background revalidation failed:",
537
+ error,
538
+ );
539
+ });
540
+ }
541
+ break;
542
+ }
463
543
 
464
- // Record revalidated segments for concurrent action tracking
465
- if (diff) {
466
- handle.recordRevalidatedSegments(diff);
467
- }
544
+ // Same history key but different pathname - safe to refetch current route
545
+ store.markCacheAsStaleAndBroadcast();
546
+ await refetchRoute({
547
+ interceptSourceUrl: store.getInterceptSourceUrl(),
548
+ });
549
+ break;
550
+ }
468
551
 
469
- // Get current page's cached segments for merging
470
- const currentKey = store.getHistoryKey();
471
- const cached = store.getCachedSegments(currentKey);
472
- const cachedSegments = cached?.segments || [];
552
+ case "hmr-missing": {
553
+ console.warn(
554
+ `[Browser] Missing segments after action (HMR detected), refetching...`,
555
+ );
556
+ await refetchRoute({ interceptSourceUrl });
557
+ store.broadcastCacheInvalidation();
558
+ break;
559
+ }
473
560
 
474
- if (!matched) {
475
- throw new Error("No matched segments in response");
476
- }
561
+ case "consolidation-needed": {
562
+ log("consolidation fetch needed", {
563
+ segmentIds: scenario.segmentIds,
564
+ });
565
+ // Calculate segments to send (exclude the ones we want fresh)
566
+ const currentSegmentIds = store.getSegmentState().currentSegmentIds;
567
+ const segmentsToSend = currentSegmentIds.filter(
568
+ (sid) => !scenario.segmentIds.includes(sid),
569
+ );
477
570
 
478
- // Reconcile server segments with cached segments (single source of truth)
479
- const reconciled = reconcileSegments({
480
- actor: "action",
481
- matched,
482
- diff: diff || [],
483
- serverSegments: segments || [],
484
- cachedSegments,
485
- });
486
- const fullSegments = reconciled.segments;
487
-
488
- const returnData = returnValue?.data;
489
-
490
- if (returnValue && !returnValue.ok) {
491
- handle.fail(returnValue.data);
492
- throw returnValue.data;
493
- }
571
+ // Clear consolidation tracking before fetch
572
+ handle.clearConsolidation();
494
573
 
495
- // Classify the post-reconciliation scenario
496
- const scenario = classifyActionOutcome({
497
- handleId: handle.id,
498
- inflightActions: eventController.getInflightActions(),
499
- hadAnyConcurrentActions: eventController.hadAnyConcurrentActions(),
500
- revalidatedSegments: handle.getRevalidatedSegments(),
501
- actionStartPathname,
502
- currentPathname: window.location.pathname,
503
- actionStartLocationKey: locationKey,
504
- currentLocationKey: window.history.state?.key,
505
- reconciledSegmentCount: fullSegments.length,
506
- matchedCount: matched.length,
507
- currentInterceptSource: store.getInterceptSourceUrl(),
508
- });
509
-
510
- switch (scenario.type) {
511
- case "navigated-away": {
512
- log("user navigated away during action", {
513
- from: actionStartPathname,
514
- to: window.location.pathname,
515
- historyKeyChanged: scenario.historyKeyChanged,
516
- });
517
- // Clear concurrent action tracking - don't consolidate for old route's segments
518
- handle.clearConsolidation();
574
+ await refetchRoute({
575
+ segments: segmentsToSend,
576
+ interceptSourceUrl,
577
+ });
578
+ store.broadcastCacheInvalidation();
579
+ break;
580
+ }
519
581
 
520
- if (scenario.historyKeyChanged) {
521
- if (!scenario.onInterceptRoute) {
522
- store.markCacheAsStaleAndBroadcast();
523
- refetchRoute().catch((error) => {
524
- if (isBackgroundSuppressible(error)) return;
525
- console.error("[Browser] Background revalidation failed:", error);
526
- });
582
+ case "concurrent-skip": {
583
+ log("skipping UI update, other actions fetching", {
584
+ otherCount: scenario.otherFetchingCount,
585
+ });
586
+ // Only update store if history key hasn't changed (user didn't navigate away)
587
+ const currentKeyNow = store.getHistoryKey();
588
+ if (currentKeyNow === currentKey) {
589
+ store.setSegmentIds(matched);
590
+ const currentHandleData = eventController.getHandleState().data;
591
+ store.cacheSegmentsForHistory(
592
+ currentKey,
593
+ fullSegments,
594
+ currentHandleData,
595
+ );
527
596
  }
528
597
  break;
529
598
  }
530
599
 
531
- // Same history key but different pathname - safe to refetch current route
532
- store.markCacheAsStaleAndBroadcast();
533
- await refetchRoute({
534
- interceptSourceUrl: store.getInterceptSourceUrl(),
535
- });
536
- break;
537
- }
600
+ case "normal": {
601
+ // Prepare new tree (await loader data resolution)
602
+ const newTree = await renderSegments(reconciled.mainSegments, {
603
+ isAction: true,
604
+ interceptSegments:
605
+ reconciled.interceptSegments.length > 0
606
+ ? reconciled.interceptSegments
607
+ : undefined,
608
+ });
538
609
 
539
- case "hmr-missing": {
540
- console.warn(
541
- `[Browser] Missing segments after action (HMR detected), refetching...`,
542
- );
543
- await refetchRoute({ interceptSourceUrl });
544
- store.broadcastCacheInvalidation();
545
- break;
546
- }
610
+ // Re-check if user navigated away (could happen during async renderSegments)
611
+ if (window.location.pathname !== actionStartPathname) {
612
+ log("user navigated during render, skipping");
613
+ break;
614
+ }
547
615
 
548
- case "consolidation-needed": {
549
- log("consolidation fetch needed", { segmentIds: scenario.segmentIds });
550
- // Calculate segments to send (exclude the ones we want fresh)
551
- const currentSegmentIds = store.getSegmentState().currentSegmentIds;
552
- const segmentsToSend = currentSegmentIds.filter(
553
- (sid) => !scenario.segmentIds.includes(sid),
554
- );
616
+ // Verify the store's current key still matches what we captured at action start
617
+ // If they differ, user navigated away and we should NOT cache under the old key
618
+ const currentKeyNow = store.getHistoryKey();
619
+ if (currentKeyNow !== currentKey) {
620
+ log("history key changed during action, skipping cache update");
621
+ break;
622
+ }
555
623
 
556
- // Clear consolidation tracking before fetch
557
- handle.clearConsolidation();
624
+ startTransition(() => {
625
+ onUpdate({ root: newTree, metadata: metadata! });
626
+ });
558
627
 
559
- await refetchRoute({
560
- segments: segmentsToSend,
561
- interceptSourceUrl,
562
- });
563
- store.broadcastCacheInvalidation();
564
- break;
565
- }
628
+ // Apply server-set location state to history.state (non-redirect flow)
629
+ const actionLocationState = metadata?.locationState;
630
+ if (actionLocationState) {
631
+ mergeLocationState(actionLocationState);
632
+ }
566
633
 
567
- case "concurrent-skip": {
568
- log("skipping UI update, other actions fetching", {
569
- otherCount: scenario.otherFetchingCount,
570
- });
571
- // Only update store if history key hasn't changed (user didn't navigate away)
572
- const currentKeyNow = store.getHistoryKey();
573
- if (currentKeyNow === currentKey) {
634
+ // Update store state
574
635
  store.setSegmentIds(matched);
575
636
  const currentHandleData = eventController.getHandleState().data;
576
637
  store.cacheSegmentsForHistory(
@@ -578,59 +639,16 @@ export function createServerActionBridge(
578
639
  fullSegments,
579
640
  currentHandleData,
580
641
  );
581
- }
582
- break;
583
- }
584
-
585
- case "normal": {
586
- // Prepare new tree (await loader data resolution)
587
- const newTree = await renderSegments(reconciled.mainSegments, {
588
- isAction: true,
589
- interceptSegments:
590
- reconciled.interceptSegments.length > 0
591
- ? reconciled.interceptSegments
592
- : undefined,
593
- });
594
-
595
- // Re-check if user navigated away (could happen during async renderSegments)
596
- if (window.location.pathname !== actionStartPathname) {
597
- log("user navigated during render, skipping");
598
- break;
599
- }
600
-
601
- // Verify the store's current key still matches what we captured at action start
602
- // If they differ, user navigated away and we should NOT cache under the old key
603
- const currentKeyNow = store.getHistoryKey();
604
- if (currentKeyNow !== currentKey) {
605
- log("history key changed during action, skipping cache update");
642
+ store.markCacheAsStaleAndBroadcast();
606
643
  break;
607
644
  }
608
-
609
- startTransition(() => {
610
- onUpdate({ root: newTree, metadata: metadata! });
611
- });
612
-
613
- // Apply server-set location state to history.state (non-redirect flow)
614
- const actionLocationState = metadata?.locationState;
615
- if (actionLocationState) {
616
- mergeLocationState(actionLocationState);
617
- }
618
-
619
- // Update store state
620
- store.setSegmentIds(matched);
621
- const currentHandleData = eventController.getHandleState().data;
622
- store.cacheSegmentsForHistory(
623
- currentKey,
624
- fullSegments,
625
- currentHandleData,
626
- );
627
- store.markCacheAsStaleAndBroadcast();
628
- break;
629
645
  }
630
- }
631
646
 
632
- handle.complete(returnData);
633
- return returnData;
647
+ handle.complete(returnData);
648
+ return returnData;
649
+ } finally {
650
+ handle[Symbol.dispose]();
651
+ }
634
652
  }
635
653
 
636
654
  return {