@ivogt/rsc-router 0.0.0-experimental.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +19 -0
  2. package/package.json +131 -0
  3. package/src/__mocks__/version.ts +6 -0
  4. package/src/__tests__/route-definition.test.ts +63 -0
  5. package/src/browser/event-controller.ts +876 -0
  6. package/src/browser/index.ts +18 -0
  7. package/src/browser/link-interceptor.ts +121 -0
  8. package/src/browser/lru-cache.ts +69 -0
  9. package/src/browser/merge-segment-loaders.ts +126 -0
  10. package/src/browser/navigation-bridge.ts +891 -0
  11. package/src/browser/navigation-client.ts +155 -0
  12. package/src/browser/navigation-store.ts +823 -0
  13. package/src/browser/partial-update.ts +545 -0
  14. package/src/browser/react/Link.tsx +248 -0
  15. package/src/browser/react/NavigationProvider.tsx +228 -0
  16. package/src/browser/react/ScrollRestoration.tsx +94 -0
  17. package/src/browser/react/context.ts +53 -0
  18. package/src/browser/react/index.ts +52 -0
  19. package/src/browser/react/location-state-shared.ts +120 -0
  20. package/src/browser/react/location-state.ts +62 -0
  21. package/src/browser/react/use-action.ts +240 -0
  22. package/src/browser/react/use-client-cache.ts +56 -0
  23. package/src/browser/react/use-handle.ts +178 -0
  24. package/src/browser/react/use-link-status.ts +134 -0
  25. package/src/browser/react/use-navigation.ts +150 -0
  26. package/src/browser/react/use-segments.ts +188 -0
  27. package/src/browser/request-controller.ts +149 -0
  28. package/src/browser/rsc-router.tsx +310 -0
  29. package/src/browser/scroll-restoration.ts +324 -0
  30. package/src/browser/server-action-bridge.ts +747 -0
  31. package/src/browser/shallow.ts +35 -0
  32. package/src/browser/types.ts +443 -0
  33. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  34. package/src/cache/__tests__/memory-store.test.ts +484 -0
  35. package/src/cache/cache-scope.ts +565 -0
  36. package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
  37. package/src/cache/cf/cf-cache-store.ts +274 -0
  38. package/src/cache/cf/index.ts +19 -0
  39. package/src/cache/index.ts +52 -0
  40. package/src/cache/memory-segment-store.ts +150 -0
  41. package/src/cache/memory-store.ts +253 -0
  42. package/src/cache/types.ts +366 -0
  43. package/src/client.rsc.tsx +88 -0
  44. package/src/client.tsx +609 -0
  45. package/src/components/DefaultDocument.tsx +20 -0
  46. package/src/default-error-boundary.tsx +88 -0
  47. package/src/deps/browser.ts +8 -0
  48. package/src/deps/html-stream-client.ts +2 -0
  49. package/src/deps/html-stream-server.ts +2 -0
  50. package/src/deps/rsc.ts +10 -0
  51. package/src/deps/ssr.ts +2 -0
  52. package/src/errors.ts +259 -0
  53. package/src/handle.ts +120 -0
  54. package/src/handles/MetaTags.tsx +178 -0
  55. package/src/handles/index.ts +6 -0
  56. package/src/handles/meta.ts +247 -0
  57. package/src/href-client.ts +128 -0
  58. package/src/href.ts +139 -0
  59. package/src/index.rsc.ts +69 -0
  60. package/src/index.ts +84 -0
  61. package/src/loader.rsc.ts +204 -0
  62. package/src/loader.ts +47 -0
  63. package/src/network-error-thrower.tsx +21 -0
  64. package/src/outlet-context.ts +15 -0
  65. package/src/root-error-boundary.tsx +277 -0
  66. package/src/route-content-wrapper.tsx +198 -0
  67. package/src/route-definition.ts +1333 -0
  68. package/src/route-map-builder.ts +140 -0
  69. package/src/route-types.ts +148 -0
  70. package/src/route-utils.ts +89 -0
  71. package/src/router/__tests__/match-context.test.ts +104 -0
  72. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  73. package/src/router/__tests__/match-result.test.ts +566 -0
  74. package/src/router/__tests__/on-error.test.ts +935 -0
  75. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  76. package/src/router/error-handling.ts +287 -0
  77. package/src/router/handler-context.ts +60 -0
  78. package/src/router/loader-resolution.ts +326 -0
  79. package/src/router/manifest.ts +116 -0
  80. package/src/router/match-context.ts +261 -0
  81. package/src/router/match-middleware/background-revalidation.ts +236 -0
  82. package/src/router/match-middleware/cache-lookup.ts +261 -0
  83. package/src/router/match-middleware/cache-store.ts +250 -0
  84. package/src/router/match-middleware/index.ts +81 -0
  85. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  86. package/src/router/match-middleware/segment-resolution.ts +174 -0
  87. package/src/router/match-pipelines.ts +214 -0
  88. package/src/router/match-result.ts +212 -0
  89. package/src/router/metrics.ts +62 -0
  90. package/src/router/middleware.test.ts +1355 -0
  91. package/src/router/middleware.ts +748 -0
  92. package/src/router/pattern-matching.ts +271 -0
  93. package/src/router/revalidation.ts +190 -0
  94. package/src/router/router-context.ts +299 -0
  95. package/src/router/types.ts +96 -0
  96. package/src/router.ts +3484 -0
  97. package/src/rsc/__tests__/helpers.test.ts +175 -0
  98. package/src/rsc/handler.ts +942 -0
  99. package/src/rsc/helpers.ts +64 -0
  100. package/src/rsc/index.ts +56 -0
  101. package/src/rsc/nonce.ts +18 -0
  102. package/src/rsc/types.ts +225 -0
  103. package/src/segment-system.tsx +405 -0
  104. package/src/server/__tests__/request-context.test.ts +171 -0
  105. package/src/server/context.ts +340 -0
  106. package/src/server/handle-store.ts +230 -0
  107. package/src/server/loader-registry.ts +174 -0
  108. package/src/server/request-context.ts +470 -0
  109. package/src/server/root-layout.tsx +10 -0
  110. package/src/server/tsconfig.json +14 -0
  111. package/src/server.ts +126 -0
  112. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  113. package/src/ssr/index.tsx +215 -0
  114. package/src/types.ts +1473 -0
  115. package/src/use-loader.tsx +346 -0
  116. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  117. package/src/vite/expose-action-id.ts +344 -0
  118. package/src/vite/expose-handle-id.ts +209 -0
  119. package/src/vite/expose-loader-id.ts +357 -0
  120. package/src/vite/expose-location-state-id.ts +177 -0
  121. package/src/vite/index.ts +608 -0
  122. package/src/vite/version.d.ts +12 -0
  123. package/src/vite/virtual-entries.ts +109 -0
@@ -0,0 +1,545 @@
1
+ import type {
2
+ NavigationStore,
3
+ NavigationClient,
4
+ UpdateSubscriber,
5
+ ResolvedSegment,
6
+ } from "./types.js";
7
+ import type { ReactNode } from "react";
8
+ import { startTransition } from "react";
9
+ import type { RenderSegmentsOptions } from "../segment-system.js";
10
+ import {
11
+ mergeSegmentLoaders,
12
+ needsLoaderMerge,
13
+ insertMissingDiffSegments,
14
+ } from "./merge-segment-loaders.js";
15
+ import type { BoundTransaction } from "./navigation-bridge.js";
16
+
17
+ /**
18
+ * Configuration for creating a partial updater
19
+ */
20
+ export interface PartialUpdateConfig {
21
+ store: NavigationStore;
22
+ client: NavigationClient;
23
+ onUpdate: UpdateSubscriber;
24
+ renderSegments: (
25
+ segments: ResolvedSegment[],
26
+ options?: RenderSegmentsOptions
27
+ ) => Promise<ReactNode> | ReactNode;
28
+ /** RSC version received from server (from initial payload metadata) */
29
+ version?: string;
30
+ }
31
+
32
+ /**
33
+ * Options that can override the pre-configured commit settings
34
+ */
35
+ export interface CommitOverrides {
36
+ /** Override scroll behavior (e.g., disable for intercepts) */
37
+ scroll?: boolean;
38
+ /** Override replace behavior (e.g., force replace for intercepts) */
39
+ replace?: boolean;
40
+ /** Mark this as an intercept route */
41
+ intercept?: boolean;
42
+ /** Source URL where intercept was triggered from */
43
+ interceptSourceUrl?: string;
44
+ }
45
+
46
+ /**
47
+ * Commit context passed to partial updater for URL updates
48
+ * Transaction encapsulates all store mutations for atomic commit
49
+ */
50
+
51
+ /**
52
+ * Type for the fetchPartialUpdate function
53
+ */
54
+ export type PartialUpdater = (
55
+ targetUrl: string,
56
+ segmentIds: string[] | undefined,
57
+ isRetry: boolean,
58
+ signal: AbortSignal | undefined,
59
+ type: BoundTransaction,
60
+ options?: {
61
+ isAction?: boolean;
62
+ staleRevalidation?: boolean;
63
+ interceptSourceUrl?: string;
64
+ /** Cached segments for the target URL. When provided, these are used to build
65
+ * the segment map instead of the current page's segments. This ensures consistency
66
+ * when we send cached segment IDs to the server - if the server returns empty diff,
67
+ * we use the same segments we told the server we have. */
68
+ targetCacheSegments?: ResolvedSegment[];
69
+ }
70
+ ) => Promise<Promise<void>>;
71
+
72
+ /**
73
+ * Create a partial updater for fetching and applying RSC partial updates
74
+ *
75
+ * This function is shared between navigation-bridge and server-action-bridge
76
+ * to handle partial RSC updates with HMR resilience.
77
+ *
78
+ * @param config - Partial update configuration
79
+ * @returns fetchPartialUpdate function
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * const fetchPartialUpdate = createPartialUpdater({
84
+ * store,
85
+ * client,
86
+ * onUpdate: (update) => store.emit(update),
87
+ * renderSegments,
88
+ * });
89
+ *
90
+ * await fetchPartialUpdate('/new-page');
91
+ * ```
92
+ */
93
+ export function createPartialUpdater(
94
+ config: PartialUpdateConfig
95
+ ): PartialUpdater {
96
+ const { store, client, onUpdate, renderSegments, version } = config;
97
+
98
+ /**
99
+ * Build a lookup map from current page's cached segments
100
+ */
101
+ function getCurrentSegmentMap(): Map<string, ResolvedSegment> {
102
+ const currentKey = store.getHistoryKey();
103
+ const cached = store.getCachedSegments(currentKey);
104
+ const cachedSegments = cached?.segments || [];
105
+ const map = new Map<string, ResolvedSegment>();
106
+ cachedSegments.forEach((s) => map.set(s.id, s));
107
+ return map;
108
+ }
109
+
110
+ /**
111
+ * Fetch partial update and trigger UI update
112
+ * Returns a promise that resolves when the RSC stream is fully consumed
113
+ *
114
+ * @param tx - Transaction for committing segment state (required)
115
+ * @param signal - AbortSignal to check if navigation is stale (not for aborting fetch)
116
+ */
117
+ async function fetchPartialUpdate(
118
+ targetUrl: string,
119
+ segmentIds: string[] | undefined,
120
+ isRetry: boolean,
121
+ signal: AbortSignal | undefined,
122
+ tx: BoundTransaction,
123
+ options?: {
124
+ isAction?: boolean;
125
+ staleRevalidation?: boolean;
126
+ interceptSourceUrl?: string;
127
+ targetCacheSegments?: ResolvedSegment[];
128
+ }
129
+ ): Promise<Promise<void>> {
130
+ const {
131
+ isAction = false,
132
+ staleRevalidation = false,
133
+ interceptSourceUrl,
134
+ targetCacheSegments,
135
+ } = options || {};
136
+ const segmentState = store.getSegmentState();
137
+ const url = targetUrl || window.location.href;
138
+
139
+ // Capture history key at start for stale revalidation consistency check
140
+ const historyKeyAtStart = store.getHistoryKey();
141
+ const segments = segmentIds ?? segmentState.currentSegmentIds;
142
+
143
+ // For intercept revalidation, use the intercept source URL as previousUrl
144
+ // This tells the server the route should be treated as an intercept
145
+ const previousUrl =
146
+ interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
147
+
148
+ console.log(`\n[Browser] >>> NAVIGATION`);
149
+ console.log(`[Browser] From: ${previousUrl}`);
150
+ console.log(`[Browser] To: ${url}`);
151
+ console.log(`[Browser] Segments to send: ${segments.join(", ")}`);
152
+ if (interceptSourceUrl) {
153
+ console.log(`[Browser] Intercept context from: ${interceptSourceUrl}`);
154
+ }
155
+
156
+ // Build segment map for merging with server diff.
157
+ // When targetCacheSegments is provided (navigating to a cached route), use those
158
+ // to ensure consistency - we use the same segments we told the server we have.
159
+ // Otherwise fall back to current page's segments (for same-route revalidation).
160
+ let currentSegmentMap: Map<string, ResolvedSegment>;
161
+ if (targetCacheSegments && targetCacheSegments.length > 0) {
162
+ currentSegmentMap = new Map();
163
+ targetCacheSegments.forEach((s) => currentSegmentMap.set(s.id, s));
164
+ } else {
165
+ currentSegmentMap = getCurrentSegmentMap();
166
+ }
167
+ // Mark navigation as streaming (response received, now parsing RSC)
168
+ // The token is ended when the stream completes
169
+ const streamingToken = tx.startStreaming();
170
+ // Fetch partial payload (no abort signal - RSC doesn't support it well)
171
+ const { payload, streamComplete: rawStreamComplete } =
172
+ await client.fetchPartial({
173
+ targetUrl: url,
174
+ segmentIds: segments,
175
+ previousUrl,
176
+ staleRevalidation,
177
+ version,
178
+ });
179
+ console.log("payload.metadata", payload.metadata);
180
+
181
+ const streamComplete = rawStreamComplete.then(() => {
182
+ streamingToken.end();
183
+ });
184
+
185
+ if (payload.metadata?.isPartial) {
186
+ const { segments: newSegments, matched, diff } = payload.metadata;
187
+
188
+ // Check if this navigation is stale (a newer one started)
189
+ if (signal?.aborted) {
190
+ console.log(`[Browser] Ignoring stale navigation (aborted)`);
191
+ return streamComplete;
192
+ }
193
+
194
+ console.log(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
195
+ console.log(`[Browser] Diff: ${diff?.join(", ")}`);
196
+
197
+ // Create lookup for new segments from server
198
+ const newSegmentMap = new Map<string, ResolvedSegment>();
199
+ (newSegments || []).forEach((s: ResolvedSegment) =>
200
+ newSegmentMap.set(s.id, s)
201
+ );
202
+
203
+ // If diff is empty, nothing changed on server side.
204
+ // However, if we're navigating with targetCacheSegments (to a different route),
205
+ // we still need to render those segments since the UI is showing the old route.
206
+ if (!diff || diff.length === 0) {
207
+ const matchedIds = matched || [];
208
+ const existingSegments = matchedIds
209
+ .map((id: string) => currentSegmentMap.get(id))
210
+ .filter(Boolean) as ResolvedSegment[];
211
+
212
+ // When navigating with cached segments to a different route, render them.
213
+ // targetCacheSegments being provided means we're navigating to a cached route.
214
+ if (targetCacheSegments && targetCacheSegments.length > 0) {
215
+ console.log(
216
+ `[Browser] No diff but navigating with cached segments - rendering target route`
217
+ );
218
+
219
+ const newTree = await renderSegments(existingSegments, {
220
+ forceAwait: true,
221
+ });
222
+
223
+ tx.commit(matchedIds, existingSegments);
224
+
225
+ onUpdate({
226
+ root: newTree,
227
+ metadata: payload.metadata!,
228
+ });
229
+
230
+ console.log(`[Browser] Navigation complete (rendered from cache)\n`);
231
+ return streamComplete;
232
+ }
233
+
234
+ // Same route revalidation with no changes - skip UI update
235
+ console.log(
236
+ `[Browser] No changes - all revalidations returned false, keeping existing UI`
237
+ );
238
+ tx.commit(matchedIds, existingSegments);
239
+ console.log(`[Browser] Navigation complete (no re-render)\n`);
240
+ return streamComplete;
241
+ }
242
+
243
+ // Build full segment list by merging:
244
+ // - New/changed segments from server response (diff)
245
+ // - Unchanged segments from current page's cache
246
+ const matchedIds = matched || [];
247
+ console.log(`[Browser] matchedIds: ${matchedIds.join(", ")}`);
248
+ console.log(
249
+ `[Browser] currentSegmentMap keys: ${[...currentSegmentMap.keys()].join(", ")}`
250
+ );
251
+ console.log(
252
+ `[Browser] newSegmentMap keys: ${[...newSegmentMap.keys()].join(", ")}`,
253
+ newSegmentMap
254
+ );
255
+
256
+ // First pass: build segments from matched IDs
257
+ const matchedIdSet = new Set(matchedIds);
258
+ const allSegments = matchedIds
259
+ .map((id: string) => {
260
+ // First check server response (new/updated segments)
261
+ const fromServer = newSegmentMap.get(id);
262
+ if (fromServer) {
263
+ // For partial revalidation (stale or action), merge server's new loader data
264
+ // with cached loader data when server returns fewer loaders than cached
265
+ const fromCache = currentSegmentMap.get(id);
266
+ if (
267
+ (staleRevalidation || isAction) &&
268
+ needsLoaderMerge(fromServer, fromCache)
269
+ ) {
270
+ return mergeSegmentLoaders(fromServer, fromCache);
271
+ }
272
+ // When server returns component: null for a layout segment, it means
273
+ // "this segment doesn't need re-rendering" - preserve the cached component
274
+ // to maintain the outlet chain and prevent React tree changes
275
+ if (
276
+ fromServer.component === null &&
277
+ fromServer.type === "layout" &&
278
+ fromCache?.component != null
279
+ ) {
280
+ console.log(
281
+ `[Browser] Preserving cached component for layout ${id} (server returned null)`
282
+ );
283
+ return { ...fromServer, component: fromCache.component };
284
+ }
285
+ return fromServer;
286
+ }
287
+ // Fall back to current page's cached segments
288
+ const fromCache = currentSegmentMap.get(id);
289
+ if (!fromCache) {
290
+ console.warn(`[Browser] Missing segment: ${id}`);
291
+ return fromCache;
292
+ }
293
+ // Clear loading for cached segments to prevent suspense - server decided
294
+ // this segment doesn't need re-rendering, so show content as-is
295
+ if (fromCache.loading !== undefined) {
296
+ return { ...fromCache, loading: undefined };
297
+ }
298
+ return fromCache;
299
+ })
300
+ .filter(Boolean) as ResolvedSegment[];
301
+
302
+ // Insert diff segments not in matchedIds (e.g., loader segments from consolidation fetch)
303
+ insertMissingDiffSegments(allSegments, diff, matchedIdSet, newSegmentMap);
304
+
305
+ // HMR RESILIENCE: Check if we're missing any matched segments
306
+ // Note: allSegments may include additional diff segments, so we check matchedIds specifically
307
+ const allSegmentIdSet = new Set(allSegments.map((s) => s.id));
308
+ const missingIds = matchedIds.filter(
309
+ (id: string) => !allSegmentIdSet.has(id)
310
+ );
311
+
312
+ if (missingIds.length > 0) {
313
+ const missingCount = missingIds.length;
314
+
315
+ if (isRetry) {
316
+ console.warn("Missing ids", { missingIds });
317
+ throw new Error(
318
+ `[Browser] Failed to fetch segments after retry. Missing: [${missingIds.join(", ")}]`
319
+ );
320
+ }
321
+ if (signal?.aborted) {
322
+ console.log(
323
+ `[Browser] Ignoring stale navigation (aborted during HMR retry)`
324
+ );
325
+ return streamComplete;
326
+ }
327
+ if (isAction) {
328
+ return streamComplete;
329
+ }
330
+ console.warn(
331
+ `[Browser] HMR detected: Missing ${missingCount} segments. Refetching all...`
332
+ );
333
+
334
+ // Refetch with empty segments = server sends everything
335
+ return fetchPartialUpdate(url, [], true, signal, tx, { isAction });
336
+ }
337
+
338
+ // INTERCEPT HANDLING: Separate intercept segments for explicit injection
339
+ // Intercept segments have namespace starting with "intercept:" or ID containing .@
340
+ // This makes the flow clearer and easier to debug
341
+ const isInterceptSegment = (s: ResolvedSegment) =>
342
+ s.namespace?.startsWith("intercept:") ||
343
+ (s.type === "parallel" && s.id.includes(".@"));
344
+
345
+ const interceptSegments = allSegments.filter(isInterceptSegment);
346
+ const mainSegments = allSegments.filter((s) => !isInterceptSegment(s));
347
+
348
+ if (signal?.aborted) {
349
+ console.log(
350
+ `[Browser] Ignoring stale navigation (aborted before render)`
351
+ );
352
+ return streamComplete;
353
+ }
354
+
355
+ // Rebuild tree on client (await for loader data resolution)
356
+ // Race against abort signal to allow cancellation during loader awaiting
357
+ // Pass intercept segments separately for explicit handling
358
+ // For stale revalidation, use forceAwait to ensure no loading fallbacks
359
+ const renderOptions = {
360
+ isAction,
361
+ forceAwait: staleRevalidation,
362
+ interceptSegments:
363
+ interceptSegments.length > 0 ? interceptSegments : undefined,
364
+ };
365
+ const newTree = await (signal
366
+ ? Promise.race([
367
+ renderSegments(mainSegments, renderOptions),
368
+ new Promise<never>((_, reject) => {
369
+ if (signal.aborted) {
370
+ reject(new DOMException("Navigation aborted", "AbortError"));
371
+ }
372
+ signal.addEventListener("abort", () => {
373
+ reject(new DOMException("Navigation aborted", "AbortError"));
374
+ });
375
+ }),
376
+ ])
377
+ : renderSegments(mainSegments, renderOptions));
378
+
379
+ // Final abort check before committing - another navigation may have started
380
+ if (signal?.aborted) {
381
+ console.log(
382
+ `[Browser] Ignoring stale navigation (aborted before commit)`
383
+ );
384
+ return streamComplete;
385
+ }
386
+
387
+ // Check if this is an intercept response (any slot is active)
388
+ // If so, disable scroll to keep the current scroll position
389
+ const hasActiveIntercept = payload.metadata?.slots
390
+ ? Object.values(payload.metadata.slots).some((slot) => slot.active)
391
+ : false;
392
+
393
+ // BUG FIX: When navigating with cached target segments but receiving an intercept response,
394
+ // the background segments should come from the SOURCE page (where we navigated from),
395
+ // not the TARGET cache. This happens when:
396
+ // 1. User visits /product/xxx (detail page) - cached under key "/product/xxx"
397
+ // 2. User navigates back to /
398
+ // 3. User clicks product link → cache hit for "/product/xxx" (detail page)
399
+ // 4. But server returns intercept response (modal with index background)
400
+ // 5. Without this fix: background uses detail page segments (wrong!)
401
+ // 6. With this fix: rebuild currentSegmentMap from source page
402
+ if (hasActiveIntercept && targetCacheSegments) {
403
+ console.log(
404
+ `[Browser] Intercept response with target cache - rebuilding segment map from source page`
405
+ );
406
+ currentSegmentMap = getCurrentSegmentMap();
407
+ }
408
+
409
+ // Track intercept context for action revalidation (only on navigation, not actions or stale revalidation)
410
+ if (!isAction && !staleRevalidation) {
411
+ if (hasActiveIntercept) {
412
+ // Save the source URL for action revalidation to maintain intercept context
413
+ store.setInterceptSourceUrl(segmentState.currentUrl);
414
+ } else {
415
+ // Clear intercept context when navigating to a non-intercept route
416
+ store.setInterceptSourceUrl(null);
417
+ }
418
+ }
419
+
420
+ // Commit navigation - transaction handles all store mutations atomically
421
+ // For intercept responses: disable scroll, mark as intercept, include source URL
422
+ // Use allSegmentIds (derived from allSegments) instead of matchedIds because
423
+ // we may have added diff segments (like loader segments) not in the matched array
424
+ const allSegmentIds = allSegments.map((s) => s.id);
425
+ tx.commit(
426
+ allSegmentIds,
427
+ allSegments,
428
+ hasActiveIntercept
429
+ ? {
430
+ scroll: false,
431
+ intercept: true,
432
+ interceptSourceUrl: segmentState.currentUrl,
433
+ }
434
+ : undefined
435
+ );
436
+
437
+ // For stale revalidation: verify history key hasn't changed before updating UI
438
+ // If user navigated away, skip UI update to avoid corrupting current view
439
+ if (staleRevalidation) {
440
+ const historyKeyNow = store.getHistoryKey();
441
+ if (historyKeyNow !== historyKeyAtStart) {
442
+ console.log(
443
+ `[Browser] Stale revalidation: history key changed (${historyKeyAtStart} -> ${historyKeyNow}), skipping UI update`
444
+ );
445
+ return streamComplete;
446
+ }
447
+ }
448
+
449
+ console.log("[partial-update] updating document");
450
+
451
+ // Emit update to trigger React render
452
+ // For stale revalidation: wait for stream to complete (loaders resolved), then update
453
+ // For actions: wrap in startTransition to avoid UI flickering
454
+ if (isAction || staleRevalidation) {
455
+ startTransition(() => {
456
+ onUpdate({
457
+ root: newTree,
458
+ metadata: payload.metadata!,
459
+ });
460
+ });
461
+ } else {
462
+ onUpdate({
463
+ root: newTree,
464
+ metadata: payload.metadata!,
465
+ });
466
+ }
467
+
468
+ console.log(`[Browser] Navigation complete\n`);
469
+ return streamComplete;
470
+ } else {
471
+ // Full update (fallback)
472
+ console.warn(`[Browser] Full update (fallback)`);
473
+
474
+ const segments = payload.metadata?.segments || [];
475
+
476
+ // Check if this navigation is stale (a newer one started)
477
+ if (signal?.aborted) {
478
+ console.log(`[Browser] Ignoring stale navigation (aborted)`);
479
+ return streamComplete;
480
+ }
481
+
482
+ // Await loader data from segments before committing URL
483
+ // This ensures URL only updates after loaders resolve
484
+ const loaderSegments = segments.filter(
485
+ (s: ResolvedSegment) =>
486
+ s.type === "loader" && s.loaderData !== undefined
487
+ );
488
+ if (loaderSegments.length > 0) {
489
+ console.log(`[Browser] Awaiting ${loaderSegments.length} loader(s)...`);
490
+ await Promise.all(
491
+ loaderSegments.map((s: ResolvedSegment) =>
492
+ s.loaderData instanceof Promise
493
+ ? s.loaderData
494
+ : Promise.resolve(s.loaderData)
495
+ )
496
+ );
497
+ console.log(`[Browser] Loaders resolved`);
498
+ }
499
+
500
+ const segmentIds = segments.map((s: ResolvedSegment) => s.id);
501
+
502
+ // Final abort check before committing - another navigation may have started
503
+ if (signal?.aborted) {
504
+ console.log(
505
+ `[Browser] Ignoring stale navigation (aborted before commit)`
506
+ );
507
+ return streamComplete;
508
+ }
509
+
510
+ // Commit navigation - transaction handles all store mutations atomically
511
+ tx.commit(segmentIds, segments);
512
+
513
+ // Emit update to trigger React render
514
+ // For stale revalidation: wait for stream to complete, then update
515
+ // For actions: wrap in startTransition to avoid UI flickering
516
+ if (staleRevalidation) {
517
+ await rawStreamComplete;
518
+ startTransition(() => {
519
+ onUpdate({
520
+ root: payload.root,
521
+ metadata: payload.metadata!,
522
+ });
523
+ });
524
+ } else if (isAction) {
525
+ startTransition(async () => {
526
+ onUpdate({
527
+ root: payload.root,
528
+ metadata: payload.metadata!,
529
+ });
530
+ });
531
+ } else {
532
+ onUpdate({
533
+ root: payload.root,
534
+ metadata: payload.metadata!,
535
+ });
536
+ }
537
+
538
+ return streamComplete;
539
+ }
540
+ }
541
+
542
+ return fetchPartialUpdate;
543
+ }
544
+
545
+ export { createPartialUpdater as default };