@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.dcbea258
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/dist/vite/index.js +16 -8
- 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/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +24 -0
- package/src/browser/navigation-bridge.ts +19 -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/segment-reconciler.ts +36 -14
- package/src/build/route-trie.ts +50 -24
- package/src/client.tsx +82 -174
- package/src/index.ts +37 -9
- package/src/reverse.ts +4 -1
- package/src/route-definition/dsl-helpers.ts +159 -20
- package/src/route-definition/helpers-types.ts +57 -13
- package/src/route-types.ts +7 -0
- package/src/router/handler-context.ts +4 -1
- package/src/router/lazy-includes.ts +5 -5
- package/src/router/manifest.ts +12 -7
- 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 +26 -3
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +0 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +30 -4
- package/src/vite/utils/prerender-utils.ts +20 -6
package/skills/parallel/SKILL.md
CHANGED
|
@@ -206,6 +206,65 @@ parallel(
|
|
|
206
206
|
)
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
+
## Composable Slots via `handler.use`
|
|
210
|
+
|
|
211
|
+
Slot handlers can carry their own loader, loading, error/notFound boundaries, revalidation, and transition defaults via `.use`. The mount site then declares **just the slot names** — no per-call data wiring.
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
const CartSummary: Handler = async (ctx) => {
|
|
215
|
+
const cart = await ctx.use(CartLoader);
|
|
216
|
+
return <CartSummaryView cart={cart} />;
|
|
217
|
+
};
|
|
218
|
+
CartSummary.use = () => [
|
|
219
|
+
loader(CartLoader),
|
|
220
|
+
loading(<CartSkeleton />),
|
|
221
|
+
revalidate(revalidateCartData),
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
// Same slot, no copy-pasted plumbing across layouts.
|
|
225
|
+
layout(<DashboardLayout />, () => [
|
|
226
|
+
parallel({ "@cart": CartSummary }),
|
|
227
|
+
path("/dashboard", DashboardIndex, { name: "dashboard.index" }),
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
layout(<AccountLayout />, () => [
|
|
231
|
+
parallel({ "@cart": CartSummary }),
|
|
232
|
+
path("/account", AccountIndex, { name: "account.index" }),
|
|
233
|
+
]);
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
A slot's `loading()` (whether from `handler.use` or explicit) makes that slot an independent streaming unit, exactly as in the **Streaming Behavior** section above.
|
|
237
|
+
|
|
238
|
+
The `parallel` mount site has the narrowest allow-list for `handler.use` items — slots cannot bring their own middleware or layout, only `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, and `transition`. See [skills/handler-use](../handler-use/SKILL.md) for the full table and merge rules.
|
|
239
|
+
|
|
240
|
+
### Two scopes for explicit `use`: shared (broadcast) and slot-local
|
|
241
|
+
|
|
242
|
+
`parallel({...slots}, () => [...use])` runs the shared `use()` callback **once per slot** ([dsl-helpers.ts](../../src/route-definition/dsl-helpers.ts)) — items in that callback land on every slot's entry. That's the right behavior for the items the parallel allow-list permits and that accumulate (`loader`, `revalidate`, `errorBoundary`, `notFoundBoundary`, `transition`). (Slots cannot bring `middleware` or `layout` — see the allowed-types note above.)
|
|
243
|
+
|
|
244
|
+
For single-assignment items like `loading()`, broadcasting overwrites every slot's `handler.use` default. Pass a **slot descriptor** `{ handler, use }` instead — items in the descriptor's `use` apply only to that slot:
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
// @cart gets a custom skeleton; @notifs keeps its handler.use default.
|
|
248
|
+
parallel({
|
|
249
|
+
"@cart": {
|
|
250
|
+
handler: Cart,
|
|
251
|
+
use: () => [loading(<CustomCartSkeleton />)],
|
|
252
|
+
},
|
|
253
|
+
"@notifs": Notifs,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Opt one slot out of streaming while siblings still stream the broadcast.
|
|
257
|
+
parallel(
|
|
258
|
+
{
|
|
259
|
+
"@cart": { handler: Cart, use: () => [loading(false)] },
|
|
260
|
+
"@notifs": Notifs,
|
|
261
|
+
},
|
|
262
|
+
() => [loading(<BroadcastSkeleton />)],
|
|
263
|
+
);
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Per-slot merge order is **handler.use → shared use → slot-local use**. Slot-local is the narrowest scope, so it wins for last-write-wins items. See [skills/handler-use § `loading()` is a single-assignment item — scope it correctly](../handler-use/SKILL.md#loading-is-a-single-assignment-item--scope-it-correctly) for the full reasoning.
|
|
267
|
+
|
|
209
268
|
## Slot Override Semantics
|
|
210
269
|
|
|
211
270
|
When multiple `parallel()` calls define the same slot name, **the last
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -10,28 +10,30 @@ Django-inspired RSC router with composable URL patterns, type-safe href, and ser
|
|
|
10
10
|
|
|
11
11
|
## Skills
|
|
12
12
|
|
|
13
|
-
| Skill
|
|
14
|
-
|
|
|
15
|
-
| `/router-setup`
|
|
16
|
-
| `/route`
|
|
17
|
-
| `/layout`
|
|
18
|
-
| `/loader`
|
|
19
|
-
| `/middleware`
|
|
20
|
-
| `/intercept`
|
|
21
|
-
| `/parallel`
|
|
22
|
-
| `/caching`
|
|
23
|
-
| `/use-cache`
|
|
24
|
-
| `/cache-guide`
|
|
25
|
-
| `/document-cache`
|
|
26
|
-
| `/theme`
|
|
27
|
-
| `/links`
|
|
28
|
-
| `/hooks`
|
|
29
|
-
| `/typesafety`
|
|
30
|
-
| `/host-router`
|
|
31
|
-
| `/tailwind`
|
|
32
|
-
| `/response-routes`
|
|
33
|
-
| `/mime-routes`
|
|
34
|
-
| `/fonts`
|
|
13
|
+
| Skill | Description |
|
|
14
|
+
| ----------------------- | -------------------------------------------------------------------------- |
|
|
15
|
+
| `/router-setup` | Create and configure the RSC router |
|
|
16
|
+
| `/route` | Define routes with `urls()` and `path()` |
|
|
17
|
+
| `/layout` | Layouts that wrap child routes |
|
|
18
|
+
| `/loader` | Data loaders with `createLoader()` |
|
|
19
|
+
| `/middleware` | Request processing and authentication |
|
|
20
|
+
| `/intercept` | Modal/slide-over patterns for soft navigation |
|
|
21
|
+
| `/parallel` | Multi-column layouts and sidebars |
|
|
22
|
+
| `/caching` | Segment caching with memory or KV stores |
|
|
23
|
+
| `/use-cache` | Function-level caching with `"use cache"` directive |
|
|
24
|
+
| `/cache-guide` | When to use `cache()` vs `"use cache"` — differences and decision guide |
|
|
25
|
+
| `/document-cache` | Edge caching with Cache-Control headers |
|
|
26
|
+
| `/theme` | Light/dark mode with FOUC prevention |
|
|
27
|
+
| `/links` | URL generation: ctx.reverse, href, useHref, useMount, scopedReverse |
|
|
28
|
+
| `/hooks` | Client-side React hooks |
|
|
29
|
+
| `/typesafety` | Type-safe routes, params, href, and environment |
|
|
30
|
+
| `/host-router` | Multi-app host routing with domain/subdomain patterns |
|
|
31
|
+
| `/tailwind` | Set up Tailwind CSS v4 with `?url` imports |
|
|
32
|
+
| `/response-routes` | JSON/text/HTML/XML/stream endpoints with `path.json()`, `path.text()` |
|
|
33
|
+
| `/mime-routes` | Content negotiation — same URL, different response types via Accept header |
|
|
34
|
+
| `/fonts` | Load web fonts with preload hints |
|
|
35
|
+
| `/migrate-nextjs` | Migrate a Next.js App Router project to Rango |
|
|
36
|
+
| `/migrate-react-router` | Migrate a React Router / Remix project to Rango |
|
|
35
37
|
|
|
36
38
|
## Quick Start
|
|
37
39
|
|
package/skills/route/SKILL.md
CHANGED
|
@@ -383,6 +383,30 @@ urls(({ path, layout }) => [
|
|
|
383
383
|
])
|
|
384
384
|
```
|
|
385
385
|
|
|
386
|
+
## Handler-attached `.use`
|
|
387
|
+
|
|
388
|
+
Page handlers can carry their own loader, middleware, error boundaries, parallels, and other defaults via a `.use` callback — so the page is self-contained and reusable across mount sites without re-wiring the same items.
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
const ProductPage: Handler<"/product/:slug"> = async (ctx) => {
|
|
392
|
+
const product = await ctx.use(ProductLoader);
|
|
393
|
+
return <ProductView product={product} />;
|
|
394
|
+
};
|
|
395
|
+
ProductPage.use = () => [
|
|
396
|
+
loader(ProductLoader),
|
|
397
|
+
loading(<ProductSkeleton />),
|
|
398
|
+
middleware(async (ctx, next) => {
|
|
399
|
+
await next();
|
|
400
|
+
ctx.header("Cache-Control", "private, max-age=60");
|
|
401
|
+
}),
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
// Mount site has no per-page wiring — defaults travel with the handler.
|
|
405
|
+
path("/product/:slug", ProductPage, { name: "product" });
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Explicit `use()` at the mount site merges with `handler.use` (handler defaults first, explicit second). See [skills/handler-use](../handler-use/SKILL.md) for the merge order, allowed item types per mount site, and override semantics.
|
|
409
|
+
|
|
386
410
|
## Complete Example
|
|
387
411
|
|
|
388
412
|
```typescript
|
|
@@ -271,10 +271,14 @@ export function createNavigationBridge(
|
|
|
271
271
|
!cached?.stale &&
|
|
272
272
|
!options?._skipCache;
|
|
273
273
|
|
|
274
|
+
// Forward navigations always await fetchPartialUpdate before rendering,
|
|
275
|
+
// so useNavigation should always report "loading". skipLoadingState is
|
|
276
|
+
// only used for popstate background revalidation (line ~526) where
|
|
277
|
+
// cached content renders instantly without a network wait.
|
|
274
278
|
const tx = createNavigationTransaction(store, eventController, url, {
|
|
275
279
|
...options,
|
|
276
280
|
state: resolvedState,
|
|
277
|
-
skipLoadingState:
|
|
281
|
+
skipLoadingState: false,
|
|
278
282
|
});
|
|
279
283
|
|
|
280
284
|
// REVALIDATE: Fetch fresh data from server
|
|
@@ -414,6 +418,15 @@ export function createNavigationBridge(
|
|
|
414
418
|
eventController.abortAllActions();
|
|
415
419
|
}
|
|
416
420
|
|
|
421
|
+
// Popstate that exits an intercept to a non-intercept destination. The
|
|
422
|
+
// fallback fetch path below needs `leave-intercept` mode so it filters
|
|
423
|
+
// the cached @modal segment from the request and forces a re-render —
|
|
424
|
+
// otherwise a cache-miss popstate whose server response has an empty
|
|
425
|
+
// diff hits the "no changes" branch in partial-update and the modal
|
|
426
|
+
// stays on screen.
|
|
427
|
+
const isLeavingIntercept =
|
|
428
|
+
!isIntercept && currentInterceptSource !== null;
|
|
429
|
+
|
|
417
430
|
// Compute history key from URL (with intercept suffix if applicable)
|
|
418
431
|
const historyKey = generateHistoryKey(url, { intercept: isIntercept });
|
|
419
432
|
|
|
@@ -564,7 +577,11 @@ export function createNavigationBridge(
|
|
|
564
577
|
intercept: isIntercept,
|
|
565
578
|
interceptSourceUrl,
|
|
566
579
|
}),
|
|
567
|
-
isIntercept
|
|
580
|
+
isIntercept
|
|
581
|
+
? { type: "navigate", interceptSourceUrl }
|
|
582
|
+
: isLeavingIntercept
|
|
583
|
+
? { type: "leave-intercept" }
|
|
584
|
+
: undefined,
|
|
568
585
|
);
|
|
569
586
|
// Restore scroll position after fetch completes
|
|
570
587
|
handleNavigationEnd({ restore: true, isStreaming });
|
|
@@ -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
|
|
@@ -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[];
|