@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2
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 +6 -10
- package/README.md +289 -938
- package/dist/bin/rango.js +271 -46
- package/dist/vite/index.js +673 -193
- package/package.json +10 -8
- package/skills/api-client/SKILL.md +1 -1
- package/skills/breadcrumbs/SKILL.md +31 -14
- package/skills/cache-guide/SKILL.md +5 -2
- package/skills/caching/SKILL.md +59 -4
- package/skills/catalog.json +271 -0
- package/skills/comparison/SKILL.md +50 -0
- package/skills/comparison/agents/openai.yaml +4 -0
- package/skills/comparison/references/framework-comparison.md +837 -0
- package/skills/composability/SKILL.md +83 -2
- package/skills/debug-manifest/SKILL.md +1 -1
- package/skills/defer-hydration/SKILL.md +235 -0
- package/skills/document-cache/SKILL.md +9 -1
- package/skills/fonts/SKILL.md +1 -1
- package/skills/handler-use/SKILL.md +8 -8
- package/skills/hooks/SKILL.md +54 -892
- package/skills/hooks/data.md +273 -0
- package/skills/hooks/handle-and-actions.md +103 -0
- package/skills/hooks/navigation.md +110 -0
- package/skills/hooks/outlets.md +41 -0
- package/skills/hooks/state.md +228 -0
- package/skills/hooks/urls.md +135 -0
- package/skills/host-router/SKILL.md +4 -4
- package/skills/i18n/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +46 -14
- package/skills/layout/SKILL.md +27 -10
- package/skills/links/SKILL.md +1 -1
- package/skills/loader/SKILL.md +23 -1
- package/skills/middleware/SKILL.md +7 -3
- package/skills/migrate-nextjs/SKILL.md +167 -6
- package/skills/migrate-react-router/SKILL.md +59 -677
- package/skills/migrate-react-router/cloudflare-workers.md +129 -0
- package/skills/migrate-react-router/component-migration.md +196 -0
- package/skills/migrate-react-router/data-and-actions.md +225 -0
- package/skills/migrate-react-router/route-mapping.md +271 -0
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/observability/SKILL.md +9 -1
- package/skills/parallel/SKILL.md +23 -4
- package/skills/ppr/SKILL.md +622 -0
- package/skills/prerender/SKILL.md +28 -18
- package/skills/rango/SKILL.md +84 -25
- package/skills/response-routes/SKILL.md +15 -1
- package/skills/route/SKILL.md +71 -4
- package/skills/router-setup/SKILL.md +14 -3
- package/skills/scripts/SKILL.md +1 -1
- package/skills/server-actions/SKILL.md +3 -2
- package/skills/shell-manifest/SKILL.md +185 -0
- package/skills/streams-and-websockets/SKILL.md +1 -1
- package/skills/tailwind/SKILL.md +1 -1
- package/skills/testing/SKILL.md +2 -1
- package/skills/testing/handles.md +4 -2
- package/skills/testing/render-handler.md +15 -14
- package/skills/testing/reverse-and-types.md +8 -7
- package/skills/theme/SKILL.md +1 -1
- package/skills/typesafety/SKILL.md +45 -919
- package/skills/typesafety/env-and-bindings.md +254 -0
- package/skills/typesafety/generated-files-and-cli.md +335 -0
- package/skills/typesafety/params-and-search.md +153 -0
- package/skills/typesafety/route-types.md +209 -0
- package/skills/use-cache/SKILL.md +30 -3
- package/skills/vercel/SKILL.md +1 -1
- package/skills/view-transitions/SKILL.md +44 -1
- package/src/browser/event-controller.ts +62 -10
- package/src/browser/logging.ts +28 -0
- package/src/browser/merge-segment-loaders.ts +6 -4
- package/src/browser/navigation-bridge.ts +65 -16
- package/src/browser/navigation-client.ts +32 -2
- package/src/browser/navigation-store.ts +128 -14
- package/src/browser/network-error-handler.ts +34 -7
- package/src/browser/partial-update.ts +76 -17
- package/src/browser/prefetch/cache.ts +51 -11
- package/src/browser/prefetch/fetch.ts +59 -21
- package/src/browser/prefetch/queue.ts +19 -4
- package/src/browser/react/Link.tsx +13 -3
- package/src/browser/react/NavigationProvider.tsx +108 -4
- package/src/browser/response-adapter.ts +38 -9
- package/src/browser/rsc-router.tsx +54 -4
- package/src/browser/scroll-restoration.ts +7 -5
- package/src/browser/segment-reconciler.ts +31 -21
- package/src/browser/server-action-bridge.ts +22 -10
- package/src/browser/types.ts +54 -1
- package/src/build/generate-manifest.ts +155 -131
- package/src/build/index.ts +3 -1
- package/src/build/route-trie.ts +35 -7
- package/src/build/route-types/include-resolution.ts +347 -47
- package/src/build/runtime-discovery.ts +4 -1
- package/src/cache/cache-key-utils.ts +29 -0
- package/src/cache/cache-runtime.ts +262 -71
- package/src/cache/cache-scope.ts +2 -17
- package/src/cache/cache-tag.ts +60 -14
- package/src/cache/cf/cf-cache-store.ts +243 -20
- package/src/cache/document-cache.ts +54 -21
- package/src/cache/index.ts +1 -0
- package/src/cache/memory-segment-store.ts +110 -3
- package/src/cache/profile-registry.ts +15 -0
- package/src/cache/read-through-swr.ts +15 -1
- package/src/cache/segment-codec.ts +4 -4
- package/src/cache/shell-snapshot.ts +417 -0
- package/src/cache/types.ts +158 -0
- package/src/cache/vercel/vercel-cache-store.ts +401 -124
- package/src/client.rsc.tsx +0 -3
- package/src/client.tsx +0 -3
- package/src/cloudflare/tracing.ts +7 -8
- package/src/defer.ts +11 -22
- package/src/handle.ts +37 -15
- package/src/handles/MetaTags.tsx +16 -82
- package/src/handles/breadcrumbs.ts +12 -14
- package/src/handles/deferred-resolution.ts +127 -0
- package/src/handles/is-thenable.ts +7 -8
- package/src/handles/meta.ts +7 -44
- package/src/host/errors.ts +15 -0
- package/src/host/index.ts +1 -0
- package/src/index.rsc.ts +8 -2
- package/src/index.ts +19 -13
- package/src/internal-debug.ts +11 -8
- package/src/prerender.ts +17 -4
- package/src/redirect-origin.ts +14 -0
- package/src/render-error-thrower.tsx +20 -0
- package/src/route-content-wrapper.tsx +12 -5
- package/src/route-definition/dsl-helpers.ts +21 -32
- package/src/route-definition/helper-factories.ts +0 -2
- package/src/route-definition/helpers-types.ts +43 -43
- package/src/route-definition/index.ts +1 -2
- package/src/route-definition/resolve-handler-use.ts +0 -1
- package/src/route-definition/use-item-types.ts +3 -6
- package/src/route-map-builder.ts +41 -4
- package/src/route-types.ts +0 -5
- package/src/router/find-match.ts +86 -8
- package/src/router/instrument.ts +9 -4
- package/src/router/lazy-includes.ts +72 -12
- package/src/router/loader-resolution.ts +14 -2
- package/src/router/manifest.ts +56 -11
- package/src/router/match-api.ts +76 -32
- package/src/router/match-handlers.ts +181 -135
- package/src/router/match-middleware/background-revalidation.ts +40 -23
- package/src/router/match-middleware/cache-store.ts +39 -24
- package/src/router/match-result.ts +35 -15
- package/src/router/middleware.ts +64 -38
- package/src/router/navigation-snapshot.ts +7 -5
- package/src/router/parse-pattern.ts +115 -0
- package/src/router/pattern-matching.ts +53 -64
- package/src/router/prefetch-limits.ts +37 -0
- package/src/router/prerender-match.ts +11 -5
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +23 -8
- package/src/router/route-snapshot.ts +14 -2
- package/src/router/router-context.ts +3 -1
- package/src/router/router-interfaces.ts +32 -1
- package/src/router/router-options.ts +30 -0
- package/src/router/segment-resolution/fresh.ts +39 -3
- package/src/router/segment-resolution/loader-cache.ts +93 -2
- package/src/router/segment-resolution/loader-mask.ts +60 -0
- package/src/router/segment-resolution/loader-snapshot.ts +259 -0
- package/src/router/segment-resolution/mask-nested.ts +83 -0
- package/src/router/segment-resolution/revalidation.ts +3 -0
- package/src/router/segment-resolution/view-transition-default.ts +35 -15
- package/src/router/substitute-pattern-params.ts +54 -35
- package/src/router/telemetry-otel.ts +6 -8
- package/src/router/telemetry.ts +9 -1
- package/src/router/tracing.ts +14 -5
- package/src/router/trie-matching.ts +19 -11
- package/src/router/url-params.ts +13 -0
- package/src/router.ts +47 -16
- package/src/rsc/full-payload.ts +70 -0
- package/src/rsc/handler.ts +60 -33
- package/src/rsc/manifest-init.ts +1 -1
- package/src/rsc/nonce.ts +10 -1
- package/src/rsc/progressive-enhancement.ts +61 -4
- package/src/rsc/redirect-guard.ts +2 -1
- package/src/rsc/rsc-rendering.ts +429 -37
- package/src/rsc/server-action.ts +25 -2
- package/src/rsc/shell-capture.ts +1190 -0
- package/src/rsc/shell-serve.ts +181 -0
- package/src/rsc/transition-gate.ts +89 -0
- package/src/rsc/types.ts +30 -0
- package/src/segment-loader-promise.ts +18 -0
- package/src/segment-system.tsx +149 -14
- package/src/server/context.ts +67 -9
- package/src/server/cookie-store.ts +73 -1
- package/src/server/loader-registry.ts +13 -1
- package/src/server/request-context.ts +169 -10
- package/src/ssr/index.tsx +462 -178
- package/src/ssr/inject-rsc-eager.ts +167 -0
- package/src/ssr/ssr-root.tsx +228 -0
- package/src/testing/collect-handle.ts +14 -8
- package/src/testing/dispatch.ts +152 -40
- package/src/testing/generated-routes.ts +27 -11
- package/src/testing/index.ts +6 -0
- package/src/testing/render-handler.ts +14 -0
- package/src/testing/render-route.tsx +13 -10
- package/src/testing/run-transition-when.ts +164 -0
- package/src/theme/ThemeProvider.tsx +36 -26
- package/src/types/handler-context.ts +1 -1
- package/src/types/index.ts +2 -0
- package/src/types/route-config.ts +19 -7
- package/src/types/segments.ts +100 -0
- package/src/urls/include-helper.ts +10 -8
- package/src/urls/include-provider.ts +71 -0
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper-types.ts +44 -12
- package/src/urls/path-helper.ts +5 -0
- package/src/urls/pattern-types.ts +36 -0
- package/src/urls/type-extraction.ts +43 -18
- package/src/urls/urls-function.ts +0 -1
- package/src/vercel/tracing.ts +7 -7
- package/src/vite/discovery/dev-prerender-cache.ts +117 -0
- package/src/vite/discovery/discover-routers.ts +1 -1
- package/src/vite/discovery/discovery-errors.ts +61 -0
- package/src/vite/index.ts +7 -0
- package/src/vite/inject-client-debug.ts +88 -0
- package/src/vite/plugins/vercel-output.ts +114 -25
- package/src/vite/plugins/version-injector.ts +22 -7
- package/src/vite/plugins/virtual-entries.ts +80 -22
- package/src/vite/rango.ts +29 -19
- package/src/vite/router-discovery.ts +171 -43
- package/src/vite/utils/prerender-utils.ts +17 -4
- package/src/vite/utils/shared-utils.ts +47 -0
- package/src/network-error-thrower.tsx +0 -18
|
@@ -20,7 +20,11 @@ import {
|
|
|
20
20
|
} from "./intercept-utils.js";
|
|
21
21
|
import type { BoundTransaction } from "./navigation-transaction.js";
|
|
22
22
|
import { ServerRedirect } from "../errors.js";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
debugLog,
|
|
25
|
+
isBrowserDebugEnabled,
|
|
26
|
+
IS_BROWSER_DEBUG,
|
|
27
|
+
} from "./logging.js";
|
|
24
28
|
import {
|
|
25
29
|
validateRedirectOrigin,
|
|
26
30
|
validateExternalRedirect,
|
|
@@ -147,9 +151,11 @@ export function createPartialUpdater(
|
|
|
147
151
|
currentCached.filter(isInterceptSegment).map((s) => s.id),
|
|
148
152
|
);
|
|
149
153
|
segments = currentSegments.filter((id) => !interceptIds.has(id));
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
154
|
+
if (IS_BROWSER_DEBUG) {
|
|
155
|
+
debugLog(
|
|
156
|
+
`[Browser] Leaving intercept - filtered segments: ${segments.join(", ")}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
153
159
|
} else {
|
|
154
160
|
segments = segmentIds ?? segmentState.currentSegmentIds;
|
|
155
161
|
}
|
|
@@ -159,12 +165,14 @@ export function createPartialUpdater(
|
|
|
159
165
|
? segmentState.currentUrl || tx.currentUrl
|
|
160
166
|
: interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
|
|
161
167
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
if (IS_BROWSER_DEBUG) {
|
|
169
|
+
debugLog(`\n[Browser] >>> NAVIGATION`);
|
|
170
|
+
debugLog(`[Browser] From: ${previousUrl}`);
|
|
171
|
+
debugLog(`[Browser] To: ${url}`);
|
|
172
|
+
debugLog(`[Browser] Segments to send: ${segments.join(", ")}`);
|
|
173
|
+
if (interceptSourceUrl) {
|
|
174
|
+
debugLog(`[Browser] Intercept context from: ${interceptSourceUrl}`);
|
|
175
|
+
}
|
|
168
176
|
}
|
|
169
177
|
|
|
170
178
|
const targetCache =
|
|
@@ -173,9 +181,11 @@ export function createPartialUpdater(
|
|
|
173
181
|
: undefined;
|
|
174
182
|
const cachedSegs = targetCache ?? getCurrentCachedSegments();
|
|
175
183
|
const cachedSegsSource = targetCache ? "history-cache" : "current-page";
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
if (IS_BROWSER_DEBUG) {
|
|
185
|
+
debugLog(
|
|
186
|
+
`[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
179
189
|
|
|
180
190
|
let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
|
|
181
191
|
fetchResult = await client.fetchPartial({
|
|
@@ -188,7 +198,11 @@ export function createPartialUpdater(
|
|
|
188
198
|
routerId: store.getRouterId?.(),
|
|
189
199
|
});
|
|
190
200
|
const streamingToken = tx.startStreaming();
|
|
191
|
-
const {
|
|
201
|
+
const {
|
|
202
|
+
payload,
|
|
203
|
+
streamComplete: rawStreamComplete,
|
|
204
|
+
fullyPrefetched,
|
|
205
|
+
} = fetchResult;
|
|
192
206
|
debugLog("payload.metadata", payload.metadata);
|
|
193
207
|
|
|
194
208
|
// Side effect only: end the streaming token once the stream settles.
|
|
@@ -254,8 +268,10 @@ export function createPartialUpdater(
|
|
|
254
268
|
return;
|
|
255
269
|
}
|
|
256
270
|
|
|
257
|
-
|
|
258
|
-
|
|
271
|
+
if (IS_BROWSER_DEBUG) {
|
|
272
|
+
debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
|
|
273
|
+
debugLog(`[Browser] Diff: ${diff?.join(", ")}`);
|
|
274
|
+
}
|
|
259
275
|
|
|
260
276
|
if (!diff || diff.length === 0) {
|
|
261
277
|
const matchedIds = matched || [];
|
|
@@ -379,6 +395,13 @@ export function createPartialUpdater(
|
|
|
379
395
|
return;
|
|
380
396
|
}
|
|
381
397
|
if (mode.type === "action") {
|
|
398
|
+
// An action refetch that lands on missing segments (navigated away /
|
|
399
|
+
// consolidation / HMR) drops rather than refetch-all: the action flow
|
|
400
|
+
// is storeOnly / skipLoadingState, so a full refetch here would fight
|
|
401
|
+
// it. Keep the stale-but-consistent tree; log so the drop is visible.
|
|
402
|
+
debugLog(
|
|
403
|
+
`[Browser] Action refetch: ${missingCount} segments missing; dropping (stale-but-consistent tree kept).`,
|
|
404
|
+
);
|
|
382
405
|
return;
|
|
383
406
|
}
|
|
384
407
|
console.warn(
|
|
@@ -395,7 +418,14 @@ export function createPartialUpdater(
|
|
|
395
418
|
|
|
396
419
|
const renderOptions = {
|
|
397
420
|
isAction: mode.type === "action",
|
|
398
|
-
forceAwait
|
|
421
|
+
// forceAwait unwraps the ROUTER loader promises during render so they
|
|
422
|
+
// land without a loading()/fallback frame. A fully-prefetched nav has
|
|
423
|
+
// its router data already resolved (the prefetch stream drained), so
|
|
424
|
+
// awaiting it here is free and lets us commit NORMALLY (not in a
|
|
425
|
+
// transition) below — a normal commit still shows fallbacks for any
|
|
426
|
+
// CLIENT component that suspends on mount, which a transition would
|
|
427
|
+
// wrongly suppress by holding the old UI until that suspense settles.
|
|
428
|
+
forceAwait: mode.type === "stale-revalidation" || fullyPrefetched,
|
|
399
429
|
interceptSegments:
|
|
400
430
|
reconciled.interceptSegments.length > 0
|
|
401
431
|
? reconciled.interceptSegments
|
|
@@ -480,6 +510,21 @@ export function createPartialUpdater(
|
|
|
480
510
|
debugLog("[partial-update] updating document");
|
|
481
511
|
|
|
482
512
|
const hasTransition = shouldStartViewTransition(reconciled.segments);
|
|
513
|
+
// [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Reports which reconciled
|
|
514
|
+
// segment still carries a transition after the server-side when-gate, and
|
|
515
|
+
// whether the commit will be held in a startTransition. If `withTransition`
|
|
516
|
+
// lists an ancestor (layout/root) id rather than the gated leaf, an ungated
|
|
517
|
+
// ancestor transition is holding the subtree (missing loading() fallback).
|
|
518
|
+
if (isBrowserDebugEnabled()) {
|
|
519
|
+
debugLog("[VT-DIAG] commit", {
|
|
520
|
+
mode: mode.type,
|
|
521
|
+
hasTransition,
|
|
522
|
+
withTransition: reconciled.segments
|
|
523
|
+
.filter((s) => s.transition)
|
|
524
|
+
.map((s) => s.id),
|
|
525
|
+
all: reconciled.segments.map((s) => s.id),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
483
528
|
const scrollPayload = toScrollPayload(navScroll);
|
|
484
529
|
|
|
485
530
|
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
@@ -505,6 +550,20 @@ export function createPartialUpdater(
|
|
|
505
550
|
});
|
|
506
551
|
});
|
|
507
552
|
} else {
|
|
553
|
+
// Normal commit (cold/partial nav AND fully-prefetched nav). For a
|
|
554
|
+
// fully-prefetched nav, renderOptions.forceAwait (above) unwrapped the
|
|
555
|
+
// already-resolved ROUTER loader data AND route content during render, so
|
|
556
|
+
// the new tree carries it inline with no loading()/fallback frame — yet we
|
|
557
|
+
// still commit NORMALLY here rather than in a transition. A transition
|
|
558
|
+
// holds the OLD UI until ALL suspense in the new tree settles, including a
|
|
559
|
+
// CLIENT component that starts its own data request only when mounted
|
|
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).
|
|
508
567
|
onUpdate({
|
|
509
568
|
root: newTree,
|
|
510
569
|
metadata: payload.metadata!,
|
|
@@ -65,17 +65,36 @@ export interface DecodedPrefetch {
|
|
|
65
65
|
* when it adopted an inflight entry through the wildcard key.
|
|
66
66
|
*/
|
|
67
67
|
scope: "source" | "wildcard";
|
|
68
|
+
/**
|
|
69
|
+
* Synchronously-readable flag, flipped to true when `streamComplete` resolves
|
|
70
|
+
* (the entire RSC stream has drained). Navigation reads this at click time to
|
|
71
|
+
* tell a FULLY warmed prefetch (payload commits without suspending → safe to
|
|
72
|
+
* commit in a startTransition, no fallback flash) from a partially-warmed one
|
|
73
|
+
* (still streaming → stream its fallbacks like a cold load). Starts false.
|
|
74
|
+
*/
|
|
75
|
+
complete: boolean;
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
let cacheTTL = 300_000;
|
|
71
79
|
|
|
80
|
+
// Max stored entries before FIFO eviction. Mirrors DEFAULT_PREFETCH_CACHE_SIZE
|
|
81
|
+
// (router/prefetch-limits.ts); kept as a local literal so the client bundle
|
|
82
|
+
// doesn't pull in router-layer code, matching the cacheTTL default above.
|
|
83
|
+
// Overridden at startup by initPrefetchCache from server metadata.
|
|
84
|
+
let maxPrefetchCacheSize = 100;
|
|
85
|
+
|
|
72
86
|
/**
|
|
73
|
-
* Initialize the prefetch cache with the configured TTL.
|
|
74
|
-
* Called once at app startup with the
|
|
75
|
-
*
|
|
87
|
+
* Initialize the prefetch cache with the configured TTL and max size.
|
|
88
|
+
* Called once at app startup with the values from server metadata. Each
|
|
89
|
+
* argument is applied only when provided, so a caller can set just the TTL.
|
|
90
|
+
* A TTL of 0 disables the in-memory cache and all prefetching. A size below 1
|
|
91
|
+
* is ignored (the default is kept) — disabling prefetch is the TTL's job.
|
|
76
92
|
*/
|
|
77
|
-
export function initPrefetchCache(ttlMs
|
|
78
|
-
cacheTTL = ttlMs;
|
|
93
|
+
export function initPrefetchCache(ttlMs?: number, maxSize?: number): void {
|
|
94
|
+
if (ttlMs !== undefined) cacheTTL = ttlMs;
|
|
95
|
+
if (maxSize !== undefined && Number.isFinite(maxSize) && maxSize >= 1) {
|
|
96
|
+
maxPrefetchCacheSize = Math.floor(maxSize);
|
|
97
|
+
}
|
|
79
98
|
}
|
|
80
99
|
|
|
81
100
|
/**
|
|
@@ -85,7 +104,6 @@ export function initPrefetchCache(ttlMs: number): void {
|
|
|
85
104
|
export function isPrefetchCacheDisabled(): boolean {
|
|
86
105
|
return cacheTTL <= 0;
|
|
87
106
|
}
|
|
88
|
-
const MAX_PREFETCH_CACHE_SIZE = 50;
|
|
89
107
|
|
|
90
108
|
interface PrefetchCacheEntry {
|
|
91
109
|
entry: DecodedPrefetch;
|
|
@@ -95,6 +113,16 @@ interface PrefetchCacheEntry {
|
|
|
95
113
|
const cache = new Map<string, PrefetchCacheEntry>();
|
|
96
114
|
const inflight = new Set<string>();
|
|
97
115
|
|
|
116
|
+
// Watermark: the smallest entry timestamp currently in `cache`, i.e. a lower
|
|
117
|
+
// bound on when the oldest entry expires (timestamp + cacheTTL). storePrefetch's
|
|
118
|
+
// eager expiry sweep is skipped while `now - earliestTimestamp <= cacheTTL` — the
|
|
119
|
+
// oldest entry cannot be expired yet, so nothing would be evicted. Deletions
|
|
120
|
+
// (consume/has/remove) may leave it stale-LOW, which only forces a harmless extra
|
|
121
|
+
// sweep that recomputes it exactly; it is never stale-high, so a skipped sweep
|
|
122
|
+
// can never retain an expired entry. consume/has still TTL-check lazily, so a
|
|
123
|
+
// skipped sweep never serves a stale entry. Infinity when the cache is empty.
|
|
124
|
+
let earliestTimestamp = Infinity;
|
|
125
|
+
|
|
98
126
|
const inflightPromises = new Map<string, Promise<DecodedPrefetch | null>>();
|
|
99
127
|
|
|
100
128
|
const inflightAliases = new Map<string, string[]>();
|
|
@@ -251,21 +279,32 @@ export function storePrefetch(
|
|
|
251
279
|
return;
|
|
252
280
|
}
|
|
253
281
|
|
|
254
|
-
// Evict expired entries
|
|
282
|
+
// Evict expired entries — but only when the watermark says the oldest entry
|
|
283
|
+
// could have expired, so a burst of stores no longer walks the whole map each
|
|
284
|
+
// time. The sweep recomputes the exact watermark from the survivors.
|
|
255
285
|
const now = Date.now();
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
286
|
+
if (now - earliestTimestamp > cacheTTL) {
|
|
287
|
+
let min = Infinity;
|
|
288
|
+
for (const [k, cached] of cache) {
|
|
289
|
+
if (now - cached.timestamp > cacheTTL) {
|
|
290
|
+
cache.delete(k);
|
|
291
|
+
} else if (cached.timestamp < min) {
|
|
292
|
+
min = cached.timestamp;
|
|
293
|
+
}
|
|
259
294
|
}
|
|
295
|
+
earliestTimestamp = min;
|
|
260
296
|
}
|
|
261
297
|
|
|
262
298
|
// FIFO eviction if at capacity
|
|
263
|
-
if (cache.size >=
|
|
299
|
+
if (cache.size >= maxPrefetchCacheSize) {
|
|
264
300
|
const oldest = cache.keys().next().value;
|
|
265
301
|
if (oldest) cache.delete(oldest);
|
|
266
302
|
}
|
|
267
303
|
|
|
268
304
|
cache.set(key, { entry, timestamp: now });
|
|
305
|
+
// The new entry is the newest, so it only moves the watermark when the cache
|
|
306
|
+
// was empty (Infinity); otherwise the older watermark stands.
|
|
307
|
+
if (now < earliestTimestamp) earliestTimestamp = now;
|
|
269
308
|
}
|
|
270
309
|
|
|
271
310
|
/**
|
|
@@ -336,6 +375,7 @@ export function clearPrefetchCache(): void {
|
|
|
336
375
|
inflightAliases.clear();
|
|
337
376
|
adoptedKeys.clear();
|
|
338
377
|
cache.clear();
|
|
378
|
+
earliestTimestamp = Infinity;
|
|
339
379
|
abortAllPrefetches();
|
|
340
380
|
invalidateRangoState();
|
|
341
381
|
}
|
|
@@ -31,7 +31,7 @@ import { getRangoState } from "../rango-state.js";
|
|
|
31
31
|
import { isActionFenceActive } from "../action-fence.js";
|
|
32
32
|
import { enqueuePrefetch } from "./queue.js";
|
|
33
33
|
import { shouldPrefetch } from "./policy.js";
|
|
34
|
-
import { debugLog } from "../logging.js";
|
|
34
|
+
import { debugLog, IS_BROWSER_DEBUG } from "../logging.js";
|
|
35
35
|
import { teeWithCompletion, isForeignRouterId } from "../response-adapter.js";
|
|
36
36
|
import type { RscPayload } from "../types.js";
|
|
37
37
|
|
|
@@ -154,6 +154,8 @@ function executePrefetchFetch(
|
|
|
154
154
|
sourceKey: string,
|
|
155
155
|
fetchUrl: string,
|
|
156
156
|
forceSourceScope: boolean,
|
|
157
|
+
/** Rango state captured once by the caller (keys + header share one read). */
|
|
158
|
+
rangoState: string,
|
|
157
159
|
expectedRouterId?: string,
|
|
158
160
|
signal?: AbortSignal,
|
|
159
161
|
): Promise<DecodedPrefetch | null> {
|
|
@@ -214,7 +216,7 @@ function executePrefetchFetch(
|
|
|
214
216
|
...(isActionFenceActive() && { cache: "no-store" as RequestCache }),
|
|
215
217
|
signal: effectiveSignal,
|
|
216
218
|
headers: {
|
|
217
|
-
"X-Rango-State":
|
|
219
|
+
"X-Rango-State": rangoState,
|
|
218
220
|
"X-RSC-Router-Client-Path": window.location.href,
|
|
219
221
|
"X-Rango-Prefetch": "1",
|
|
220
222
|
},
|
|
@@ -244,14 +246,21 @@ function executePrefetchFetch(
|
|
|
244
246
|
const storageKey = scope === "source" ? sourceKey : wildcardKey;
|
|
245
247
|
|
|
246
248
|
// Track stream completion off a tee so navigation's scroll/revalidation
|
|
247
|
-
// gating matches the fresh-fetch path; decode the other branch.
|
|
249
|
+
// gating matches the fresh-fetch path; decode the other branch. The
|
|
250
|
+
// completion callback reports whether the stream ended on a clean EOF
|
|
251
|
+
// (true) or was aborted/errored (false) — only a clean end can mark the
|
|
252
|
+
// entry complete (see below).
|
|
248
253
|
let resolveStreamComplete!: () => void;
|
|
254
|
+
let endedCleanly = false;
|
|
249
255
|
const streamComplete = new Promise<void>((resolve) => {
|
|
250
256
|
resolveStreamComplete = resolve;
|
|
251
257
|
});
|
|
252
258
|
const tracked = teeWithCompletion(
|
|
253
259
|
response,
|
|
254
|
-
() =>
|
|
260
|
+
(clean) => {
|
|
261
|
+
endedCleanly = clean;
|
|
262
|
+
resolveStreamComplete();
|
|
263
|
+
},
|
|
255
264
|
effectiveSignal,
|
|
256
265
|
// Speculative prefetch: a never-consumed/aborted stream error is benign.
|
|
257
266
|
true,
|
|
@@ -264,7 +273,12 @@ function executePrefetchFetch(
|
|
|
264
273
|
// error is still surfaced to navigation if it consumes the entry.
|
|
265
274
|
payload.catch(() => {});
|
|
266
275
|
|
|
267
|
-
const entry: DecodedPrefetch = {
|
|
276
|
+
const entry: DecodedPrefetch = {
|
|
277
|
+
payload,
|
|
278
|
+
streamComplete,
|
|
279
|
+
scope,
|
|
280
|
+
complete: false,
|
|
281
|
+
};
|
|
268
282
|
storePrefetch(storageKey, entry, gen);
|
|
269
283
|
// The stall timeout now owns the body stream: arm eviction (publishedKey)
|
|
270
284
|
// and clear the timer once the stream completes. The tee's finally resolves
|
|
@@ -272,7 +286,23 @@ function executePrefetchFetch(
|
|
|
272
286
|
// no lingering timer while a stalled one is evicted when the timer fires.
|
|
273
287
|
publishedKey = storageKey;
|
|
274
288
|
publishedEntry = entry;
|
|
275
|
-
|
|
289
|
+
// Evict a broken prefetch IMMEDIATELY on the earliest failure signal — do not
|
|
290
|
+
// wait for both branches to settle. A decode that rejects while the tracking
|
|
291
|
+
// stream is still draining (or hung) would otherwise leave the rejected payload
|
|
292
|
+
// consumable (navigation reads entry.payload regardless of `complete`) until EOF
|
|
293
|
+
// or the stall timeout. removePrefetch is identity-guarded, so a fresh entry
|
|
294
|
+
// republished under the same key is never dropped, and a double call is a no-op.
|
|
295
|
+
payload.catch(() => removePrefetch(storageKey, entry));
|
|
296
|
+
streamComplete.then(() => {
|
|
297
|
+
if (!endedCleanly) removePrefetch(storageKey, entry);
|
|
298
|
+
});
|
|
299
|
+
// Mark complete ONLY on a fully-healthy prefetch (decode resolved AND clean EOF).
|
|
300
|
+
Promise.allSettled([payload, streamComplete]).then(([decode]) => {
|
|
301
|
+
if (decode.status === "fulfilled" && endedCleanly) {
|
|
302
|
+
entry.complete = true;
|
|
303
|
+
}
|
|
304
|
+
clearTimeout(timeoutId);
|
|
305
|
+
});
|
|
276
306
|
return entry;
|
|
277
307
|
})
|
|
278
308
|
.catch(() => null)
|
|
@@ -343,26 +373,31 @@ export function prefetchDirect(
|
|
|
343
373
|
const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
|
|
344
374
|
const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
|
|
345
375
|
if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
|
|
346
|
-
|
|
376
|
+
if (IS_BROWSER_DEBUG) {
|
|
377
|
+
debugLog("[prefetch] direct dedup (key already exists)", {
|
|
378
|
+
url,
|
|
379
|
+
wildcardKey,
|
|
380
|
+
sourceKey,
|
|
381
|
+
forceSourceScope,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (IS_BROWSER_DEBUG) {
|
|
387
|
+
debugLog("[prefetch] direct fetch", {
|
|
347
388
|
url,
|
|
348
389
|
wildcardKey,
|
|
349
390
|
sourceKey,
|
|
391
|
+
source: sourceHref,
|
|
350
392
|
forceSourceScope,
|
|
351
393
|
});
|
|
352
|
-
return;
|
|
353
394
|
}
|
|
354
|
-
debugLog("[prefetch] direct fetch", {
|
|
355
|
-
url,
|
|
356
|
-
wildcardKey,
|
|
357
|
-
sourceKey,
|
|
358
|
-
source: sourceHref,
|
|
359
|
-
forceSourceScope,
|
|
360
|
-
});
|
|
361
395
|
executePrefetchFetch(
|
|
362
396
|
wildcardKey,
|
|
363
397
|
sourceKey,
|
|
364
398
|
targetUrl.toString(),
|
|
365
399
|
forceSourceScope,
|
|
400
|
+
rangoState,
|
|
366
401
|
routerId,
|
|
367
402
|
);
|
|
368
403
|
}
|
|
@@ -393,12 +428,14 @@ export function prefetchQueued(
|
|
|
393
428
|
const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
|
|
394
429
|
const queueKey = forceSourceScope ? sourceKey : wildcardKey;
|
|
395
430
|
if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
431
|
+
if (IS_BROWSER_DEBUG) {
|
|
432
|
+
debugLog("[prefetch] queued dedup (key already exists)", {
|
|
433
|
+
url,
|
|
434
|
+
wildcardKey,
|
|
435
|
+
sourceKey,
|
|
436
|
+
forceSourceScope,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
402
439
|
return queueKey;
|
|
403
440
|
}
|
|
404
441
|
const fetchUrlStr = targetUrl.toString();
|
|
@@ -416,6 +453,7 @@ export function prefetchQueued(
|
|
|
416
453
|
sourceKey,
|
|
417
454
|
fetchUrlStr,
|
|
418
455
|
forceSourceScope,
|
|
456
|
+
rangoState,
|
|
419
457
|
routerId,
|
|
420
458
|
signal,
|
|
421
459
|
).then(() => {});
|
|
@@ -16,9 +16,24 @@
|
|
|
16
16
|
|
|
17
17
|
import { wait, waitForIdle, waitForViewportImages } from "./resource-ready.js";
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// Max prefetches executing at once. Mirrors DEFAULT_PREFETCH_CONCURRENCY
|
|
20
|
+
// (router/prefetch-limits.ts); kept as a local literal so the client bundle
|
|
21
|
+
// doesn't pull in router-layer code. Overridden at startup by
|
|
22
|
+
// setPrefetchConcurrency from server metadata.
|
|
23
|
+
let maxConcurrent = 2;
|
|
20
24
|
const IMAGE_WAIT_TIMEOUT = 2000;
|
|
21
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Set the max number of concurrently-executing speculative prefetches.
|
|
28
|
+
* Called once at app startup with the value from server metadata. A value
|
|
29
|
+
* below 1 (or non-finite) is ignored, keeping the default.
|
|
30
|
+
*/
|
|
31
|
+
export function setPrefetchConcurrency(n: number): void {
|
|
32
|
+
if (Number.isFinite(n) && n >= 1) {
|
|
33
|
+
maxConcurrent = Math.floor(n);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
let active = 0;
|
|
23
38
|
const queue: Array<{
|
|
24
39
|
key: string;
|
|
@@ -42,7 +57,7 @@ function startExecution(
|
|
|
42
57
|
abortControllers.delete(key);
|
|
43
58
|
// Only decrement if this key wasn't already cleared by cancelAllPrefetches.
|
|
44
59
|
// Without this guard, cancelled tasks' .finally() would underflow active
|
|
45
|
-
// below zero, breaking the
|
|
60
|
+
// below zero, breaking the maxConcurrent guarantee.
|
|
46
61
|
if (executing.delete(key)) {
|
|
47
62
|
active--;
|
|
48
63
|
}
|
|
@@ -63,7 +78,7 @@ function startExecution(
|
|
|
63
78
|
*/
|
|
64
79
|
function scheduleDrain(): void {
|
|
65
80
|
if (drainScheduled) return;
|
|
66
|
-
if (active >=
|
|
81
|
+
if (active >= maxConcurrent || queue.length === 0) return;
|
|
67
82
|
drainScheduled = true;
|
|
68
83
|
const gen = drainGeneration;
|
|
69
84
|
waitForIdle()
|
|
@@ -83,7 +98,7 @@ function scheduleDrain(): void {
|
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
function drain(): void {
|
|
86
|
-
while (active <
|
|
101
|
+
while (active < maxConcurrent && queue.length > 0) {
|
|
87
102
|
const item = queue.shift()!;
|
|
88
103
|
queued.delete(item.key);
|
|
89
104
|
startExecution(item.key, item.execute);
|
|
@@ -39,9 +39,16 @@ import {
|
|
|
39
39
|
unobserveForPrefetch,
|
|
40
40
|
} from "../prefetch/observer.js";
|
|
41
41
|
|
|
42
|
+
// The (hover: none) MediaQueryList, created lazily on first client read and
|
|
43
|
+
// reused across every Link render. matchMedia allocates and registers a live
|
|
44
|
+
// query object; a fresh one per render (Link renders can be very frequent) is
|
|
45
|
+
// wasteful when the same object's `.matches` is already live. Left null on the
|
|
46
|
+
// server (no window).
|
|
47
|
+
let hoverNoneQuery: MediaQueryList | null = null;
|
|
48
|
+
|
|
42
49
|
/**
|
|
43
|
-
* Read current touch/no-hover capability
|
|
44
|
-
*
|
|
50
|
+
* Read current touch/no-hover capability from the cached MediaQueryList. The
|
|
51
|
+
* `.matches` read is live, so `prefetch="adaptive"` still reacts to
|
|
45
52
|
* input-capability changes on hybrid devices (touch laptops, tablets gaining or
|
|
46
53
|
* losing a pointer) and after SSR -> hydrate. The SSR guard returns a stable
|
|
47
54
|
* `false` (pointer/hover default) so the resolved strategy doesn't drift on the
|
|
@@ -49,7 +56,10 @@ import {
|
|
|
49
56
|
*/
|
|
50
57
|
function isTouchDevice(): boolean {
|
|
51
58
|
if (typeof window === "undefined") return false;
|
|
52
|
-
|
|
59
|
+
if (!hoverNoneQuery) {
|
|
60
|
+
hoverNoneQuery = window.matchMedia("(hover: none)");
|
|
61
|
+
}
|
|
62
|
+
return hoverNoneQuery.matches;
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
/**
|
|
@@ -31,6 +31,11 @@ import { handleNavigationEnd } from "../scroll-restoration.js";
|
|
|
31
31
|
import { createAppShellRef, type AppShellRef } from "../app-shell.js";
|
|
32
32
|
import { startConnectionWarmup } from "../connection-warmup.js";
|
|
33
33
|
import { debugLog } from "../logging.js";
|
|
34
|
+
import { cloneHandleData } from "../navigation-store.js";
|
|
35
|
+
import {
|
|
36
|
+
deferredHandleNames,
|
|
37
|
+
resolveDeferredHandleValues,
|
|
38
|
+
} from "../../handles/deferred-resolution.js";
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
41
|
* Process handles from an async generator, updating the event controller
|
|
@@ -66,6 +71,19 @@ async function processHandles(
|
|
|
66
71
|
historyKey,
|
|
67
72
|
} = opts;
|
|
68
73
|
|
|
74
|
+
// This nav's instance token, captured before any await — processHandles runs
|
|
75
|
+
// right after its own commit, so this is that commit's token. generateHistoryKey
|
|
76
|
+
// is URL-only, so an A->B->A revisit reuses the key; the token lets a late
|
|
77
|
+
// resolution tell its own visit apart from a newer same-URL visit, so a stale
|
|
78
|
+
// nav can never clobber a fresher one's live state or cache (P1).
|
|
79
|
+
const myInstance = store.getNavInstance();
|
|
80
|
+
|
|
81
|
+
// True while this nav still owns the live page: same history key AND the most
|
|
82
|
+
// recent commit is still ours (no newer nav has committed since).
|
|
83
|
+
const stillLive = (): boolean =>
|
|
84
|
+
historyKey === store.getHistoryKey() &&
|
|
85
|
+
myInstance === store.getNavInstance();
|
|
86
|
+
|
|
69
87
|
let yieldCount = 0;
|
|
70
88
|
for await (const handleData of handlesGenerator) {
|
|
71
89
|
// Check if user navigated away before each update.
|
|
@@ -79,7 +97,91 @@ async function processHandles(
|
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
yieldCount++;
|
|
82
|
-
|
|
100
|
+
|
|
101
|
+
// Resolve-by-default: hold the previous resolved value until this yield's
|
|
102
|
+
// deferred (Promise) handle values settle, then apply the fully-resolved
|
|
103
|
+
// snapshot. The hold needs NO extra state — we simply do not touch the store
|
|
104
|
+
// until the values resolve, so useHandle keeps reading (and showing) the
|
|
105
|
+
// previous data. A yield with no deferred value applies synchronously.
|
|
106
|
+
const hasDeferred = deferredHandleNames(handleData).size > 0;
|
|
107
|
+
|
|
108
|
+
if (!hasDeferred) {
|
|
109
|
+
eventController.setHandleData(
|
|
110
|
+
handleData,
|
|
111
|
+
matched,
|
|
112
|
+
isPartial,
|
|
113
|
+
resolvedIds,
|
|
114
|
+
);
|
|
115
|
+
// Keep the cache fresh. The token guard stops a stale same-URL nav writing
|
|
116
|
+
// a newer entry; the owned-write folds probe + write into one scan.
|
|
117
|
+
store.updateCacheHandleDataIfOwned(
|
|
118
|
+
historyKey,
|
|
119
|
+
eventController.getHandleState().data,
|
|
120
|
+
myInstance,
|
|
121
|
+
false,
|
|
122
|
+
);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The PREVIOUS (held) snapshot — captured before the await so the cache and
|
|
127
|
+
// the navigate-away merge below reflect what useHandle is still showing.
|
|
128
|
+
const previousSnapshot = cloneHandleData(
|
|
129
|
+
eventController.getHandleState().data,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// The route HAS changed even though the handle data is held, so update
|
|
133
|
+
// `routeSegmentIds` (what useSegments reads) now. This leaves `data` /
|
|
134
|
+
// `segmentOrder` (what useHandle collects over) untouched, so useHandle keeps
|
|
135
|
+
// holding its previous value while useSegments reflects the new route.
|
|
136
|
+
eventController.setRouteSegmentIds(matched ?? []);
|
|
137
|
+
|
|
138
|
+
// Deferred-pending: the new values are not applied yet (the previous value is
|
|
139
|
+
// held), so the cache entry must NOT be served as fresh on a popstate return.
|
|
140
|
+
// Mark it STALE + handlesPending (token-guarded), storing the PREVIOUS (held)
|
|
141
|
+
// snapshot. P1 fix: a deferred value is a SERVER-side promise streamed via
|
|
142
|
+
// Flight, so a navigate-away ABORTS the stream and the resolve below never
|
|
143
|
+
// settles. stale makes a popstate return revalidate; handlesPending makes that
|
|
144
|
+
// revalidation a FULL re-render (no client segment IDs) so the server
|
|
145
|
+
// re-streams the handles — a diff-only revalidation would omit the unchanged
|
|
146
|
+
// segments' handles and the deferred value would never land (see the
|
|
147
|
+
// segmentIds branch in navigation-bridge.ts).
|
|
148
|
+
store.updateCacheHandleDataIfOwned(
|
|
149
|
+
historyKey,
|
|
150
|
+
previousSnapshot,
|
|
151
|
+
myInstance,
|
|
152
|
+
true,
|
|
153
|
+
true,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Resolve every deferred value (allSettled; rejected + nullish dropped, sync
|
|
157
|
+
// values pass through). Each stream yield is a full cumulative snapshot.
|
|
158
|
+
const resolved = await resolveDeferredHandleValues(handleData);
|
|
159
|
+
|
|
160
|
+
if (!stillLive()) {
|
|
161
|
+
// Navigated away (or a same-URL nav superseded us) while resolving. We do
|
|
162
|
+
// NOT write `resolved` into the entry. It is THIS yield's snapshot only (on a
|
|
163
|
+
// partial nav, just the re-resolved segments' buckets), and a correct write
|
|
164
|
+
// needs setHandleData's nested per-segment merge + matched/resolvedIds
|
|
165
|
+
// cleanup: HandleData is handleName -> segmentId -> entries[], so a
|
|
166
|
+
// handle-name-level spread would drop a shared layout bucket (e.g. a
|
|
167
|
+
// Breadcrumbs layout crumb under L0 when the route pushed under R0) and would
|
|
168
|
+
// mark stale previous-route buckets fresh. We cannot run that merge here
|
|
169
|
+
// without touching the now-different live page. Instead leave the entry as it
|
|
170
|
+
// was marked before the await — stale + handlesPending — so a popstate return
|
|
171
|
+
// revalidates with a full re-render and re-streams the handles. A newer nav
|
|
172
|
+
// owning the entry has already overwritten it; nothing to do either way.
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Still live: apply the fully-resolved snapshot and refresh the cache fresh.
|
|
177
|
+
eventController.setHandleData(resolved, matched, isPartial, resolvedIds);
|
|
178
|
+
store.updateCacheHandleDataIfOwned(
|
|
179
|
+
historyKey,
|
|
180
|
+
eventController.getHandleState().data,
|
|
181
|
+
myInstance,
|
|
182
|
+
false,
|
|
183
|
+
false,
|
|
184
|
+
);
|
|
83
185
|
}
|
|
84
186
|
|
|
85
187
|
// Check again before final updates
|
|
@@ -97,8 +199,9 @@ async function processHandles(
|
|
|
97
199
|
// After handles processing completes, update the cache's handleData.
|
|
98
200
|
// This fixes a race condition where commit() caches stale handleData before
|
|
99
201
|
// the async handles processing completes.
|
|
100
|
-
// Only update if we're still on the same page
|
|
101
|
-
|
|
202
|
+
// Only update if we're still on the same page AND this is still the live nav
|
|
203
|
+
// (the token guard stops a stale same-URL nav writing a newer nav's state).
|
|
204
|
+
if (stillLive()) {
|
|
102
205
|
const finalHandleData = eventController.getHandleState().data;
|
|
103
206
|
store.updateCacheHandleData(historyKey, finalHandleData);
|
|
104
207
|
}
|
|
@@ -362,7 +465,8 @@ export function NavigationProvider({
|
|
|
362
465
|
payload.root instanceof Promise ? use(payload.root) : payload.root;
|
|
363
466
|
|
|
364
467
|
// Wrap content in RootErrorBoundary to catch:
|
|
365
|
-
// 1. Errors from
|
|
468
|
+
// 1. Errors from RenderErrorThrower (network failures and unprocessable
|
|
469
|
+
// navigation responses, routed here by the navigation bridge)
|
|
366
470
|
// 2. Client component errors that occur before/outside the segment tree's error boundary
|
|
367
471
|
// 3. Errors during promise resolution or navigation state updates
|
|
368
472
|
// This acts as a safety net - the segment tree has its own RootErrorBoundary that
|