@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.
- package/README.md +19 -0
- package/package.json +131 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +891 -0
- package/src/browser/navigation-client.ts +155 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +545 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +228 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +149 -0
- package/src/browser/rsc-router.tsx +310 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +443 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
- package/src/cache/cf/cf-cache-store.ts +274 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/index.ts +52 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +366 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +609 -0
- package/src/components/DefaultDocument.tsx +20 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +178 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href.ts +139 -0
- package/src/index.rsc.ts +69 -0
- package/src/index.ts +84 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1333 -0
- package/src/route-map-builder.ts +140 -0
- package/src/route-types.ts +148 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +60 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +116 -0
- package/src/router/match-context.ts +261 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +250 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +212 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +271 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3484 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +942 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +225 -0
- package/src/segment-system.tsx +405 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +340 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +470 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +126 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +215 -0
- package/src/types.ts +1473 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +608 -0
- package/src/vite/version.d.ts +12 -0
- 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 };
|