@rangojs/router 0.0.0-experimental.992564d9 → 0.0.0-experimental.9c76129b
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 +3 -3
- package/package.json +3 -3
- package/src/browser/navigation-bridge.ts +7 -1
- package/src/browser/navigation-client.ts +31 -9
- package/src/browser/partial-update.ts +5 -0
- package/src/browser/prefetch/fetch.ts +56 -2
- package/src/browser/segment-reconciler.ts +26 -0
- package/src/router/match-middleware/cache-lookup.ts +2 -1
package/dist/vite/index.js
CHANGED
|
@@ -1864,7 +1864,7 @@ import { resolve } from "node:path";
|
|
|
1864
1864
|
// package.json
|
|
1865
1865
|
var package_default = {
|
|
1866
1866
|
name: "@rangojs/router",
|
|
1867
|
-
version: "0.0.0-experimental.
|
|
1867
|
+
version: "0.0.0-experimental.9c76129b",
|
|
1868
1868
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
1869
1869
|
keywords: [
|
|
1870
1870
|
"react",
|
|
@@ -2006,7 +2006,7 @@ var package_default = {
|
|
|
2006
2006
|
"test:unit:watch": "vitest"
|
|
2007
2007
|
},
|
|
2008
2008
|
dependencies: {
|
|
2009
|
-
"@vitejs/plugin-rsc": "^0.5.
|
|
2009
|
+
"@vitejs/plugin-rsc": "^0.5.23",
|
|
2010
2010
|
"magic-string": "^0.30.17",
|
|
2011
2011
|
picomatch: "^4.0.3",
|
|
2012
2012
|
"rsc-html-stream": "^0.0.7"
|
|
@@ -2026,7 +2026,7 @@ var package_default = {
|
|
|
2026
2026
|
},
|
|
2027
2027
|
peerDependencies: {
|
|
2028
2028
|
"@cloudflare/vite-plugin": "^1.25.0",
|
|
2029
|
-
"@vitejs/plugin-rsc": "^0.5.
|
|
2029
|
+
"@vitejs/plugin-rsc": "^0.5.23",
|
|
2030
2030
|
react: "^18.0.0 || ^19.0.0",
|
|
2031
2031
|
vite: "^7.3.0"
|
|
2032
2032
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.0.0-experimental.
|
|
3
|
+
"version": "0.0.0-experimental.9c76129b",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
"test:unit:watch": "vitest"
|
|
143
143
|
},
|
|
144
144
|
"dependencies": {
|
|
145
|
-
"@vitejs/plugin-rsc": "^0.5.
|
|
145
|
+
"@vitejs/plugin-rsc": "^0.5.23",
|
|
146
146
|
"magic-string": "^0.30.17",
|
|
147
147
|
"picomatch": "^4.0.3",
|
|
148
148
|
"rsc-html-stream": "^0.0.7"
|
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
},
|
|
163
163
|
"peerDependencies": {
|
|
164
164
|
"@cloudflare/vite-plugin": "^1.25.0",
|
|
165
|
-
"@vitejs/plugin-rsc": "^0.5.
|
|
165
|
+
"@vitejs/plugin-rsc": "^0.5.23",
|
|
166
166
|
"react": "^18.0.0 || ^19.0.0",
|
|
167
167
|
"vite": "^7.3.0"
|
|
168
168
|
},
|
|
@@ -261,18 +261,24 @@ export function createNavigationBridge(
|
|
|
261
261
|
// 2. routes that CAN be intercepted - we don't know if this navigation will intercept
|
|
262
262
|
// 3. when leaving intercept - we need fresh non-intercept segments from server
|
|
263
263
|
// 4. redirect-with-state - force re-render so hooks read fresh state
|
|
264
|
+
// 5. stale cache - server action invalidated it, need fresh data with loading state
|
|
264
265
|
const hasUsableCache =
|
|
265
266
|
cachedSegments &&
|
|
266
267
|
cachedSegments.length > 0 &&
|
|
267
268
|
!isInterceptOnlyCache(cachedSegments) &&
|
|
268
269
|
!hasInterceptCache &&
|
|
269
270
|
!isLeavingIntercept &&
|
|
271
|
+
!cached?.stale &&
|
|
270
272
|
!options?._skipCache;
|
|
271
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.
|
|
272
278
|
const tx = createNavigationTransaction(store, eventController, url, {
|
|
273
279
|
...options,
|
|
274
280
|
state: resolvedState,
|
|
275
|
-
skipLoadingState:
|
|
281
|
+
skipLoadingState: false,
|
|
276
282
|
});
|
|
277
283
|
|
|
278
284
|
// REVALIDATE: Fetch fresh data from server
|
|
@@ -104,13 +104,29 @@ export function createNavigationClient(
|
|
|
104
104
|
// Wildcard key matches prefetch entries stored with a custom prefetchKey
|
|
105
105
|
// (Link's prefetchKey prop stores under "*" instead of the source URL).
|
|
106
106
|
const wildcardKey = "*\0" + fetchUrl.pathname + fetchUrl.search;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|
|
114
130
|
// Track when the stream completes
|
|
115
131
|
let resolveStreamComplete: () => void;
|
|
116
132
|
const streamComplete = new Promise<void>((resolve) => {
|
|
@@ -203,7 +219,10 @@ export function createNavigationClient(
|
|
|
203
219
|
|
|
204
220
|
if (cachedResponse) {
|
|
205
221
|
if (tx) {
|
|
206
|
-
browserDebugLog(tx, "prefetch cache hit", {
|
|
222
|
+
browserDebugLog(tx, "prefetch cache hit", {
|
|
223
|
+
key: hitKey,
|
|
224
|
+
wildcard: hitKey === wildcardKey,
|
|
225
|
+
});
|
|
207
226
|
}
|
|
208
227
|
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
209
228
|
const validated = validateRscHeaders(response, "prefetch cache");
|
|
@@ -220,7 +239,10 @@ export function createNavigationClient(
|
|
|
220
239
|
});
|
|
221
240
|
} else if (inflightResponsePromise) {
|
|
222
241
|
if (tx) {
|
|
223
|
-
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
242
|
+
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
243
|
+
key: hitKey,
|
|
244
|
+
wildcard: hitKey === wildcardKey,
|
|
245
|
+
});
|
|
224
246
|
}
|
|
225
247
|
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
226
248
|
if (!response) {
|
|
@@ -188,6 +188,11 @@ export function createPartialUpdater(
|
|
|
188
188
|
targetCache && targetCache.length > 0
|
|
189
189
|
? targetCache
|
|
190
190
|
: getCurrentCachedSegments();
|
|
191
|
+
const cachedSegsSource =
|
|
192
|
+
targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
|
|
193
|
+
debugLog(
|
|
194
|
+
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
195
|
+
);
|
|
191
196
|
|
|
192
197
|
// Fetch partial payload (no abort signal - RSC doesn't support it well)
|
|
193
198
|
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
@@ -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.
|
|
@@ -119,8 +137,26 @@ export function prefetchDirect(
|
|
|
119
137
|
|
|
120
138
|
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
121
139
|
if (!targetUrl) return;
|
|
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
|
+
}
|
|
122
145
|
const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
|
|
123
|
-
if (hasPrefetch(key))
|
|
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
|
+
});
|
|
124
160
|
executePrefetchFetch(key, targetUrl.toString());
|
|
125
161
|
}
|
|
126
162
|
|
|
@@ -139,13 +175,31 @@ export function prefetchQueued(
|
|
|
139
175
|
if (!shouldPrefetch()) return "";
|
|
140
176
|
const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
|
|
141
177
|
if (!targetUrl) return "";
|
|
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
|
+
}
|
|
142
183
|
const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
|
|
143
|
-
if (hasPrefetch(key))
|
|
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
|
+
}
|
|
144
192
|
const fetchUrlStr = targetUrl.toString();
|
|
145
193
|
enqueuePrefetch(key, (signal) => {
|
|
146
194
|
// Re-check at execution time: a hover-triggered prefetchDirect may
|
|
147
195
|
// have started or completed this key while the item sat in the queue.
|
|
148
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
|
+
}
|
|
149
203
|
return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
|
|
150
204
|
});
|
|
151
205
|
return key;
|
|
@@ -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,6 +180,10 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
158
180
|
return fromCache;
|
|
159
181
|
}
|
|
160
182
|
|
|
183
|
+
debugLog(
|
|
184
|
+
`[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
|
|
185
|
+
);
|
|
186
|
+
|
|
161
187
|
// For non-action actors: cached segments the server decided not to re-render.
|
|
162
188
|
// - Preserve loading=false (suppressed boundary) to maintain tree structure
|
|
163
189
|
// - Preserve parallel segment loading so renderSegments can reconstruct
|
|
@@ -251,6 +251,7 @@ async function* yieldFromStore<TEnv>(
|
|
|
251
251
|
ctx.url,
|
|
252
252
|
ctx.routeKey,
|
|
253
253
|
ctx.actionContext,
|
|
254
|
+
ctx.stale || undefined,
|
|
254
255
|
),
|
|
255
256
|
);
|
|
256
257
|
state.matchedIds = [
|
|
@@ -598,7 +599,7 @@ export function withCacheLookup<TEnv>(
|
|
|
598
599
|
routeKey: ctx.routeKey,
|
|
599
600
|
context: ctx.handlerContext,
|
|
600
601
|
actionContext: ctx.actionContext,
|
|
601
|
-
stale: cacheResult.shouldRevalidate || undefined,
|
|
602
|
+
stale: cacheResult.shouldRevalidate || ctx.stale || undefined,
|
|
602
603
|
traceSource: "cache-hit",
|
|
603
604
|
});
|
|
604
605
|
|