@rangojs/router 0.0.0-experimental.2

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