@rangojs/router 0.0.0-experimental.10

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