@rangojs/router 0.1.0 → 0.1.1
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/bin/rango.js +11 -1
- package/dist/types/browser/dev-discovery.d.ts +11 -0
- package/dist/types/browser/navigation-store.d.ts +3 -13
- package/dist/types/browser/prefetch/loader.d.ts +10 -0
- package/dist/types/browser/prefetch/runtime.d.ts +3 -0
- package/dist/types/browser/types.d.ts +4 -16
- package/dist/types/build/generate-manifest.d.ts +6 -0
- package/dist/types/cache/cache-scope.d.ts +7 -5
- package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
- package/dist/types/cache/memory-segment-store.d.ts +6 -6
- package/dist/types/cache/shell-snapshot.d.ts +21 -8
- package/dist/types/cache/types.d.ts +24 -7
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
- package/dist/types/dev-discovery-protocol.d.ts +5 -0
- package/dist/types/prerender/store.d.ts +1 -0
- package/dist/types/route-map-builder.d.ts +4 -29
- package/dist/types/router/prerender-match.d.ts +4 -1
- package/dist/types/router/router-interfaces.d.ts +3 -1
- package/dist/types/rsc/handler-context.d.ts +1 -0
- package/dist/types/rsc/render-pipeline.d.ts +3 -2
- package/dist/types/rsc/rsc-rendering.d.ts +5 -10
- package/dist/types/rsc/shell-capture.d.ts +4 -8
- package/dist/types/rsc/types.d.ts +2 -0
- package/dist/types/server/context.d.ts +16 -0
- package/dist/types/server/request-context.d.ts +18 -8
- package/dist/types/server.d.ts +1 -1
- package/dist/types/types/route-entry.d.ts +3 -0
- package/dist/types/vite/discovery/state.d.ts +3 -2
- package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
- package/dist/vite/index.js +194 -107
- package/package.json +7 -2
- package/skills/caching/SKILL.md +1 -1
- package/skills/ppr/SKILL.md +48 -4
- package/src/browser/dev-discovery.ts +66 -0
- package/src/browser/navigation-client.ts +6 -0
- package/src/browser/navigation-store.ts +4 -271
- package/src/browser/partial-update.ts +7 -14
- package/src/browser/prefetch/cache.ts +1 -1
- package/src/browser/prefetch/loader.ts +110 -0
- package/src/browser/prefetch/runtime.ts +7 -0
- package/src/browser/react/Link.tsx +7 -10
- package/src/browser/react/NavigationProvider.tsx +1 -1
- package/src/browser/react/use-router.ts +1 -1
- package/src/browser/rsc-router.tsx +4 -2
- package/src/browser/types.ts +4 -27
- package/src/build/generate-manifest.ts +11 -0
- package/src/cache/cache-scope.ts +17 -6
- package/src/cache/cf/cf-cache-store.ts +63 -39
- package/src/cache/memory-segment-store.ts +17 -6
- package/src/cache/shell-snapshot.ts +45 -15
- package/src/cache/types.ts +23 -6
- package/src/cache/vercel/vercel-cache-store.ts +52 -22
- package/src/dev-discovery-protocol.ts +11 -0
- package/src/prerender/build-shell-capture.ts +7 -1
- package/src/prerender/store.ts +5 -0
- package/src/route-definition/dsl-helpers.ts +7 -2
- package/src/route-map-builder.ts +56 -49
- package/src/router/handler-context.ts +13 -5
- package/src/router/lazy-includes.ts +1 -0
- package/src/router/match-api.ts +8 -4
- package/src/router/prerender-match.ts +19 -2
- package/src/router/router-interfaces.ts +4 -0
- package/src/router/segment-resolution/static-store.ts +36 -10
- package/src/router.ts +34 -3
- package/src/rsc/handler-context.ts +1 -0
- package/src/rsc/handler.ts +3 -1
- package/src/rsc/loader-fetch.ts +2 -2
- package/src/rsc/manifest-init.ts +4 -11
- package/src/rsc/render-pipeline.ts +10 -2
- package/src/rsc/rsc-rendering.ts +207 -146
- package/src/rsc/shell-capture.ts +34 -11
- package/src/rsc/types.ts +2 -0
- package/src/server/context.ts +28 -0
- package/src/server/request-context.ts +32 -10
- package/src/server.ts +0 -2
- package/src/testing/dispatch.ts +1 -0
- package/src/types/route-entry.ts +3 -0
- package/src/urls/include-helper.ts +1 -0
- package/src/urls/path-helper.ts +8 -4
- package/src/vite/discovery/bundle-postprocess.ts +10 -7
- package/src/vite/discovery/discover-routers.ts +7 -66
- package/src/vite/discovery/prerender-collection.ts +13 -2
- package/src/vite/discovery/state.ts +4 -4
- package/src/vite/discovery/virtual-module-codegen.ts +75 -42
- package/src/vite/plugins/version-injector.ts +0 -1
- package/src/vite/plugins/virtual-entries.ts +11 -1
- package/src/vite/router-discovery.ts +132 -22
- package/src/vite/utils/manifest-utils.ts +1 -4
- package/src/vite/utils/shared-utils.ts +7 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEV_DISCOVERY_QUERY_EVENT,
|
|
3
|
+
DEV_DISCOVERY_READY_EVENT,
|
|
4
|
+
isValidDevDiscoveryEpoch,
|
|
5
|
+
} from "../dev-discovery-protocol.js";
|
|
6
|
+
|
|
7
|
+
export interface DevDiscoveryHot {
|
|
8
|
+
on(
|
|
9
|
+
event: typeof DEV_DISCOVERY_READY_EVENT | "vite:ws:connect",
|
|
10
|
+
listener: (payload: unknown) => void,
|
|
11
|
+
): void;
|
|
12
|
+
send(
|
|
13
|
+
event: typeof DEV_DISCOVERY_QUERY_EVENT,
|
|
14
|
+
payload: { epoch: number },
|
|
15
|
+
): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DevDiscoveryHandshakeOptions {
|
|
19
|
+
reload?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readEpoch(payload: unknown): number | undefined {
|
|
23
|
+
if (
|
|
24
|
+
typeof payload !== "object" ||
|
|
25
|
+
payload === null ||
|
|
26
|
+
!("epoch" in payload)
|
|
27
|
+
) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return isValidDevDiscoveryEpoch(payload.epoch) ? payload.epoch : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function startDevDiscoveryHandshake(
|
|
35
|
+
documentEpoch: unknown,
|
|
36
|
+
hot: DevDiscoveryHot,
|
|
37
|
+
options: DevDiscoveryHandshakeOptions = {},
|
|
38
|
+
): void {
|
|
39
|
+
if (!isValidDevDiscoveryEpoch(documentEpoch)) return;
|
|
40
|
+
const currentDocumentEpoch = documentEpoch;
|
|
41
|
+
|
|
42
|
+
const reload = options.reload ?? (() => window.location.reload());
|
|
43
|
+
let reloadRequested = false;
|
|
44
|
+
|
|
45
|
+
function reloadIfNewer(payload: unknown): void {
|
|
46
|
+
const readyEpoch = readEpoch(payload);
|
|
47
|
+
if (
|
|
48
|
+
reloadRequested ||
|
|
49
|
+
readyEpoch === undefined ||
|
|
50
|
+
readyEpoch <= currentDocumentEpoch
|
|
51
|
+
) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
reloadRequested = true;
|
|
56
|
+
reload();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requestReadyEpoch(): void {
|
|
60
|
+
hot.send(DEV_DISCOVERY_QUERY_EVENT, { epoch: currentDocumentEpoch });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
hot.on(DEV_DISCOVERY_READY_EVENT, reloadIfNewer);
|
|
64
|
+
hot.on("vite:ws:connect", requestReadyEpoch);
|
|
65
|
+
requestReadyEpoch();
|
|
66
|
+
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
consumePrefetch,
|
|
29
29
|
type DecodedPrefetch,
|
|
30
30
|
} from "./prefetch/cache.js";
|
|
31
|
+
import { cancelAllPrefetches } from "./prefetch/loader.js";
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Create a navigation client for fetching RSC payloads
|
|
@@ -101,6 +102,11 @@ export function createNavigationClient(
|
|
|
101
102
|
fetchUrl.searchParams.set("_rsc_rid", routerId);
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
// If the lazy prefetch runtime has not loaded yet, prevent a deferred
|
|
106
|
+
// speculative request from starting after this navigation misses the
|
|
107
|
+
// synchronous cache lookup and begins its own fetch.
|
|
108
|
+
cancelAllPrefetches(targetUrl);
|
|
109
|
+
|
|
104
110
|
// Check completed in-memory prefetch cache before making a network
|
|
105
111
|
// request. Try the source-scoped key first (populated when the server
|
|
106
112
|
// tagged the prefetch response as source-sensitive, e.g. intercepts,
|
|
@@ -1,30 +1,14 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
NavigationState,
|
|
3
2
|
NavigationLocation,
|
|
4
3
|
SegmentState,
|
|
5
4
|
NavigationStore,
|
|
6
5
|
NavigationUpdate,
|
|
7
6
|
UpdateSubscriber,
|
|
8
|
-
StateListener,
|
|
9
7
|
ResolvedSegment,
|
|
10
|
-
InflightAction,
|
|
11
|
-
TrackedActionState,
|
|
12
|
-
ActionStateListener,
|
|
13
8
|
HandleData,
|
|
14
9
|
} from "./types.js";
|
|
15
10
|
import { clearPrefetchCache } from "./prefetch/cache.js";
|
|
16
11
|
|
|
17
|
-
/**
|
|
18
|
-
* Default action state (idle with no payload)
|
|
19
|
-
*/
|
|
20
|
-
const DEFAULT_ACTION_STATE: TrackedActionState = {
|
|
21
|
-
state: "idle",
|
|
22
|
-
actionId: null,
|
|
23
|
-
payload: null,
|
|
24
|
-
error: null,
|
|
25
|
-
result: null,
|
|
26
|
-
};
|
|
27
|
-
|
|
28
12
|
// Maximum number of history entries to cache (URLs visited)
|
|
29
13
|
const HISTORY_CACHE_SIZE = 20;
|
|
30
14
|
|
|
@@ -177,11 +161,10 @@ function createLocation(loc: { href: string }): NavigationLocation {
|
|
|
177
161
|
}
|
|
178
162
|
|
|
179
163
|
/**
|
|
180
|
-
* Create a navigation store for
|
|
164
|
+
* Create a navigation store for browser-side segment and history state.
|
|
181
165
|
*
|
|
182
|
-
* The
|
|
183
|
-
*
|
|
184
|
-
* - SegmentState: Internal segment management for partial RSC updates
|
|
166
|
+
* The public navigation lifecycle lives in EventController; this store owns
|
|
167
|
+
* segment reconciliation, history snapshots, and cross-tab cache invalidation.
|
|
185
168
|
*
|
|
186
169
|
* @param config - Initial configuration
|
|
187
170
|
* @returns NavigationStore instance
|
|
@@ -193,15 +176,6 @@ function createLocation(loc: { href: string }): NavigationLocation {
|
|
|
193
176
|
* initialSegmentIds: [],
|
|
194
177
|
* });
|
|
195
178
|
*
|
|
196
|
-
* // Subscribe to state changes (for useNavigation hook)
|
|
197
|
-
* const unsubscribe = store.subscribe(() => {
|
|
198
|
-
* const state = store.getState();
|
|
199
|
-
* console.log('Navigation state:', state);
|
|
200
|
-
* });
|
|
201
|
-
*
|
|
202
|
-
* // Update state
|
|
203
|
-
* store.setState({ state: 'loading' });
|
|
204
|
-
*
|
|
205
179
|
* // Subscribe to UI updates (for re-rendering)
|
|
206
180
|
* store.onUpdate((update) => {
|
|
207
181
|
* console.log('New root:', update.root);
|
|
@@ -217,19 +191,6 @@ export function createNavigationStore(
|
|
|
217
191
|
? createLocation(window.location)
|
|
218
192
|
: new URL("/", "http://localhost");
|
|
219
193
|
|
|
220
|
-
// Public navigation state (for useNavigation hook)
|
|
221
|
-
// isStreaming starts false to match SSR and avoid hydration mismatch
|
|
222
|
-
// After hydration, entry.browser.tsx sets it to true if stream is still open
|
|
223
|
-
let navState: NavigationState = {
|
|
224
|
-
state: "idle",
|
|
225
|
-
isStreaming: false,
|
|
226
|
-
location: config?.initialLocation
|
|
227
|
-
? createLocation(config.initialLocation)
|
|
228
|
-
: defaultLocation,
|
|
229
|
-
pendingUrl: null,
|
|
230
|
-
inflightActions: [],
|
|
231
|
-
};
|
|
232
|
-
|
|
233
194
|
// Resolve the initial location for segment state
|
|
234
195
|
const initialLoc = config?.initialLocation
|
|
235
196
|
? createLocation(config.initialLocation)
|
|
@@ -251,9 +212,6 @@ export function createNavigationStore(
|
|
|
251
212
|
let crossTabRefreshCallback: (() => void) | null =
|
|
252
213
|
config?.onCrossTabRefresh ?? null;
|
|
253
214
|
|
|
254
|
-
// Track pending cross-tab refresh to prevent duplicate refreshes
|
|
255
|
-
let pendingCrossTabRefresh = false;
|
|
256
|
-
|
|
257
215
|
// History-based segment cache: array of [url-key, segments] tuples
|
|
258
216
|
// Each URL gets its own complete snapshot of segments for back/forward and partial merging
|
|
259
217
|
// Oldest entries (at front) are removed when over cacheSize limit
|
|
@@ -283,15 +241,9 @@ export function createNavigationStore(
|
|
|
283
241
|
]);
|
|
284
242
|
}
|
|
285
243
|
|
|
286
|
-
// State change listeners (for useNavigation subscriptions)
|
|
287
|
-
const stateListeners = new Set<StateListener>();
|
|
288
|
-
|
|
289
244
|
// UI update subscribers (for re-rendering)
|
|
290
245
|
const updateSubscribers = new Set<UpdateSubscriber>();
|
|
291
246
|
|
|
292
|
-
// Internal flag to track if a server action is in progress
|
|
293
|
-
let actionInProgress = false;
|
|
294
|
-
|
|
295
247
|
// Intercept source URL - tracks where the intercept was triggered from
|
|
296
248
|
// Used to maintain intercept context during action revalidation
|
|
297
249
|
let interceptSourceUrl: string | null = null;
|
|
@@ -301,63 +253,6 @@ export function createNavigationStore(
|
|
|
301
253
|
// tree replacement instead of reconciling with stale segments.
|
|
302
254
|
let currentRouterId: string | undefined;
|
|
303
255
|
|
|
304
|
-
// Action state tracking (for useAction hook)
|
|
305
|
-
// Maps action function ID to its tracked state
|
|
306
|
-
const actionStates = new Map<string, TrackedActionState>();
|
|
307
|
-
|
|
308
|
-
// Action state listeners (per action ID)
|
|
309
|
-
// Maps action function ID to set of listeners
|
|
310
|
-
const actionListeners = new Map<string, Set<ActionStateListener>>();
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Create a debounced function that batches rapid calls
|
|
314
|
-
*/
|
|
315
|
-
// A non-keyed notifier is the keyed one restricted to a single constant key;
|
|
316
|
-
// its own keyed instance means the "" key never collides with action keys.
|
|
317
|
-
function createDebouncedNotifier<T extends (...args: any[]) => void>(
|
|
318
|
-
fn: T,
|
|
319
|
-
ms: number = 20,
|
|
320
|
-
): T {
|
|
321
|
-
const keyed = createKeyedDebouncedNotifier(
|
|
322
|
-
(_key: string, ...args: any[]) => fn(...args),
|
|
323
|
-
ms,
|
|
324
|
-
);
|
|
325
|
-
return ((...args: Parameters<T>) => keyed("", ...args)) as T;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Create a keyed debounced function (separate timers per key)
|
|
330
|
-
*/
|
|
331
|
-
function createKeyedDebouncedNotifier<
|
|
332
|
-
T extends (key: string, ...args: any[]) => void,
|
|
333
|
-
>(fn: T, ms: number = 20): T {
|
|
334
|
-
const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
335
|
-
return ((key: string, ...args: any[]) => {
|
|
336
|
-
const existing = timeouts.get(key);
|
|
337
|
-
if (existing !== undefined) clearTimeout(existing);
|
|
338
|
-
timeouts.set(
|
|
339
|
-
key,
|
|
340
|
-
setTimeout(() => {
|
|
341
|
-
timeouts.delete(key);
|
|
342
|
-
fn(key, ...args);
|
|
343
|
-
}, ms),
|
|
344
|
-
);
|
|
345
|
-
}) as T;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const notifyStateListeners = createDebouncedNotifier(() => {
|
|
349
|
-
stateListeners.forEach((listener) => listener());
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
const notifyActionListeners = createKeyedDebouncedNotifier(
|
|
353
|
-
(actionId: string, state: TrackedActionState) => {
|
|
354
|
-
const listeners = actionListeners.get(actionId);
|
|
355
|
-
if (listeners) {
|
|
356
|
-
listeners.forEach((listener) => listener(state));
|
|
357
|
-
}
|
|
358
|
-
},
|
|
359
|
-
);
|
|
360
|
-
|
|
361
256
|
/**
|
|
362
257
|
* Clear the history cache (internal - does not broadcast)
|
|
363
258
|
*/
|
|
@@ -450,22 +345,7 @@ export function createNavigationStore(
|
|
|
450
345
|
|
|
451
346
|
// Auto-refresh if enabled and callback is registered
|
|
452
347
|
if (crossTabAutoRefresh && crossTabRefreshCallback) {
|
|
453
|
-
|
|
454
|
-
if (navState.state === "idle") {
|
|
455
|
-
crossTabRefreshCallback();
|
|
456
|
-
} else if (!pendingCrossTabRefresh) {
|
|
457
|
-
// Only queue one refresh, ignore subsequent events while loading
|
|
458
|
-
pendingCrossTabRefresh = true;
|
|
459
|
-
// Subscribe to state changes, refresh when idle
|
|
460
|
-
const listener: StateListener = () => {
|
|
461
|
-
if (navState.state === "idle") {
|
|
462
|
-
stateListeners.delete(listener);
|
|
463
|
-
pendingCrossTabRefresh = false;
|
|
464
|
-
crossTabRefreshCallback?.();
|
|
465
|
-
}
|
|
466
|
-
};
|
|
467
|
-
stateListeners.add(listener);
|
|
468
|
-
}
|
|
348
|
+
crossTabRefreshCallback();
|
|
469
349
|
}
|
|
470
350
|
}
|
|
471
351
|
};
|
|
@@ -473,80 +353,6 @@ export function createNavigationStore(
|
|
|
473
353
|
}
|
|
474
354
|
|
|
475
355
|
return {
|
|
476
|
-
// ========================================================================
|
|
477
|
-
// Public State (for useNavigation hook)
|
|
478
|
-
// ========================================================================
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* Get current navigation state
|
|
482
|
-
*/
|
|
483
|
-
getState(): NavigationState {
|
|
484
|
-
return navState;
|
|
485
|
-
},
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Update navigation state and notify listeners
|
|
489
|
-
*/
|
|
490
|
-
setState(partial: Partial<NavigationState>): void {
|
|
491
|
-
navState = { ...navState, ...partial };
|
|
492
|
-
notifyStateListeners();
|
|
493
|
-
},
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Subscribe to state changes
|
|
497
|
-
* Returns unsubscribe function
|
|
498
|
-
*/
|
|
499
|
-
subscribe(listener: StateListener): () => void {
|
|
500
|
-
stateListeners.add(listener);
|
|
501
|
-
return () => {
|
|
502
|
-
stateListeners.delete(listener);
|
|
503
|
-
};
|
|
504
|
-
},
|
|
505
|
-
|
|
506
|
-
// ========================================================================
|
|
507
|
-
// Inflight Action Management
|
|
508
|
-
// ========================================================================
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Add an inflight action to the list
|
|
512
|
-
*/
|
|
513
|
-
addInflightAction(action: InflightAction): void {
|
|
514
|
-
navState = {
|
|
515
|
-
...navState,
|
|
516
|
-
inflightActions: [...navState.inflightActions, action],
|
|
517
|
-
};
|
|
518
|
-
notifyStateListeners();
|
|
519
|
-
},
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* Remove an inflight action by ID
|
|
523
|
-
*/
|
|
524
|
-
removeInflightAction(id: string): void {
|
|
525
|
-
navState = {
|
|
526
|
-
...navState,
|
|
527
|
-
inflightActions: navState.inflightActions.filter((a) => a.id !== id),
|
|
528
|
-
};
|
|
529
|
-
notifyStateListeners();
|
|
530
|
-
},
|
|
531
|
-
|
|
532
|
-
// ========================================================================
|
|
533
|
-
// Action State (for controlling update behavior during server actions)
|
|
534
|
-
// ========================================================================
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Check if a server action is currently in progress
|
|
538
|
-
*/
|
|
539
|
-
isActionInProgress(): boolean {
|
|
540
|
-
return actionInProgress;
|
|
541
|
-
},
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* Set the action in progress flag
|
|
545
|
-
*/
|
|
546
|
-
setActionInProgress(value: boolean): void {
|
|
547
|
-
actionInProgress = value;
|
|
548
|
-
},
|
|
549
|
-
|
|
550
356
|
// ========================================================================
|
|
551
357
|
// Internal Segment State (for bridges)
|
|
552
358
|
// ========================================================================
|
|
@@ -609,17 +415,6 @@ export function createNavigationStore(
|
|
|
609
415
|
return navInstance;
|
|
610
416
|
},
|
|
611
417
|
|
|
612
|
-
/**
|
|
613
|
-
* The nav-instance token recorded on a specific cache entry, or undefined if
|
|
614
|
-
* no entry exists for that key. Because the history key is URL-only, this is
|
|
615
|
-
* how a late resolution tells "the entry I seeded is still mine" from "a
|
|
616
|
-
* newer same-URL visit replaced my entry".
|
|
617
|
-
*/
|
|
618
|
-
getCacheEntryInstance(historyKey: string): number | undefined {
|
|
619
|
-
const entry = historyCache.find(([key]) => key === historyKey);
|
|
620
|
-
return entry ? entry[5] : undefined;
|
|
621
|
-
},
|
|
622
|
-
|
|
623
418
|
/**
|
|
624
419
|
* Store segments for a history entry
|
|
625
420
|
* Updates existing entry if key exists, otherwise adds new entry
|
|
@@ -777,14 +572,6 @@ export function createNavigationStore(
|
|
|
777
572
|
];
|
|
778
573
|
},
|
|
779
574
|
|
|
780
|
-
/**
|
|
781
|
-
* Mark all cache entries as stale
|
|
782
|
-
* Called after server actions to indicate data may be outdated
|
|
783
|
-
*/
|
|
784
|
-
markCacheAsStale(): void {
|
|
785
|
-
markCacheAsStaleInternal();
|
|
786
|
-
},
|
|
787
|
-
|
|
788
575
|
/**
|
|
789
576
|
* Mark every history entry stale WITHOUT clearing the prefetch caches or
|
|
790
577
|
* rotating the rango state. The jar-divergence observer calls this after an
|
|
@@ -871,59 +658,5 @@ export function createNavigationStore(
|
|
|
871
658
|
callback(update);
|
|
872
659
|
});
|
|
873
660
|
},
|
|
874
|
-
|
|
875
|
-
// ========================================================================
|
|
876
|
-
// Action State Tracking (for useAction hook)
|
|
877
|
-
// ========================================================================
|
|
878
|
-
|
|
879
|
-
/**
|
|
880
|
-
* Get the current state for a tracked action
|
|
881
|
-
* Returns default idle state if action hasn't been tracked
|
|
882
|
-
*/
|
|
883
|
-
getActionState(actionId: string): TrackedActionState {
|
|
884
|
-
return actionStates.get(actionId) ?? { ...DEFAULT_ACTION_STATE };
|
|
885
|
-
},
|
|
886
|
-
|
|
887
|
-
/**
|
|
888
|
-
* Update the state for a tracked action
|
|
889
|
-
* Merges partial state with existing state and notifies listeners
|
|
890
|
-
*/
|
|
891
|
-
setActionState(
|
|
892
|
-
actionId: string,
|
|
893
|
-
partial: Partial<TrackedActionState>,
|
|
894
|
-
): void {
|
|
895
|
-
const current = actionStates.get(actionId) ?? { ...DEFAULT_ACTION_STATE };
|
|
896
|
-
const updated: TrackedActionState = {
|
|
897
|
-
...current,
|
|
898
|
-
...partial,
|
|
899
|
-
actionId, // Always set the actionId
|
|
900
|
-
};
|
|
901
|
-
actionStates.set(actionId, updated);
|
|
902
|
-
notifyActionListeners(actionId, updated);
|
|
903
|
-
},
|
|
904
|
-
|
|
905
|
-
/**
|
|
906
|
-
* Subscribe to state changes for a specific action
|
|
907
|
-
* Returns unsubscribe function
|
|
908
|
-
*/
|
|
909
|
-
subscribeToAction(
|
|
910
|
-
actionId: string,
|
|
911
|
-
listener: ActionStateListener,
|
|
912
|
-
): () => void {
|
|
913
|
-
let listeners = actionListeners.get(actionId);
|
|
914
|
-
if (!listeners) {
|
|
915
|
-
listeners = new Set();
|
|
916
|
-
actionListeners.set(actionId, listeners);
|
|
917
|
-
}
|
|
918
|
-
listeners.add(listener);
|
|
919
|
-
|
|
920
|
-
return () => {
|
|
921
|
-
listeners!.delete(listener);
|
|
922
|
-
// Clean up empty listener sets
|
|
923
|
-
if (listeners!.size === 0) {
|
|
924
|
-
actionListeners.delete(actionId);
|
|
925
|
-
}
|
|
926
|
-
};
|
|
927
|
-
},
|
|
928
661
|
};
|
|
929
662
|
}
|
|
@@ -550,20 +550,13 @@ export function createPartialUpdater(
|
|
|
550
550
|
});
|
|
551
551
|
});
|
|
552
552
|
} else {
|
|
553
|
-
// Normal commit
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
// the new tree
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
// (post-commit) under a persistent boundary; that would retain the
|
|
561
|
-
// previous page indefinitely with no feedback. A normal commit lets such
|
|
562
|
-
// client-initiated suspense reveal a fallback (correct) while the router
|
|
563
|
-
// data — genuinely ready — never flashes. Cold/partial navs
|
|
564
|
-
// (fullyPrefetched=false) do not forceAwait, so they stream their
|
|
565
|
-
// fallbacks. Explicit transition() routes keep the broader content-hold
|
|
566
|
-
// via the hasTransition branch above (the documented opt-in).
|
|
553
|
+
// Normal commit for ALL navs here, never a transition (see the
|
|
554
|
+
// forceAwait comment above for why prefetched router data lands
|
|
555
|
+
// without fallback frames). A transition would hold the OLD UI until
|
|
556
|
+
// every suspense in the new tree settles — including a client
|
|
557
|
+
// component that only starts fetching post-mount, retaining the
|
|
558
|
+
// previous page indefinitely. Explicit transition() routes keep the
|
|
559
|
+
// content-hold via the hasTransition branch above (the opt-in).
|
|
567
560
|
onUpdate({
|
|
568
561
|
root: newTree,
|
|
569
562
|
metadata: payload.metadata!,
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* effectively per-document. Unit tests reset them via clearPrefetchCache().
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
-
import { abortAllPrefetches } from "./
|
|
42
|
+
import { abortAllPrefetches } from "./loader.js";
|
|
43
43
|
import { invalidateRangoState } from "../rango-state.js";
|
|
44
44
|
import type { RscPayload } from "../types.js";
|
|
45
45
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { RscPayload } from "../types.js";
|
|
2
|
+
|
|
3
|
+
type PrefetchDecoder = (response: Promise<Response>) => Promise<RscPayload>;
|
|
4
|
+
|
|
5
|
+
type PrefetchRuntime = typeof import("./runtime.js");
|
|
6
|
+
|
|
7
|
+
let runtime: PrefetchRuntime | null = null;
|
|
8
|
+
let runtimePromise: Promise<PrefetchRuntime> | null = null;
|
|
9
|
+
let decoder: PrefetchDecoder | null = null;
|
|
10
|
+
let concurrency: number | undefined;
|
|
11
|
+
let requestGeneration = 0;
|
|
12
|
+
|
|
13
|
+
function loadRuntime(): Promise<PrefetchRuntime> {
|
|
14
|
+
if (runtime) return Promise.resolve(runtime);
|
|
15
|
+
if (!runtimePromise) {
|
|
16
|
+
runtimePromise = import("./runtime.js").then(
|
|
17
|
+
(loaded) => {
|
|
18
|
+
runtime = loaded;
|
|
19
|
+
if (decoder) loaded.setPrefetchDecoder(decoder);
|
|
20
|
+
if (concurrency !== undefined) {
|
|
21
|
+
loaded.setPrefetchConcurrency(concurrency);
|
|
22
|
+
}
|
|
23
|
+
return loaded;
|
|
24
|
+
},
|
|
25
|
+
(error: unknown) => {
|
|
26
|
+
runtimePromise = null;
|
|
27
|
+
throw error;
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return runtimePromise;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function withRuntime(run: (loaded: PrefetchRuntime) => void): void {
|
|
35
|
+
if (runtime) {
|
|
36
|
+
run(runtime);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const generation = requestGeneration;
|
|
40
|
+
void loadRuntime()
|
|
41
|
+
.then((loaded) => {
|
|
42
|
+
if (generation === requestGeneration) run(loaded);
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function setPrefetchDecoder(fn: PrefetchDecoder): void {
|
|
48
|
+
decoder = fn;
|
|
49
|
+
runtime?.setPrefetchDecoder(fn);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function setPrefetchConcurrency(value: number): void {
|
|
53
|
+
concurrency = value;
|
|
54
|
+
runtime?.setPrefetchConcurrency(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function prefetchDirect(
|
|
58
|
+
url: string,
|
|
59
|
+
segmentIds: string[],
|
|
60
|
+
version?: string,
|
|
61
|
+
routerId?: string,
|
|
62
|
+
prefetchKey?: ":source",
|
|
63
|
+
): void {
|
|
64
|
+
withRuntime((loaded) =>
|
|
65
|
+
loaded.prefetchDirect(url, segmentIds, version, routerId, prefetchKey),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function prefetchQueued(
|
|
70
|
+
url: string,
|
|
71
|
+
segmentIds: string[],
|
|
72
|
+
version?: string,
|
|
73
|
+
routerId?: string,
|
|
74
|
+
prefetchKey?: ":source",
|
|
75
|
+
): void {
|
|
76
|
+
withRuntime((loaded) =>
|
|
77
|
+
loaded.prefetchQueued(url, segmentIds, version, routerId, prefetchKey),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function observeForPrefetch(
|
|
82
|
+
element: Element,
|
|
83
|
+
callback: () => void,
|
|
84
|
+
): () => void {
|
|
85
|
+
if (typeof IntersectionObserver === "undefined") return () => {};
|
|
86
|
+
let active = true;
|
|
87
|
+
if (runtime) {
|
|
88
|
+
runtime.observeForPrefetch(element, callback);
|
|
89
|
+
} else {
|
|
90
|
+
void loadRuntime()
|
|
91
|
+
.then((loaded) => {
|
|
92
|
+
if (active) loaded.observeForPrefetch(element, callback);
|
|
93
|
+
})
|
|
94
|
+
.catch(() => {});
|
|
95
|
+
}
|
|
96
|
+
return () => {
|
|
97
|
+
active = false;
|
|
98
|
+
runtime?.unobserveForPrefetch(element);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function cancelAllPrefetches(keepUrl?: string | null): void {
|
|
103
|
+
requestGeneration++;
|
|
104
|
+
runtime?.cancelAllPrefetches(keepUrl);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function abortAllPrefetches(): void {
|
|
108
|
+
requestGeneration++;
|
|
109
|
+
runtime?.abortAllPrefetches();
|
|
110
|
+
}
|
|
@@ -32,12 +32,12 @@ export type LinkState =
|
|
|
32
32
|
| LocationStateEntry[]
|
|
33
33
|
| StateOrGetter<Record<string, unknown>>;
|
|
34
34
|
|
|
35
|
-
import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
|
|
36
|
-
import { getAppVersion } from "../app-version.js";
|
|
37
35
|
import {
|
|
38
36
|
observeForPrefetch,
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
prefetchDirect,
|
|
38
|
+
prefetchQueued,
|
|
39
|
+
} from "../prefetch/loader.js";
|
|
40
|
+
import { getAppVersion } from "../app-version.js";
|
|
41
41
|
|
|
42
42
|
// The (hover: none) MediaQueryList, created lazily on first client read and
|
|
43
43
|
// reused across every Link render. matchMedia allocates and registers a live
|
|
@@ -394,7 +394,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
394
394
|
|
|
395
395
|
let cancelled = false;
|
|
396
396
|
let unsubIdle: (() => void) | undefined;
|
|
397
|
-
let
|
|
397
|
+
let stopObserving: (() => void) | undefined;
|
|
398
398
|
|
|
399
399
|
const triggerPrefetch = () => {
|
|
400
400
|
if (cancelled) return;
|
|
@@ -431,8 +431,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
431
431
|
} else if (isViewport) {
|
|
432
432
|
const element = internalRef.current;
|
|
433
433
|
if (!element) return;
|
|
434
|
-
|
|
435
|
-
observeForPrefetch(element, () => {
|
|
434
|
+
stopObserving = observeForPrefetch(element, () => {
|
|
436
435
|
scheduleWhenIdle(triggerPrefetch);
|
|
437
436
|
});
|
|
438
437
|
}
|
|
@@ -440,9 +439,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
440
439
|
return () => {
|
|
441
440
|
cancelled = true;
|
|
442
441
|
unsubIdle?.();
|
|
443
|
-
|
|
444
|
-
unobserveForPrefetch(observedElement);
|
|
445
|
-
}
|
|
442
|
+
stopObserving?.();
|
|
446
443
|
};
|
|
447
444
|
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
448
445
|
|
|
@@ -26,7 +26,7 @@ import type { HandleData } from "../types.js";
|
|
|
26
26
|
import { ThemeProvider } from "../../theme/ThemeProvider.js";
|
|
27
27
|
import { NonceContext } from "./nonce-context.js";
|
|
28
28
|
import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
|
|
29
|
-
import { cancelAllPrefetches } from "../prefetch/
|
|
29
|
+
import { cancelAllPrefetches } from "../prefetch/loader.js";
|
|
30
30
|
import { handleNavigationEnd } from "../scroll-restoration.js";
|
|
31
31
|
import { createAppShellRef, type AppShellRef } from "../app-shell.js";
|
|
32
32
|
import { startConnectionWarmup } from "../connection-warmup.js";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useContext, useMemo } from "react";
|
|
4
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
|
-
import { prefetchDirect } from "../prefetch/
|
|
5
|
+
import { prefetchDirect } from "../prefetch/loader.js";
|
|
6
6
|
import { getAppVersion } from "../app-version.js";
|
|
7
7
|
import type { RouterInstance, RouterNavigateOptions } from "../types.js";
|
|
8
8
|
|
|
@@ -26,8 +26,10 @@ import { expandSegmentFragments } from "../segment-fragments.js";
|
|
|
26
26
|
import { initRangoState } from "./rango-state.js";
|
|
27
27
|
import { registerNavigationStore } from "./navigation-store-handle.js";
|
|
28
28
|
import { initPrefetchCache } from "./prefetch/cache.js";
|
|
29
|
-
import {
|
|
30
|
-
|
|
29
|
+
import {
|
|
30
|
+
setPrefetchConcurrency,
|
|
31
|
+
setPrefetchDecoder,
|
|
32
|
+
} from "./prefetch/loader.js";
|
|
31
33
|
import { setAppVersion } from "./app-version.js";
|
|
32
34
|
import {
|
|
33
35
|
isInterceptSegment,
|