@rangojs/router 0.1.0 → 0.2.0
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/README.md +2 -1
- 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/document-cache.d.ts +3 -1
- 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 +25 -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-definition/helpers-types.d.ts +6 -6
- package/dist/types/route-map-builder.d.ts +4 -29
- package/dist/types/router/match-handlers.d.ts +2 -3
- package/dist/types/router/prerender-match.d.ts +4 -1
- package/dist/types/router/router-interfaces.d.ts +3 -1
- package/dist/types/router/segment-resolution/view-transition-default.d.ts +3 -4
- package/dist/types/router/transition-when.d.ts +13 -0
- 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/shell-serve.d.ts +2 -0
- package/dist/types/rsc/transition-gate.d.ts +10 -14
- 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 +22 -8
- package/dist/types/server.d.ts +1 -1
- package/dist/types/testing/e2e/index.d.ts +1 -1
- package/dist/types/testing/index.d.ts +2 -2
- package/dist/types/testing/run-transition-when.d.ts +6 -5
- package/dist/types/testing/shell-status.d.ts +23 -3
- package/dist/types/types/route-entry.d.ts +3 -0
- package/dist/types/types/segments.d.ts +27 -22
- package/dist/types/urls/path-helper-types.d.ts +4 -3
- 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 +24 -20
- package/skills/cache-guide/SKILL.md +6 -3
- package/skills/caching/SKILL.md +1 -1
- package/skills/catalog.json +7 -1
- package/skills/deployment-caching/SKILL.md +176 -0
- package/skills/document-cache/SKILL.md +30 -3
- package/skills/ppr/SKILL.md +90 -14
- package/skills/prerender/SKILL.md +15 -8
- package/skills/rango/SKILL.md +20 -17
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/cache-prerender.md +5 -1
- package/skills/vercel/SKILL.md +22 -1
- package/skills/view-transitions/SKILL.md +12 -8
- 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 +23 -7
- package/src/cache/cf/cf-cache-store.ts +63 -39
- package/src/cache/document-cache.ts +4 -2
- package/src/cache/memory-segment-store.ts +17 -6
- package/src/cache/shell-snapshot.ts +45 -15
- package/src/cache/types.ts +24 -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-definition/helpers-types.ts +6 -6
- 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/match-handlers.ts +44 -6
- package/src/router/match-middleware/cache-lookup.ts +10 -3
- 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/segment-resolution/view-transition-default.ts +9 -5
- package/src/router/transition-when.ts +76 -0
- 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/progressive-enhancement.ts +9 -14
- package/src/rsc/render-pipeline.ts +10 -2
- package/src/rsc/rsc-rendering.ts +319 -147
- package/src/rsc/shell-capture.ts +34 -11
- package/src/rsc/shell-serve.ts +3 -0
- package/src/rsc/transition-gate.ts +37 -40
- package/src/rsc/types.ts +2 -0
- package/src/server/context.ts +28 -0
- package/src/server/request-context.ts +38 -10
- package/src/server.ts +0 -2
- package/src/testing/dispatch.ts +1 -0
- package/src/testing/e2e/index.ts +5 -0
- package/src/testing/index.ts +9 -1
- package/src/testing/run-transition-when.ts +42 -9
- package/src/testing/shell-status.ts +92 -3
- package/src/types/route-entry.ts +3 -0
- package/src/types/segments.ts +27 -22
- package/src/urls/include-helper.ts +1 -0
- package/src/urls/path-helper-types.ts +4 -3
- 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
|
@@ -209,9 +209,8 @@ interface TransitionConfig {
|
|
|
209
209
|
default?: string | Record<string, string>; // fallback for any phase
|
|
210
210
|
name?: string; // explicit view-transition-name
|
|
211
211
|
viewTransition?: "auto" | false; // boundary opt-out (see below)
|
|
212
|
-
// Conditional
|
|
213
|
-
//
|
|
214
|
-
// loading() fallback instead of holding. See the gate section below.
|
|
212
|
+
// Conditional server-side gate. PPR routes run it before route handlers and
|
|
213
|
+
// on every replay; other routes run it after handlers on fresh resolution.
|
|
215
214
|
when?: (ctx: TransitionWhenContext) => boolean;
|
|
216
215
|
}
|
|
217
216
|
```
|
|
@@ -223,14 +222,19 @@ interface TransitionConfig {
|
|
|
223
222
|
|
|
224
223
|
## Conditional transitions (`when`)
|
|
225
224
|
|
|
226
|
-
`transition({ when })` gates the hold per request. The predicate runs
|
|
225
|
+
`transition({ when })` gates the hold per request. The predicate runs server-side and outside any cache scope; return `false` to drop this segment's transition for the request (the navigation streams its `loading()` fallback instead of holding).
|
|
227
226
|
|
|
228
|
-
|
|
227
|
+
Timing follows the route's rendering contract:
|
|
228
|
+
|
|
229
|
+
- On an ordinary route it runs after the route handler during fresh resolution, so `get()` can read handler- and middleware-set context. Cache/prerender hits replay the stored decision.
|
|
230
|
+
- On a `ppr` route it is automatically hoisted before route handlers and runs on every match, including runtime-cache, prerender, document-shell, and partial-navigation replay. It can read URL/params/action metadata, `env`, and middleware-set context, but not values set by route handlers. This is what keeps the handler-free PPR fast path available without a second API.
|
|
231
|
+
|
|
232
|
+
Its context mirrors the `revalidate()` predicate args — the same navigation/action metadata — plus `get`/`env` for request-context reads:
|
|
229
233
|
|
|
230
234
|
```ts
|
|
231
235
|
import type { TransitionWhenContext } from "@rangojs/router";
|
|
232
236
|
|
|
233
|
-
//
|
|
237
|
+
// Ordinary route: hold only when the handler marked this request:
|
|
234
238
|
transition({ when: (ctx) => ctx.get(KeepScroll) === true });
|
|
235
239
|
|
|
236
240
|
// Hold only when arriving from a specific page (the navigation SOURCE):
|
|
@@ -252,13 +256,13 @@ transition({
|
|
|
252
256
|
| `toRouteName` (and `fromRouteName`) | route **name** | when the route is named (undefined for unnamed/auto-generated) |
|
|
253
257
|
| `actionId` / `actionUrl` / `actionResult` / `formData` | the server action that triggered this render | action-triggered renders only |
|
|
254
258
|
| `method` | `"GET"` (nav) / `"POST"` (action) | always |
|
|
255
|
-
| `get` / `env` | read
|
|
259
|
+
| `get` / `env` | read request vars + app env | always; PPR timing exposes middleware vars, not handler writes |
|
|
256
260
|
|
|
257
261
|
A predicate that throws is reported to `router.onError` (phase `"rendering"`) and treated as no-hold (conservative).
|
|
258
262
|
|
|
259
263
|
**Same-route content-holds need the transition present on the FIRST render.** The same-route hold works by giving the route a param-agnostic key so a param change reconciles instead of remounting — but that key is established when the route first mounts. A source gate that returns `false` on the initial full load (where `currentUrl`/`currentParams`/`fromRouteName` are undefined) drops the transition before the route mounts, so the route mounts _outside_ a transition scope and **every** later same-route param nav remounts (flashing the skeleton) regardless of what the gate decides on those navs. Write source gates so they hold when there is no source — e.g. `({ currentParams }) => currentParams?.tab !== "raw"` (true on the initial load) rather than `=== "details"` (false on the initial load) — when the same-route content-hold must engage. This only affects same-route param navigations; action-only or cross-route gating is unaffected (no shared param key is in play).
|
|
260
264
|
|
|
261
|
-
**Prefetch / cache caveat.**
|
|
265
|
+
**Prefetch / cache caveat.** A **prefetched** navigation still decides at prefetch time — `currentUrl`/`currentParams`/`fromRouteName` reflect the page the prefetch fired from, not necessarily the click-time source. Non-PPR `cache()`/prerender hits also replay the stored transition without rerunning the predicate. PPR routes rerun it on the server for each cache/prerender/PPR match, but a completed browser prefetch still carries its earlier Flight decision. If the exact click-time source matters, source-scope the prefetch (`<Link prefetchKey=":source">`).
|
|
262
266
|
|
|
263
267
|
## Opting out of the router boundary (place your own `<ViewTransition>`)
|
|
264
268
|
|
|
@@ -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
|
+
}
|