@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.
@@ -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.992564d9",
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.19",
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.14",
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.992564d9",
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.19",
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.14",
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: hasUsableCache,
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
- const cachedResponse = canUsePrefetch
108
- ? (consumePrefetch(cacheKey) ?? consumePrefetch(wildcardKey))
109
- : null;
110
- const inflightResponsePromise = canUsePrefetch
111
- ? (consumeInflightPrefetch(cacheKey) ??
112
- consumeInflightPrefetch(wildcardKey))
113
- : null;
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", { key: cacheKey });
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", { key: cacheKey });
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)) return;
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)) return 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