@rangojs/router 0.0.0-experimental.57 → 0.0.0-experimental.57005a2b
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 +76 -18
- package/dist/bin/rango.js +2 -1
- package/dist/vite/index.js +507 -192
- package/dist/vite/index.js.bak +5448 -0
- package/package.json +3 -3
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/middleware/SKILL.md +32 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +764 -0
- package/skills/parallel/SKILL.md +59 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +24 -0
- package/src/__internal.ts +1 -1
- package/src/browser/navigation-bridge.ts +21 -2
- package/src/browser/navigation-client.ts +34 -6
- package/src/browser/partial-update.ts +14 -2
- package/src/browser/prefetch/cache.ts +16 -6
- package/src/browser/prefetch/fetch.ts +60 -4
- package/src/browser/react/Link.tsx +25 -2
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/scroll-restoration.ts +10 -8
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/build/generate-manifest.ts +3 -6
- package/src/build/route-trie.ts +50 -24
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/client.tsx +84 -230
- package/src/handle.ts +40 -0
- package/src/index.rsc.ts +3 -1
- package/src/index.ts +46 -6
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +25 -1
- package/src/route-definition/dsl-helpers.ts +194 -32
- package/src/route-definition/helpers-types.ts +61 -14
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-types.ts +18 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/handler-context.ts +46 -6
- package/src/router/lazy-includes.ts +5 -5
- package/src/router/loader-resolution.ts +147 -19
- package/src/router/manifest.ts +12 -7
- package/src/router/match-api.ts +124 -189
- package/src/router/match-middleware/cache-lookup.ts +24 -7
- package/src/router/match-middleware/segment-resolution.ts +53 -0
- package/src/router/match-result.ts +82 -4
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/prerender-match.ts +108 -8
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-interfaces.ts +11 -0
- package/src/router/segment-resolution/fresh.ts +59 -2
- package/src/router/segment-resolution/revalidation.ts +79 -6
- package/src/router.ts +13 -1
- package/src/rsc/handler.ts +468 -377
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/progressive-enhancement.ts +10 -2
- package/src/rsc/rsc-rendering.ts +5 -1
- package/src/rsc/server-action.ts +6 -0
- package/src/rsc/ssr-setup.ts +1 -1
- package/src/rsc/types.ts +1 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +11 -61
- package/src/server/context.ts +40 -4
- package/src/server/handle-store.ts +19 -0
- package/src/server/request-context.ts +125 -3
- package/src/static-handler.ts +18 -6
- package/src/types/handler-context.ts +12 -2
- package/src/types/loader-types.ts +32 -4
- package/src/types/route-entry.ts +12 -1
- package/src/types/segments.ts +1 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +39 -6
- package/src/urls/path-helper.ts +47 -12
- package/src/urls/response-types.ts +16 -6
- package/src/use-loader.tsx +77 -5
- package/src/vite/discovery/bundle-postprocess.ts +30 -33
- package/src/vite/discovery/prerender-collection.ts +128 -74
- package/src/vite/discovery/state.ts +13 -4
- package/src/vite/index.ts +4 -0
- package/src/vite/plugin-types.ts +60 -5
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +257 -40
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/rango.ts +2 -1
- package/src/vite/router-discovery.ts +178 -37
- package/src/vite/utils/prerender-utils.ts +37 -5
|
@@ -101,10 +101,32 @@ export function createNavigationClient(
|
|
|
101
101
|
//
|
|
102
102
|
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
103
103
|
const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
// Wildcard key matches prefetch entries stored with a custom prefetchKey
|
|
105
|
+
// (Link's prefetchKey prop stores under "*" instead of the source URL).
|
|
106
|
+
const wildcardKey = "*\0" + fetchUrl.pathname + fetchUrl.search;
|
|
107
|
+
|
|
108
|
+
let cachedResponse: Response | null = null;
|
|
109
|
+
let hitKey: string | null = null;
|
|
110
|
+
if (canUsePrefetch) {
|
|
111
|
+
cachedResponse = consumePrefetch(cacheKey);
|
|
112
|
+
if (cachedResponse) {
|
|
113
|
+
hitKey = cacheKey;
|
|
114
|
+
} else {
|
|
115
|
+
cachedResponse = consumePrefetch(wildcardKey);
|
|
116
|
+
if (cachedResponse) hitKey = wildcardKey;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let inflightResponsePromise: Promise<Response | null> | null = null;
|
|
121
|
+
if (canUsePrefetch && !cachedResponse) {
|
|
122
|
+
inflightResponsePromise = consumeInflightPrefetch(cacheKey);
|
|
123
|
+
if (inflightResponsePromise) {
|
|
124
|
+
hitKey = cacheKey;
|
|
125
|
+
} else {
|
|
126
|
+
inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
|
|
127
|
+
if (inflightResponsePromise) hitKey = wildcardKey;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
108
130
|
// Track when the stream completes
|
|
109
131
|
let resolveStreamComplete: () => void;
|
|
110
132
|
const streamComplete = new Promise<void>((resolve) => {
|
|
@@ -197,7 +219,10 @@ export function createNavigationClient(
|
|
|
197
219
|
|
|
198
220
|
if (cachedResponse) {
|
|
199
221
|
if (tx) {
|
|
200
|
-
browserDebugLog(tx, "prefetch cache hit", {
|
|
222
|
+
browserDebugLog(tx, "prefetch cache hit", {
|
|
223
|
+
key: hitKey,
|
|
224
|
+
wildcard: hitKey === wildcardKey,
|
|
225
|
+
});
|
|
201
226
|
}
|
|
202
227
|
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
203
228
|
const validated = validateRscHeaders(response, "prefetch cache");
|
|
@@ -214,7 +239,10 @@ export function createNavigationClient(
|
|
|
214
239
|
});
|
|
215
240
|
} else if (inflightResponsePromise) {
|
|
216
241
|
if (tx) {
|
|
217
|
-
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
242
|
+
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
243
|
+
key: hitKey,
|
|
244
|
+
wildcard: hitKey === wildcardKey,
|
|
245
|
+
});
|
|
218
246
|
}
|
|
219
247
|
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
220
248
|
if (!response) {
|
|
@@ -167,9 +167,16 @@ export function createPartialUpdater(
|
|
|
167
167
|
segments = segmentIds ?? segmentState.currentSegmentIds;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
// For intercept revalidation, use the intercept source URL as previousUrl
|
|
170
|
+
// For intercept revalidation, use the intercept source URL as previousUrl.
|
|
171
|
+
// For leave-intercept, tx.currentUrl captures window.location.href at tx
|
|
172
|
+
// creation, which on popstate is already the destination URL and would
|
|
173
|
+
// tell the server "from == to". segmentState.currentUrl still points at
|
|
174
|
+
// the URL the cached segments render (the intercept URL), which is the
|
|
175
|
+
// correct "from" for the server's diff computation.
|
|
171
176
|
const previousUrl =
|
|
172
|
-
|
|
177
|
+
mode.type === "leave-intercept"
|
|
178
|
+
? segmentState.currentUrl || tx.currentUrl
|
|
179
|
+
: interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
|
|
173
180
|
|
|
174
181
|
debugLog(`\n[Browser] >>> NAVIGATION`);
|
|
175
182
|
debugLog(`[Browser] From: ${previousUrl}`);
|
|
@@ -188,6 +195,11 @@ export function createPartialUpdater(
|
|
|
188
195
|
targetCache && targetCache.length > 0
|
|
189
196
|
? targetCache
|
|
190
197
|
: getCurrentCachedSegments();
|
|
198
|
+
const cachedSegsSource =
|
|
199
|
+
targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
|
|
200
|
+
debugLog(
|
|
201
|
+
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
202
|
+
);
|
|
191
203
|
|
|
192
204
|
// Fetch partial payload (no abort signal - RSC doesn't support it well)
|
|
193
205
|
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
@@ -61,13 +61,23 @@ const inflightPromises = new Map<string, Promise<Response | null>>();
|
|
|
61
61
|
let generation = 0;
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* Build a
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
64
|
+
* Build a cache key for prefetched responses.
|
|
65
|
+
*
|
|
66
|
+
* By default the key includes the source page href so the same target
|
|
67
|
+
* prefetched from different pages gets separate entries (the server's
|
|
68
|
+
* diff response depends on the source page context).
|
|
69
|
+
*
|
|
70
|
+
* When `prefetchKey` is provided, the source portion is replaced with
|
|
71
|
+
* a `*` sentinel so all custom-keyed entries share one cache slot per
|
|
72
|
+
* target — enabling source-agnostic cache reuse.
|
|
68
73
|
*/
|
|
69
|
-
export function buildPrefetchKey(
|
|
70
|
-
|
|
74
|
+
export function buildPrefetchKey(
|
|
75
|
+
sourceHref: string,
|
|
76
|
+
targetUrl: URL,
|
|
77
|
+
prefetchKey?: string | ((from: string) => string),
|
|
78
|
+
): string {
|
|
79
|
+
const source = prefetchKey != null ? "*" : sourceHref;
|
|
80
|
+
return source + "\0" + targetUrl.pathname + targetUrl.search;
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
/**
|
|
@@ -23,6 +23,24 @@ import {
|
|
|
23
23
|
import { getRangoState } from "../rango-state.js";
|
|
24
24
|
import { enqueuePrefetch } from "./queue.js";
|
|
25
25
|
import { shouldPrefetch } from "./policy.js";
|
|
26
|
+
import { debugLog } from "../logging.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a URL resolves to the current page (same pathname + search).
|
|
30
|
+
* Used to prevent same-page prefetching with prefetchKey, which would
|
|
31
|
+
* produce a trivial diff that corrupts the wildcard cache.
|
|
32
|
+
*/
|
|
33
|
+
function isSamePage(url: string): boolean {
|
|
34
|
+
try {
|
|
35
|
+
const target = new URL(url, window.location.origin);
|
|
36
|
+
return (
|
|
37
|
+
target.pathname + target.search ===
|
|
38
|
+
window.location.pathname + window.location.search
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
26
44
|
|
|
27
45
|
/**
|
|
28
46
|
* Build an RSC partial URL for prefetching.
|
|
@@ -113,13 +131,32 @@ export function prefetchDirect(
|
|
|
113
131
|
segmentIds: string[],
|
|
114
132
|
version?: string,
|
|
115
133
|
routerId?: string,
|
|
134
|
+
prefetchKey?: string | ((from: string) => string),
|
|
116
135
|
): void {
|
|
117
136
|
if (!shouldPrefetch()) return;
|
|
118
137
|
|
|
119
138
|
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
120
139
|
if (!targetUrl) return;
|
|
121
|
-
|
|
122
|
-
|
|
140
|
+
// Skip same-page prefetch with prefetchKey — a same-page diff is trivial
|
|
141
|
+
// and would corrupt the wildcard cache entry for cross-page navigation.
|
|
142
|
+
if (prefetchKey != null && isSamePage(url)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
|
|
146
|
+
if (hasPrefetch(key)) {
|
|
147
|
+
debugLog("[prefetch] direct dedup (key already exists)", {
|
|
148
|
+
url,
|
|
149
|
+
key,
|
|
150
|
+
prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
debugLog("[prefetch] direct fetch", {
|
|
155
|
+
url,
|
|
156
|
+
key,
|
|
157
|
+
source: window.location.href,
|
|
158
|
+
prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
|
|
159
|
+
});
|
|
123
160
|
executePrefetchFetch(key, targetUrl.toString());
|
|
124
161
|
}
|
|
125
162
|
|
|
@@ -133,17 +170,36 @@ export function prefetchQueued(
|
|
|
133
170
|
segmentIds: string[],
|
|
134
171
|
version?: string,
|
|
135
172
|
routerId?: string,
|
|
173
|
+
prefetchKey?: string | ((from: string) => string),
|
|
136
174
|
): string {
|
|
137
175
|
if (!shouldPrefetch()) return "";
|
|
138
176
|
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
139
177
|
if (!targetUrl) return "";
|
|
140
|
-
|
|
141
|
-
|
|
178
|
+
// Skip same-page prefetch with prefetchKey — a same-page diff is trivial
|
|
179
|
+
// and would corrupt the wildcard cache entry for cross-page navigation.
|
|
180
|
+
if (prefetchKey != null && isSamePage(url)) {
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
|
|
184
|
+
if (hasPrefetch(key)) {
|
|
185
|
+
debugLog("[prefetch] queued dedup (key already exists)", {
|
|
186
|
+
url,
|
|
187
|
+
key,
|
|
188
|
+
prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
|
|
189
|
+
});
|
|
190
|
+
return key;
|
|
191
|
+
}
|
|
142
192
|
const fetchUrlStr = targetUrl.toString();
|
|
143
193
|
enqueuePrefetch(key, (signal) => {
|
|
144
194
|
// Re-check at execution time: a hover-triggered prefetchDirect may
|
|
145
195
|
// have started or completed this key while the item sat in the queue.
|
|
146
196
|
if (hasPrefetch(key)) return Promise.resolve();
|
|
197
|
+
// By execution time, the user may have navigated to the target page.
|
|
198
|
+
// A same-page prefetch produces a trivial diff that would overwrite
|
|
199
|
+
// the useful cross-page entry in the wildcard cache.
|
|
200
|
+
if (prefetchKey != null && isSamePage(url)) {
|
|
201
|
+
return Promise.resolve();
|
|
202
|
+
}
|
|
147
203
|
return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
|
|
148
204
|
});
|
|
149
205
|
return key;
|
|
@@ -97,6 +97,26 @@ export interface LinkProps extends Omit<
|
|
|
97
97
|
* @default "none"
|
|
98
98
|
*/
|
|
99
99
|
prefetch?: PrefetchStrategy;
|
|
100
|
+
/**
|
|
101
|
+
* Custom prefetch cache key for source-agnostic cache reuse.
|
|
102
|
+
* When set, prefetch responses are cached independently of the current
|
|
103
|
+
* page URL, so navigating to the same target from different source pages
|
|
104
|
+
* reuses the cached prefetch.
|
|
105
|
+
*
|
|
106
|
+
* - String: static group name (e.g., `"pages"`)
|
|
107
|
+
* - Function: receives current URL (`window.location.href`), returns a
|
|
108
|
+
* normalized source key
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```tsx
|
|
112
|
+
* // Static group — all "pages" links share one cache entry per target
|
|
113
|
+
* <Link to="/page/3" prefetch="hover" prefetchKey="pages" />
|
|
114
|
+
*
|
|
115
|
+
* // Normalize — strip trailing page number from source URL
|
|
116
|
+
* <Link to="/page/3" prefetch="hover" prefetchKey={(from) => from.replace(/\/\d+$/, '')} />
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
prefetchKey?: string | ((from: string) => string);
|
|
100
120
|
/**
|
|
101
121
|
* State to pass to history.pushState/replaceState.
|
|
102
122
|
* Accessible via useLocationState() hook.
|
|
@@ -184,6 +204,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
184
204
|
reloadDocument = false,
|
|
185
205
|
revalidate,
|
|
186
206
|
prefetch = "none",
|
|
207
|
+
prefetchKey,
|
|
187
208
|
state,
|
|
188
209
|
children,
|
|
189
210
|
onClick,
|
|
@@ -320,9 +341,10 @@ export const Link: ForwardRefExoticComponent<
|
|
|
320
341
|
segmentState.currentSegmentIds,
|
|
321
342
|
getAppVersion(),
|
|
322
343
|
ctx.store.getRouterId?.(),
|
|
344
|
+
prefetchKey,
|
|
323
345
|
);
|
|
324
346
|
}
|
|
325
|
-
}, [resolvedStrategy, resolvedTo, isExternal, ctx]);
|
|
347
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
326
348
|
|
|
327
349
|
// Viewport/render prefetch: waits for idle before starting,
|
|
328
350
|
// uses concurrency-limited queue to avoid flooding.
|
|
@@ -344,6 +366,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
344
366
|
segmentState.currentSegmentIds,
|
|
345
367
|
getAppVersion(),
|
|
346
368
|
ctx.store.getRouterId?.(),
|
|
369
|
+
prefetchKey,
|
|
347
370
|
);
|
|
348
371
|
};
|
|
349
372
|
|
|
@@ -383,7 +406,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
383
406
|
unobserveForPrefetch(observedElement);
|
|
384
407
|
}
|
|
385
408
|
};
|
|
386
|
-
}, [resolvedStrategy, resolvedTo, isExternal, ctx]);
|
|
409
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
387
410
|
|
|
388
411
|
return (
|
|
389
412
|
<a
|
|
@@ -9,64 +9,11 @@ import {
|
|
|
9
9
|
startTransition,
|
|
10
10
|
} from "react";
|
|
11
11
|
import type { Handle } from "../../handle.js";
|
|
12
|
-
import {
|
|
12
|
+
import { collectHandleData } from "../../handle.js";
|
|
13
13
|
import type { HandleData } from "../types.js";
|
|
14
14
|
import { NavigationStoreContext } from "./context.js";
|
|
15
15
|
import { shallowEqual } from "./shallow-equal.js";
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
* Resolve the collect function for a handle.
|
|
19
|
-
* Handle objects are plain { __brand, $$id } - collect is stored in the registry
|
|
20
|
-
* (populated when createHandle runs on the client).
|
|
21
|
-
*/
|
|
22
|
-
function resolveCollect<T, A>(handle: Handle<T, A>): (segments: T[][]) => A {
|
|
23
|
-
// Look up collect from the registry (populated when the handle module is imported).
|
|
24
|
-
const registered = getCollectFn(handle.$$id);
|
|
25
|
-
if (registered) {
|
|
26
|
-
return registered as (segments: T[][]) => A;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Fall back to default flat collect with a dev warning.
|
|
30
|
-
if (process.env.NODE_ENV !== "production") {
|
|
31
|
-
console.warn(
|
|
32
|
-
`[rsc-router] Handle "${handle.$$id}" was passed as a prop but its collect ` +
|
|
33
|
-
`function could not be resolved. Falling back to flat array. ` +
|
|
34
|
-
`Import the handle module in a client component to register its collect function.`,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
return ((segments: unknown[][]) => segments.flat()) as unknown as (
|
|
38
|
-
segments: T[][],
|
|
39
|
-
) => A;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Collect handle data from segments and transform to final value.
|
|
44
|
-
*/
|
|
45
|
-
function collectHandle<T, A>(
|
|
46
|
-
handle: Handle<T, A>,
|
|
47
|
-
data: HandleData,
|
|
48
|
-
segmentOrder: string[],
|
|
49
|
-
): A {
|
|
50
|
-
const collect = resolveCollect(handle);
|
|
51
|
-
const segmentData = data[handle.$$id];
|
|
52
|
-
|
|
53
|
-
if (!segmentData) {
|
|
54
|
-
return collect([]);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Build array of segment arrays in parent -> child order
|
|
58
|
-
const segmentArrays: T[][] = [];
|
|
59
|
-
for (const segmentId of segmentOrder) {
|
|
60
|
-
const entries = segmentData[segmentId];
|
|
61
|
-
if (entries && entries.length > 0) {
|
|
62
|
-
segmentArrays.push(entries as T[]);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Call collect once with all segment data
|
|
67
|
-
return collect(segmentArrays);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
17
|
/**
|
|
71
18
|
* Hook to access collected handle data.
|
|
72
19
|
*
|
|
@@ -99,13 +46,13 @@ export function useHandle<T, A, S>(
|
|
|
99
46
|
// Initial state from context event controller, or empty fallback without provider.
|
|
100
47
|
const [value, setValue] = useState<A | S>(() => {
|
|
101
48
|
if (!ctx) {
|
|
102
|
-
const collected =
|
|
49
|
+
const collected = collectHandleData(handle, {}, []);
|
|
103
50
|
return selector ? selector(collected) : collected;
|
|
104
51
|
}
|
|
105
52
|
|
|
106
53
|
// On client, use event controller state
|
|
107
54
|
const state = ctx.eventController.getHandleState();
|
|
108
|
-
const collected =
|
|
55
|
+
const collected = collectHandleData(handle, state.data, state.segmentOrder);
|
|
109
56
|
return selector ? selector(collected) : collected;
|
|
110
57
|
});
|
|
111
58
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
@@ -125,7 +72,7 @@ export function useHandle<T, A, S>(
|
|
|
125
72
|
// Sync current state for the (possibly new) handle so that switching
|
|
126
73
|
// handles on an idle page doesn't leave stale data from the old handle.
|
|
127
74
|
const currentHandleState = ctx.eventController.getHandleState();
|
|
128
|
-
const currentCollected =
|
|
75
|
+
const currentCollected = collectHandleData(
|
|
129
76
|
handle,
|
|
130
77
|
currentHandleState.data,
|
|
131
78
|
currentHandleState.segmentOrder,
|
|
@@ -142,7 +89,11 @@ export function useHandle<T, A, S>(
|
|
|
142
89
|
const state = ctx.eventController.getHandleState();
|
|
143
90
|
const isAction =
|
|
144
91
|
ctx.eventController.getState().inflightActions.length > 0;
|
|
145
|
-
const collected =
|
|
92
|
+
const collected = collectHandleData(
|
|
93
|
+
handle,
|
|
94
|
+
state.data,
|
|
95
|
+
state.segmentOrder,
|
|
96
|
+
);
|
|
146
97
|
const nextValue = selectorRef.current
|
|
147
98
|
? selectorRef.current(collected)
|
|
148
99
|
: collected;
|
|
@@ -356,19 +356,18 @@ export function handleNavigationEnd(options: {
|
|
|
356
356
|
scroll?: boolean;
|
|
357
357
|
isStreaming?: () => boolean;
|
|
358
358
|
}): void {
|
|
359
|
-
if (!initialized) {
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
359
|
const { restore = false, scroll = true, isStreaming } = options;
|
|
364
360
|
|
|
365
|
-
// Don't scroll if explicitly disabled
|
|
366
|
-
if (scroll === false) {
|
|
361
|
+
// Don't scroll if explicitly disabled or not in a browser
|
|
362
|
+
if (scroll === false || typeof window === "undefined") {
|
|
367
363
|
return;
|
|
368
364
|
}
|
|
369
365
|
|
|
370
|
-
//
|
|
371
|
-
|
|
366
|
+
// Save/restore requires initialization (sessionStorage, history state).
|
|
367
|
+
// But basic scroll-to-top and hash scrolling work without it — this
|
|
368
|
+
// matters during cross-app navigation where ScrollRestoration unmounts
|
|
369
|
+
// and remounts, creating a brief window where initialized is false.
|
|
370
|
+
if (restore && initialized) {
|
|
372
371
|
if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
|
|
373
372
|
return;
|
|
374
373
|
}
|
|
@@ -378,6 +377,9 @@ export function handleNavigationEnd(options: {
|
|
|
378
377
|
// Defer hash and scroll-to-top to after React paints the new content,
|
|
379
378
|
// so the user doesn't see the current page jump before the new route appears.
|
|
380
379
|
deferToNextPaint(() => {
|
|
380
|
+
// Re-check: the deferred callback may fire after environment teardown
|
|
381
|
+
if (typeof window === "undefined") return;
|
|
382
|
+
|
|
381
383
|
// Try hash scrolling first
|
|
382
384
|
if (scrollToHash()) {
|
|
383
385
|
return;
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "./merge-segment-loaders.js";
|
|
7
7
|
import { assertSegmentStructure } from "./segment-structure-assert.js";
|
|
8
8
|
import { splitInterceptSegments } from "./intercept-utils.js";
|
|
9
|
+
import { debugLog } from "./logging.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Determines the merging behavior for segment reconciliation.
|
|
@@ -85,14 +86,29 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
85
86
|
const cachedSegments = new Map<string, ResolvedSegment>();
|
|
86
87
|
input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
|
|
87
88
|
|
|
89
|
+
const diffSet = new Set(diff);
|
|
90
|
+
debugLog(
|
|
91
|
+
`[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
|
|
92
|
+
);
|
|
93
|
+
debugLog(
|
|
94
|
+
`[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
|
|
95
|
+
);
|
|
96
|
+
debugLog(
|
|
97
|
+
`[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
|
|
98
|
+
);
|
|
99
|
+
|
|
88
100
|
const segments = matched
|
|
89
101
|
.map((segId: string) => {
|
|
90
102
|
const fromServer = serverSegments.get(segId);
|
|
91
103
|
const fromCache = cachedSegments.get(segId);
|
|
92
104
|
|
|
93
105
|
if (fromServer) {
|
|
106
|
+
const inDiff = diffSet.has(segId);
|
|
94
107
|
// Merge partial loader data when server returns fewer loaders than cached
|
|
95
108
|
if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
|
|
109
|
+
debugLog(
|
|
110
|
+
`[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
|
|
111
|
+
);
|
|
96
112
|
return mergeSegmentLoaders(fromServer, fromCache);
|
|
97
113
|
}
|
|
98
114
|
|
|
@@ -143,8 +159,14 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
143
159
|
// above fails to preserve a value it should have.
|
|
144
160
|
assertSegmentStructure(fromCache, merged, context);
|
|
145
161
|
|
|
162
|
+
debugLog(
|
|
163
|
+
`[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
|
|
164
|
+
);
|
|
146
165
|
return merged;
|
|
147
166
|
}
|
|
167
|
+
debugLog(
|
|
168
|
+
`[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
|
|
169
|
+
);
|
|
148
170
|
return fromServer;
|
|
149
171
|
}
|
|
150
172
|
|
|
@@ -158,20 +180,20 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
158
180
|
return fromCache;
|
|
159
181
|
}
|
|
160
182
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
183
|
+
debugLog(
|
|
184
|
+
`[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Return the cached segment as-is, regardless of actor. We used to clear
|
|
188
|
+
// truthy `loading` here to prevent a stale Suspense fallback from
|
|
189
|
+
// committing against cached content, but that swapped the render tree
|
|
190
|
+
// from the LoaderBoundary branch to the plain OutletProvider branch
|
|
191
|
+
// inside renderSegments, causing React to unmount the entire chain
|
|
192
|
+
// (LoaderBoundary > Suspense > LoaderResolver > RouteContentWrapper >
|
|
193
|
+
// Suspender) every time the user opened an intercept or navigated back
|
|
194
|
+
// to a cached page. The flicker is now prevented by renderSegments'
|
|
195
|
+
// promise memoization keeping React's use() in "known fulfilled" state,
|
|
196
|
+
// so preserving `loading` keeps the element tree stable.
|
|
175
197
|
return fromCache;
|
|
176
198
|
})
|
|
177
199
|
.filter(Boolean) as ResolvedSegment[];
|
|
@@ -45,7 +45,7 @@ export interface GeneratedManifest {
|
|
|
45
45
|
routeTrailingSlash?: Record<string, string>;
|
|
46
46
|
/** Route names using Prerender (for dev-mode Node.js delegation) */
|
|
47
47
|
prerenderRoutes?: string[];
|
|
48
|
-
/** Route names with
|
|
48
|
+
/** Route names wrapped with Passthrough() (live handler for runtime fallback) */
|
|
49
49
|
passthroughRoutes?: string[];
|
|
50
50
|
/** Route name → response type for non-RSC routes */
|
|
51
51
|
responseTypeRoutes?: Record<string, string>;
|
|
@@ -150,10 +150,7 @@ function buildPrefixTreeNode(
|
|
|
150
150
|
if (prerenderDefs && entry.prerenderDef) {
|
|
151
151
|
prerenderDefs[name] = entry.prerenderDef;
|
|
152
152
|
}
|
|
153
|
-
if (
|
|
154
|
-
passthroughRoutes &&
|
|
155
|
-
entry.prerenderDef?.options?.passthrough === true
|
|
156
|
-
) {
|
|
153
|
+
if (passthroughRoutes && entry.isPassthrough === true) {
|
|
157
154
|
passthroughRoutes.push(name);
|
|
158
155
|
}
|
|
159
156
|
}
|
|
@@ -350,7 +347,7 @@ export function generateManifestFull<TEnv>(
|
|
|
350
347
|
if (entry.prerenderDef) {
|
|
351
348
|
prerenderDefs[name] = entry.prerenderDef;
|
|
352
349
|
}
|
|
353
|
-
if (entry.
|
|
350
|
+
if (entry.isPassthrough === true) {
|
|
354
351
|
passthroughRoutes.push(name);
|
|
355
352
|
}
|
|
356
353
|
}
|