@rangojs/router 0.0.0-experimental.cb54cbba → 0.0.0-experimental.ea6d5eec
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/AGENTS.md +4 -0
- package/dist/bin/rango.js +8 -3
- package/dist/vite/index.js +136 -197
- package/package.json +15 -14
- package/skills/caching/SKILL.md +37 -4
- package/src/browser/navigation-bridge.ts +1 -3
- package/src/browser/navigation-client.ts +77 -24
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +39 -9
- package/src/browser/prefetch/cache.ts +54 -2
- package/src/browser/prefetch/fetch.ts +22 -12
- package/src/browser/prefetch/queue.ts +53 -13
- package/src/browser/react/Link.tsx +9 -1
- package/src/browser/react/NavigationProvider.tsx +27 -0
- package/src/browser/rsc-router.tsx +90 -57
- package/src/browser/scroll-restoration.ts +31 -34
- package/src/browser/types.ts +9 -0
- package/src/build/route-types/router-processing.ts +12 -2
- package/src/cache/cache-scope.ts +2 -2
- package/src/cache/cf/cf-cache-store.ts +453 -11
- package/src/cache/cf/index.ts +5 -1
- package/src/cache/index.ts +1 -0
- package/src/route-definition/redirect.ts +2 -2
- package/src/route-map-builder.ts +7 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/intercept-resolution.ts +2 -0
- package/src/router/lazy-includes.ts +2 -0
- package/src/router/logging.ts +4 -1
- package/src/router/manifest.ts +3 -1
- package/src/router/match-middleware/segment-resolution.ts +1 -0
- package/src/router/middleware.ts +2 -1
- package/src/router/router-context.ts +5 -1
- package/src/router/segment-resolution/revalidation.ts +4 -1
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router.ts +4 -0
- package/src/server/request-context.ts +10 -4
- package/src/types/route-entry.ts +7 -0
- package/src/vite/discovery/state.ts +0 -2
- package/src/vite/plugin-types.ts +0 -83
- package/src/vite/plugins/expose-action-id.ts +1 -3
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +144 -209
- package/src/vite/router-discovery.ts +0 -8
|
@@ -5,12 +5,22 @@
|
|
|
5
5
|
* Hover prefetches bypass this queue — they fire directly for immediate response
|
|
6
6
|
* to user intent.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Draining is deferred to the next animation frame so prefetch network activity
|
|
9
|
+
* never blocks paint. This applies to both the initial batch and subsequent
|
|
10
|
+
* batches — every drain cycle yields to the browser first.
|
|
11
|
+
*
|
|
12
|
+
* When a navigation starts, queued prefetches are cancelled but executing ones
|
|
13
|
+
* are left running. Navigation can reuse their in-flight responses via the
|
|
14
|
+
* prefetch cache's inflight promise map, avoiding duplicate requests.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
const MAX_CONCURRENT = 2;
|
|
13
18
|
|
|
19
|
+
const deferToNextPaint: (fn: () => void) => void =
|
|
20
|
+
typeof requestAnimationFrame === "function"
|
|
21
|
+
? requestAnimationFrame
|
|
22
|
+
: (fn) => setTimeout(fn, 0);
|
|
23
|
+
|
|
14
24
|
let active = 0;
|
|
15
25
|
const queue: Array<{
|
|
16
26
|
key: string;
|
|
@@ -19,6 +29,7 @@ const queue: Array<{
|
|
|
19
29
|
const queued = new Set<string>();
|
|
20
30
|
const executing = new Set<string>();
|
|
21
31
|
let abortController: AbortController | null = null;
|
|
32
|
+
let drainScheduled = false;
|
|
22
33
|
|
|
23
34
|
function startExecution(
|
|
24
35
|
key: string,
|
|
@@ -34,6 +45,21 @@ function startExecution(
|
|
|
34
45
|
if (executing.delete(key)) {
|
|
35
46
|
active--;
|
|
36
47
|
}
|
|
48
|
+
scheduleDrain();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Schedule a drain on the next animation frame.
|
|
54
|
+
* Coalesces multiple drain requests into a single rAF callback so
|
|
55
|
+
* batch completion doesn't schedule redundant frames.
|
|
56
|
+
*/
|
|
57
|
+
function scheduleDrain(): void {
|
|
58
|
+
if (drainScheduled) return;
|
|
59
|
+
if (active >= MAX_CONCURRENT || queue.length === 0) return;
|
|
60
|
+
drainScheduled = true;
|
|
61
|
+
deferToNextPaint(() => {
|
|
62
|
+
drainScheduled = false;
|
|
37
63
|
drain();
|
|
38
64
|
});
|
|
39
65
|
}
|
|
@@ -48,8 +74,8 @@ function drain(): void {
|
|
|
48
74
|
|
|
49
75
|
/**
|
|
50
76
|
* Enqueue a prefetch for concurrency-limited execution.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
77
|
+
* Execution is always deferred to the next animation frame to avoid
|
|
78
|
+
* blocking paint, even when below the concurrency limit.
|
|
53
79
|
* Deduplicates by key — items already queued or executing are skipped.
|
|
54
80
|
*
|
|
55
81
|
* The executor receives an AbortSignal that is aborted when
|
|
@@ -61,20 +87,33 @@ export function enqueuePrefetch(
|
|
|
61
87
|
): void {
|
|
62
88
|
if (queued.has(key) || executing.has(key)) return;
|
|
63
89
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
queued.add(key);
|
|
68
|
-
queue.push({ key, execute });
|
|
69
|
-
}
|
|
90
|
+
queued.add(key);
|
|
91
|
+
queue.push({ key, execute });
|
|
92
|
+
scheduleDrain();
|
|
70
93
|
}
|
|
71
94
|
|
|
72
95
|
/**
|
|
73
|
-
* Cancel
|
|
74
|
-
*
|
|
75
|
-
*
|
|
96
|
+
* Cancel queued prefetches. Executing prefetches are left running so
|
|
97
|
+
* navigation can reuse their in-flight responses (checked via
|
|
98
|
+
* consumeInflightPrefetch in the prefetch cache). With MAX_CONCURRENT=2
|
|
99
|
+
* and priority: "low", in-flight prefetches don't meaningfully compete
|
|
100
|
+
* with navigation fetches under HTTP/2 multiplexing.
|
|
101
|
+
*
|
|
102
|
+
* Called when a navigation starts via the NavigationProvider's
|
|
103
|
+
* event controller subscription.
|
|
76
104
|
*/
|
|
77
105
|
export function cancelAllPrefetches(): void {
|
|
106
|
+
queue.length = 0;
|
|
107
|
+
queued.clear();
|
|
108
|
+
drainScheduled = false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Hard-cancel everything including in-flight prefetches.
|
|
113
|
+
* Used by clearPrefetchCache (server action invalidation) where
|
|
114
|
+
* in-flight responses would be stale.
|
|
115
|
+
*/
|
|
116
|
+
export function abortAllPrefetches(): void {
|
|
78
117
|
abortController?.abort();
|
|
79
118
|
abortController = null;
|
|
80
119
|
|
|
@@ -85,4 +124,5 @@ export function cancelAllPrefetches(): void {
|
|
|
85
124
|
// so active settles at 0 without underflow.
|
|
86
125
|
executing.clear();
|
|
87
126
|
active = 0;
|
|
127
|
+
drainScheduled = false;
|
|
88
128
|
}
|
|
@@ -279,7 +279,15 @@ export const Link: ForwardRefExoticComponent<
|
|
|
279
279
|
);
|
|
280
280
|
|
|
281
281
|
const handleMouseEnter = useCallback(() => {
|
|
282
|
-
if (
|
|
282
|
+
if (
|
|
283
|
+
(resolvedStrategy === "hover" || resolvedStrategy === "viewport") &&
|
|
284
|
+
!isExternal &&
|
|
285
|
+
ctx?.store
|
|
286
|
+
) {
|
|
287
|
+
// For "hover", this is the primary prefetch trigger.
|
|
288
|
+
// For "viewport", this upgrades/prioritizes a potentially queued
|
|
289
|
+
// prefetch — prefetchDirect bypasses the queue, and hasPrefetch
|
|
290
|
+
// deduplicates if the viewport prefetch already completed.
|
|
283
291
|
const segmentState = ctx.store.getSegmentState();
|
|
284
292
|
prefetchDirect(to, segmentState.currentSegmentIds, ctx.version);
|
|
285
293
|
}
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
import React, {
|
|
4
4
|
useState,
|
|
5
5
|
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
6
7
|
useCallback,
|
|
7
8
|
useMemo,
|
|
9
|
+
useRef,
|
|
8
10
|
use,
|
|
9
11
|
type ReactNode,
|
|
10
12
|
} from "react";
|
|
@@ -25,6 +27,7 @@ import { ThemeProvider } from "../../theme/ThemeProvider.js";
|
|
|
25
27
|
import { NonceContext } from "./nonce-context.js";
|
|
26
28
|
import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
|
|
27
29
|
import { cancelAllPrefetches } from "../prefetch/queue.js";
|
|
30
|
+
import { handleNavigationEnd } from "../scroll-restoration.js";
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Process handles from an async generator, updating the event controller
|
|
@@ -301,9 +304,33 @@ export function NavigationProvider({
|
|
|
301
304
|
return unsub;
|
|
302
305
|
}, [eventController]);
|
|
303
306
|
|
|
307
|
+
// Pending scroll action to apply after React commits
|
|
308
|
+
const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
|
|
309
|
+
|
|
310
|
+
// Apply scroll after React commits the new content to the DOM
|
|
311
|
+
useLayoutEffect(() => {
|
|
312
|
+
const scrollAction = pendingScrollRef.current;
|
|
313
|
+
if (!scrollAction) return;
|
|
314
|
+
pendingScrollRef.current = undefined;
|
|
315
|
+
|
|
316
|
+
if (scrollAction.enabled === false) return;
|
|
317
|
+
|
|
318
|
+
handleNavigationEnd({
|
|
319
|
+
restore: scrollAction.restore,
|
|
320
|
+
scroll: scrollAction.enabled,
|
|
321
|
+
isStreaming: scrollAction.isStreaming,
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
304
325
|
// Subscribe to UI updates (for re-rendering the tree)
|
|
305
326
|
useEffect(() => {
|
|
306
327
|
const unsubscribe = store.onUpdate((update) => {
|
|
328
|
+
// Capture scroll intent — it will be applied in useLayoutEffect
|
|
329
|
+
// after React commits this state update to the DOM.
|
|
330
|
+
// Always assign (even undefined) to clear stale scroll from prior navigations,
|
|
331
|
+
// so server actions or error updates don't accidentally replay old scroll.
|
|
332
|
+
pendingScrollRef.current = update.scroll;
|
|
333
|
+
|
|
307
334
|
setPayload({
|
|
308
335
|
root: update.root,
|
|
309
336
|
metadata: update.metadata,
|
|
@@ -263,71 +263,104 @@ export async function initBrowserApp(
|
|
|
263
263
|
// Build initial tree with rootLayout
|
|
264
264
|
const initialTree = renderSegments(initialPayload.metadata!.segments);
|
|
265
265
|
|
|
266
|
-
// Setup HMR
|
|
266
|
+
// Setup HMR with debounce — burst saves (format-on-save, rapid edits)
|
|
267
|
+
// fire many rsc:update events in quick succession. Without debouncing,
|
|
268
|
+
// each event triggers a fetchPartial() which on slow routes can pile up
|
|
269
|
+
// and overwhelm the worker (cross-request promise issues, 500s).
|
|
267
270
|
if (import.meta.hot) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const handle = eventController.startNavigation(window.location.href, {
|
|
272
|
-
replace: true,
|
|
273
|
-
});
|
|
274
|
-
const streamingToken = handle.startStreaming();
|
|
275
|
-
|
|
276
|
-
const interceptSourceUrl = store.getInterceptSourceUrl();
|
|
277
|
-
|
|
278
|
-
try {
|
|
279
|
-
const { payload, streamComplete } = await client.fetchPartial({
|
|
280
|
-
targetUrl: window.location.href,
|
|
281
|
-
segmentIds: [],
|
|
282
|
-
previousUrl: store.getSegmentState().currentUrl,
|
|
283
|
-
interceptSourceUrl: interceptSourceUrl || undefined,
|
|
284
|
-
hmr: true,
|
|
285
|
-
});
|
|
271
|
+
let hmrTimer: ReturnType<typeof setTimeout> | null = null;
|
|
272
|
+
let hmrAbort: AbortController | null = null;
|
|
286
273
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
274
|
+
import.meta.hot.on("rsc:update", () => {
|
|
275
|
+
// Cancel any pending debounce timer
|
|
276
|
+
if (hmrTimer !== null) {
|
|
277
|
+
clearTimeout(hmrTimer);
|
|
278
|
+
}
|
|
290
279
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
280
|
+
// Abort any in-flight HMR fetch so it doesn't race with the next one
|
|
281
|
+
if (hmrAbort) {
|
|
282
|
+
hmrAbort.abort();
|
|
283
|
+
hmrAbort = null;
|
|
284
|
+
}
|
|
295
285
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
286
|
+
// Debounce: wait 200ms of quiet before fetching
|
|
287
|
+
hmrTimer = setTimeout(async () => {
|
|
288
|
+
hmrTimer = null;
|
|
289
|
+
console.log("[RSCRouter] HMR: Server update, refetching RSC");
|
|
300
290
|
|
|
301
|
-
|
|
302
|
-
|
|
291
|
+
const abort = new AbortController();
|
|
292
|
+
hmrAbort = abort;
|
|
303
293
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}),
|
|
320
|
-
metadata: payload.metadata,
|
|
294
|
+
const handle = eventController.startNavigation(window.location.href, {
|
|
295
|
+
replace: true,
|
|
296
|
+
});
|
|
297
|
+
const streamingToken = handle.startStreaming();
|
|
298
|
+
|
|
299
|
+
const interceptSourceUrl = store.getInterceptSourceUrl();
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const { payload, streamComplete } = await client.fetchPartial({
|
|
303
|
+
targetUrl: window.location.href,
|
|
304
|
+
segmentIds: [],
|
|
305
|
+
previousUrl: store.getSegmentState().currentUrl,
|
|
306
|
+
interceptSourceUrl: interceptSourceUrl || undefined,
|
|
307
|
+
hmr: true,
|
|
308
|
+
signal: abort.signal,
|
|
321
309
|
});
|
|
322
|
-
}
|
|
323
310
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
311
|
+
if (abort.signal.aborted) return;
|
|
312
|
+
|
|
313
|
+
if (payload.metadata?.isPartial) {
|
|
314
|
+
const segments = payload.metadata.segments || [];
|
|
315
|
+
const matched = payload.metadata.matched || [];
|
|
316
|
+
|
|
317
|
+
// Derive intercept state from the returned payload, not the
|
|
318
|
+
// pre-fetch store snapshot. If the HMR edit removed intercept
|
|
319
|
+
// behavior, the response won't contain intercept segments.
|
|
320
|
+
const responseIsIntercept = segments.some(isInterceptSegment);
|
|
321
|
+
|
|
322
|
+
// Sync store intercept state with what the server returned
|
|
323
|
+
if (!responseIsIntercept && interceptSourceUrl) {
|
|
324
|
+
store.setInterceptSourceUrl(null);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
store.setSegmentIds(matched);
|
|
328
|
+
store.setCurrentUrl(window.location.href);
|
|
329
|
+
|
|
330
|
+
const historyKey = generateHistoryKey(window.location.href, {
|
|
331
|
+
intercept: responseIsIntercept,
|
|
332
|
+
});
|
|
333
|
+
store.setHistoryKey(historyKey);
|
|
334
|
+
const currentHandleData = eventController.getHandleState().data;
|
|
335
|
+
store.cacheSegmentsForHistory(
|
|
336
|
+
historyKey,
|
|
337
|
+
segments,
|
|
338
|
+
currentHandleData,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const { main, intercept } = splitInterceptSegments(segments);
|
|
342
|
+
store.emitUpdate({
|
|
343
|
+
root: renderSegments(main, {
|
|
344
|
+
interceptSegments: intercept.length > 0 ? intercept : undefined,
|
|
345
|
+
}),
|
|
346
|
+
metadata: payload.metadata,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
await streamComplete;
|
|
351
|
+
handle.complete(new URL(window.location.href));
|
|
352
|
+
console.log("[RSCRouter] HMR: RSC stream complete");
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (abort.signal.aborted) return;
|
|
355
|
+
console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
|
|
356
|
+
window.location.reload();
|
|
357
|
+
return;
|
|
358
|
+
} finally {
|
|
359
|
+
if (hmrAbort === abort) hmrAbort = null;
|
|
360
|
+
streamingToken.end();
|
|
361
|
+
handle[Symbol.dispose]();
|
|
362
|
+
}
|
|
363
|
+
}, 200);
|
|
331
364
|
});
|
|
332
365
|
}
|
|
333
366
|
|
|
@@ -10,6 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
import { debugLog } from "./logging.js";
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Defers a callback to the next animation frame.
|
|
15
|
+
* Falls back to setTimeout(0) in environments without requestAnimationFrame.
|
|
16
|
+
*/
|
|
17
|
+
const deferToNextPaint: (fn: () => void) => void =
|
|
18
|
+
typeof requestAnimationFrame === "function"
|
|
19
|
+
? requestAnimationFrame
|
|
20
|
+
: (fn) => setTimeout(fn, 0);
|
|
21
|
+
|
|
13
22
|
const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
|
|
14
23
|
|
|
15
24
|
/**
|
|
@@ -264,51 +273,35 @@ export function restoreScrollPosition(options?: {
|
|
|
264
273
|
return false;
|
|
265
274
|
}
|
|
266
275
|
|
|
267
|
-
//
|
|
268
|
-
const maxScrollY = document.documentElement.scrollHeight - window.innerHeight;
|
|
269
|
-
const canScrollToPosition = savedY <= maxScrollY;
|
|
270
|
-
|
|
271
|
-
if (canScrollToPosition) {
|
|
272
|
-
window.scrollTo(0, savedY);
|
|
273
|
-
debugLog("[Scroll] Restored position:", savedY, "for key:", key);
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Scroll as far as we can for now
|
|
278
|
-
window.scrollTo(0, maxScrollY);
|
|
279
|
-
debugLog("[Scroll] Partial restore to:", maxScrollY, "target:", savedY);
|
|
280
|
-
|
|
281
|
-
// Poll while streaming until we can scroll to target position
|
|
276
|
+
// If streaming, poll until streaming ends then scroll to saved position
|
|
282
277
|
if (options?.retryIfStreaming && options?.isStreaming?.()) {
|
|
283
278
|
const startTime = Date.now();
|
|
284
279
|
|
|
285
280
|
pendingPollInterval = setInterval(() => {
|
|
286
|
-
// Stop if we've exceeded the timeout
|
|
287
281
|
if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
|
|
288
282
|
debugLog("[Scroll] Polling timeout, giving up");
|
|
289
283
|
cancelScrollRestorationPolling();
|
|
290
284
|
return;
|
|
291
285
|
}
|
|
292
286
|
|
|
293
|
-
// Stop if streaming ended
|
|
294
287
|
if (!options.isStreaming?.()) {
|
|
295
|
-
debugLog("[Scroll] Streaming ended, stopping poll");
|
|
296
|
-
cancelScrollRestorationPolling();
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Check if we can now scroll to the target position
|
|
301
|
-
const currentMaxScrollY =
|
|
302
|
-
document.documentElement.scrollHeight - window.innerHeight;
|
|
303
|
-
if (savedY <= currentMaxScrollY) {
|
|
304
288
|
window.scrollTo(0, savedY);
|
|
305
|
-
debugLog("[Scroll]
|
|
289
|
+
debugLog("[Scroll] Restored after streaming:", savedY);
|
|
306
290
|
cancelScrollRestorationPolling();
|
|
307
291
|
}
|
|
308
292
|
}, SCROLL_POLL_INTERVAL_MS);
|
|
293
|
+
|
|
294
|
+
return true;
|
|
309
295
|
}
|
|
310
296
|
|
|
311
|
-
|
|
297
|
+
// Not streaming — scroll after React commits and browser paints.
|
|
298
|
+
// startTransition defers the DOM commit, so scrolling synchronously
|
|
299
|
+
// would be overwritten when React replaces the content.
|
|
300
|
+
deferToNextPaint(() => {
|
|
301
|
+
window.scrollTo(0, savedY);
|
|
302
|
+
debugLog("[Scroll] Restored position:", savedY, "for key:", key);
|
|
303
|
+
});
|
|
304
|
+
return true;
|
|
312
305
|
}
|
|
313
306
|
|
|
314
307
|
/**
|
|
@@ -382,13 +375,17 @@ export function handleNavigationEnd(options: {
|
|
|
382
375
|
// Fall through to hash or top if no saved position
|
|
383
376
|
}
|
|
384
377
|
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
378
|
+
// Defer hash and scroll-to-top to after React paints the new content,
|
|
379
|
+
// so the user doesn't see the current page jump before the new route appears.
|
|
380
|
+
deferToNextPaint(() => {
|
|
381
|
+
// Try hash scrolling first
|
|
382
|
+
if (scrollToHash()) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
389
385
|
|
|
390
|
-
|
|
391
|
-
|
|
386
|
+
// Default: scroll to top
|
|
387
|
+
scrollToTop();
|
|
388
|
+
});
|
|
392
389
|
}
|
|
393
390
|
|
|
394
391
|
/**
|
package/src/browser/types.ts
CHANGED
|
@@ -215,6 +215,15 @@ export interface SegmentState {
|
|
|
215
215
|
export interface NavigationUpdate {
|
|
216
216
|
root: ReactNode | Promise<ReactNode>;
|
|
217
217
|
metadata: RscMetadata;
|
|
218
|
+
/** Scroll behavior to apply after React commits this update */
|
|
219
|
+
scroll?: {
|
|
220
|
+
/** For back/forward: restore saved position */
|
|
221
|
+
restore?: boolean;
|
|
222
|
+
/** Set to false to disable scrolling entirely */
|
|
223
|
+
enabled?: boolean;
|
|
224
|
+
/** Function to check if streaming is in progress */
|
|
225
|
+
isStreaming?: () => boolean;
|
|
226
|
+
};
|
|
218
227
|
}
|
|
219
228
|
|
|
220
229
|
/**
|
|
@@ -45,7 +45,9 @@ function isRoutableSourceFile(name: string): boolean {
|
|
|
45
45
|
name.endsWith(".tsx") ||
|
|
46
46
|
name.endsWith(".js") ||
|
|
47
47
|
name.endsWith(".jsx")) &&
|
|
48
|
-
!name.includes(".gen.")
|
|
48
|
+
!name.includes(".gen.") &&
|
|
49
|
+
!name.includes(".test.") &&
|
|
50
|
+
!name.includes(".spec.")
|
|
49
51
|
);
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -70,7 +72,15 @@ function findRouterFilesRecursive(
|
|
|
70
72
|
for (const entry of entries) {
|
|
71
73
|
const fullPath = join(dir, entry.name);
|
|
72
74
|
if (entry.isDirectory()) {
|
|
73
|
-
if (
|
|
75
|
+
if (
|
|
76
|
+
entry.name === "node_modules" ||
|
|
77
|
+
entry.name === "dist" ||
|
|
78
|
+
entry.name === "coverage" ||
|
|
79
|
+
entry.name === "__tests__" ||
|
|
80
|
+
entry.name === "__mocks__" ||
|
|
81
|
+
entry.name.startsWith(".")
|
|
82
|
+
)
|
|
83
|
+
continue;
|
|
74
84
|
childDirs.push(fullPath);
|
|
75
85
|
continue;
|
|
76
86
|
}
|
package/src/cache/cache-scope.ts
CHANGED
|
@@ -73,7 +73,7 @@ function getDefaultRouteCacheKey(
|
|
|
73
73
|
isIntercept?: boolean,
|
|
74
74
|
): string {
|
|
75
75
|
const ctx = getRequestContext();
|
|
76
|
-
const isPartial = ctx?.
|
|
76
|
+
const isPartial = ctx?.originalUrl?.searchParams.has("_rsc_partial") ?? false;
|
|
77
77
|
const searchParams = ctx?.url.searchParams;
|
|
78
78
|
const host = ctx?.url.host ?? "localhost";
|
|
79
79
|
|
|
@@ -326,7 +326,7 @@ export class CacheScope {
|
|
|
326
326
|
const key = await this.resolveKey(pathname, params, isIntercept);
|
|
327
327
|
|
|
328
328
|
// Check if this is a partial request (navigation) vs document request
|
|
329
|
-
const isPartial = requestCtx.
|
|
329
|
+
const isPartial = requestCtx.originalUrl.searchParams.has("_rsc_partial");
|
|
330
330
|
|
|
331
331
|
requestCtx.waitUntil(async () => {
|
|
332
332
|
await handleStore.settled;
|