@rangojs/router 0.0.0-experimental.133 → 0.0.0-experimental.135
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 +7 -2
- package/dist/vite/index.js +41 -27
- package/package.json +23 -24
- package/skills/composability/SKILL.md +0 -1
- package/skills/handler-use/SKILL.md +7 -7
- package/skills/intercept/SKILL.md +38 -13
- package/skills/loader/SKILL.md +10 -0
- package/skills/migrate-nextjs/SKILL.md +3 -3
- package/skills/migrate-react-router/SKILL.md +144 -1
- package/skills/prerender/SKILL.md +20 -17
- package/skills/router-setup/SKILL.md +1 -2
- package/skills/testing/SKILL.md +1 -0
- package/skills/testing/render-handler.md +15 -14
- package/skills/use-cache/SKILL.md +11 -0
- package/skills/view-transitions/SKILL.md +43 -0
- package/src/browser/navigation-bridge.ts +65 -16
- package/src/browser/navigation-client.ts +27 -1
- package/src/browser/navigation-store.ts +82 -8
- package/src/browser/network-error-handler.ts +34 -7
- package/src/browser/partial-update.ts +43 -3
- package/src/browser/prefetch/cache.ts +8 -0
- package/src/browser/prefetch/fetch.ts +32 -4
- package/src/browser/react/NavigationProvider.tsx +195 -4
- package/src/browser/react/deferred-handle-resolution.ts +75 -0
- package/src/browser/response-adapter.ts +38 -9
- package/src/browser/types.ts +32 -1
- package/src/cache/cache-runtime.ts +26 -5
- package/src/cache/document-cache.ts +17 -1
- package/src/cache/profile-registry.ts +15 -0
- package/src/cache/read-through-swr.ts +15 -1
- package/src/handles/MetaTags.tsx +6 -0
- package/src/index.rsc.ts +6 -1
- package/src/index.ts +6 -4
- package/src/internal-debug.ts +11 -8
- 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 +38 -39
- 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-types.ts +0 -5
- package/src/router/match-api.ts +5 -1
- package/src/router/match-middleware/background-revalidation.ts +40 -23
- package/src/router/match-middleware/cache-store.ts +39 -24
- package/src/router/segment-resolution/fresh.ts +4 -0
- package/src/router/segment-resolution/loader-cache.ts +14 -2
- package/src/router/segment-resolution/revalidation.ts +3 -0
- package/src/router/segment-resolution/view-transition-default.ts +35 -15
- package/src/rsc/progressive-enhancement.ts +56 -2
- package/src/rsc/rsc-rendering.ts +7 -2
- package/src/rsc/server-action.ts +25 -2
- package/src/rsc/transition-gate.ts +89 -0
- package/src/segment-system.tsx +59 -8
- package/src/server/context.ts +13 -0
- package/src/server/loader-registry.ts +13 -1
- package/src/server/request-context.ts +52 -3
- package/src/testing/index.ts +6 -0
- package/src/testing/render-handler.ts +14 -0
- package/src/testing/run-transition-when.ts +164 -0
- package/src/types/handler-context.ts +1 -1
- package/src/types/index.ts +2 -0
- package/src/types/segments.ts +100 -0
- package/src/urls/path-helper-types.ts +10 -7
- package/src/urls/urls-function.ts +0 -1
- package/src/vite/inject-client-debug.ts +36 -0
- package/src/vite/plugins/version-injector.ts +22 -7
- package/src/vite/plugins/virtual-entries.ts +28 -9
- package/src/vite/router-discovery.ts +8 -13
- package/src/network-error-thrower.tsx +0 -18
|
@@ -20,7 +20,7 @@ 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 { debugLog } from "./logging.js";
|
|
23
|
+
import { debugLog, isBrowserDebugEnabled } from "./logging.js";
|
|
24
24
|
import {
|
|
25
25
|
validateRedirectOrigin,
|
|
26
26
|
validateExternalRedirect,
|
|
@@ -188,7 +188,11 @@ export function createPartialUpdater(
|
|
|
188
188
|
routerId: store.getRouterId?.(),
|
|
189
189
|
});
|
|
190
190
|
const streamingToken = tx.startStreaming();
|
|
191
|
-
const {
|
|
191
|
+
const {
|
|
192
|
+
payload,
|
|
193
|
+
streamComplete: rawStreamComplete,
|
|
194
|
+
fullyPrefetched,
|
|
195
|
+
} = fetchResult;
|
|
192
196
|
debugLog("payload.metadata", payload.metadata);
|
|
193
197
|
|
|
194
198
|
// Side effect only: end the streaming token once the stream settles.
|
|
@@ -395,7 +399,14 @@ export function createPartialUpdater(
|
|
|
395
399
|
|
|
396
400
|
const renderOptions = {
|
|
397
401
|
isAction: mode.type === "action",
|
|
398
|
-
forceAwait
|
|
402
|
+
// forceAwait unwraps the ROUTER loader promises during render so they
|
|
403
|
+
// land without a loading()/fallback frame. A fully-prefetched nav has
|
|
404
|
+
// its router data already resolved (the prefetch stream drained), so
|
|
405
|
+
// awaiting it here is free and lets us commit NORMALLY (not in a
|
|
406
|
+
// transition) below — a normal commit still shows fallbacks for any
|
|
407
|
+
// CLIENT component that suspends on mount, which a transition would
|
|
408
|
+
// wrongly suppress by holding the old UI until that suspense settles.
|
|
409
|
+
forceAwait: mode.type === "stale-revalidation" || fullyPrefetched,
|
|
399
410
|
interceptSegments:
|
|
400
411
|
reconciled.interceptSegments.length > 0
|
|
401
412
|
? reconciled.interceptSegments
|
|
@@ -480,6 +491,21 @@ export function createPartialUpdater(
|
|
|
480
491
|
debugLog("[partial-update] updating document");
|
|
481
492
|
|
|
482
493
|
const hasTransition = shouldStartViewTransition(reconciled.segments);
|
|
494
|
+
// [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Reports which reconciled
|
|
495
|
+
// segment still carries a transition after the server-side when-gate, and
|
|
496
|
+
// whether the commit will be held in a startTransition. If `withTransition`
|
|
497
|
+
// lists an ancestor (layout/root) id rather than the gated leaf, an ungated
|
|
498
|
+
// ancestor transition is holding the subtree (missing loading() fallback).
|
|
499
|
+
if (isBrowserDebugEnabled()) {
|
|
500
|
+
debugLog("[VT-DIAG] commit", {
|
|
501
|
+
mode: mode.type,
|
|
502
|
+
hasTransition,
|
|
503
|
+
withTransition: reconciled.segments
|
|
504
|
+
.filter((s) => s.transition)
|
|
505
|
+
.map((s) => s.id),
|
|
506
|
+
all: reconciled.segments.map((s) => s.id),
|
|
507
|
+
});
|
|
508
|
+
}
|
|
483
509
|
const scrollPayload = toScrollPayload(navScroll);
|
|
484
510
|
|
|
485
511
|
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
@@ -505,6 +531,20 @@ export function createPartialUpdater(
|
|
|
505
531
|
});
|
|
506
532
|
});
|
|
507
533
|
} else {
|
|
534
|
+
// Normal commit (cold/partial nav AND fully-prefetched nav). For a
|
|
535
|
+
// fully-prefetched nav, renderOptions.forceAwait (above) unwrapped the
|
|
536
|
+
// already-resolved ROUTER loader data AND route content during render, so
|
|
537
|
+
// the new tree carries it inline with no loading()/fallback frame — yet we
|
|
538
|
+
// still commit NORMALLY here rather than in a transition. A transition
|
|
539
|
+
// holds the OLD UI until ALL suspense in the new tree settles, including a
|
|
540
|
+
// CLIENT component that starts its own data request only when mounted
|
|
541
|
+
// (post-commit) under a persistent boundary; that would retain the
|
|
542
|
+
// previous page indefinitely with no feedback. A normal commit lets such
|
|
543
|
+
// client-initiated suspense reveal a fallback (correct) while the router
|
|
544
|
+
// data — genuinely ready — never flashes. Cold/partial navs
|
|
545
|
+
// (fullyPrefetched=false) do not forceAwait, so they stream their
|
|
546
|
+
// fallbacks. Explicit transition() routes keep the broader content-hold
|
|
547
|
+
// via the hasTransition branch above (the documented opt-in).
|
|
508
548
|
onUpdate({
|
|
509
549
|
root: newTree,
|
|
510
550
|
metadata: payload.metadata!,
|
|
@@ -65,6 +65,14 @@ 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;
|
|
@@ -244,14 +244,21 @@ function executePrefetchFetch(
|
|
|
244
244
|
const storageKey = scope === "source" ? sourceKey : wildcardKey;
|
|
245
245
|
|
|
246
246
|
// Track stream completion off a tee so navigation's scroll/revalidation
|
|
247
|
-
// gating matches the fresh-fetch path; decode the other branch.
|
|
247
|
+
// gating matches the fresh-fetch path; decode the other branch. The
|
|
248
|
+
// completion callback reports whether the stream ended on a clean EOF
|
|
249
|
+
// (true) or was aborted/errored (false) — only a clean end can mark the
|
|
250
|
+
// entry complete (see below).
|
|
248
251
|
let resolveStreamComplete!: () => void;
|
|
252
|
+
let endedCleanly = false;
|
|
249
253
|
const streamComplete = new Promise<void>((resolve) => {
|
|
250
254
|
resolveStreamComplete = resolve;
|
|
251
255
|
});
|
|
252
256
|
const tracked = teeWithCompletion(
|
|
253
257
|
response,
|
|
254
|
-
() =>
|
|
258
|
+
(clean) => {
|
|
259
|
+
endedCleanly = clean;
|
|
260
|
+
resolveStreamComplete();
|
|
261
|
+
},
|
|
255
262
|
effectiveSignal,
|
|
256
263
|
// Speculative prefetch: a never-consumed/aborted stream error is benign.
|
|
257
264
|
true,
|
|
@@ -264,7 +271,12 @@ function executePrefetchFetch(
|
|
|
264
271
|
// error is still surfaced to navigation if it consumes the entry.
|
|
265
272
|
payload.catch(() => {});
|
|
266
273
|
|
|
267
|
-
const entry: DecodedPrefetch = {
|
|
274
|
+
const entry: DecodedPrefetch = {
|
|
275
|
+
payload,
|
|
276
|
+
streamComplete,
|
|
277
|
+
scope,
|
|
278
|
+
complete: false,
|
|
279
|
+
};
|
|
268
280
|
storePrefetch(storageKey, entry, gen);
|
|
269
281
|
// The stall timeout now owns the body stream: arm eviction (publishedKey)
|
|
270
282
|
// and clear the timer once the stream completes. The tee's finally resolves
|
|
@@ -272,7 +284,23 @@ function executePrefetchFetch(
|
|
|
272
284
|
// no lingering timer while a stalled one is evicted when the timer fires.
|
|
273
285
|
publishedKey = storageKey;
|
|
274
286
|
publishedEntry = entry;
|
|
275
|
-
|
|
287
|
+
// Evict a broken prefetch IMMEDIATELY on the earliest failure signal — do not
|
|
288
|
+
// wait for both branches to settle. A decode that rejects while the tracking
|
|
289
|
+
// stream is still draining (or hung) would otherwise leave the rejected payload
|
|
290
|
+
// consumable (navigation reads entry.payload regardless of `complete`) until EOF
|
|
291
|
+
// or the stall timeout. removePrefetch is identity-guarded, so a fresh entry
|
|
292
|
+
// republished under the same key is never dropped, and a double call is a no-op.
|
|
293
|
+
payload.catch(() => removePrefetch(storageKey, entry));
|
|
294
|
+
streamComplete.then(() => {
|
|
295
|
+
if (!endedCleanly) removePrefetch(storageKey, entry);
|
|
296
|
+
});
|
|
297
|
+
// Mark complete ONLY on a fully-healthy prefetch (decode resolved AND clean EOF).
|
|
298
|
+
Promise.allSettled([payload, streamComplete]).then(([decode]) => {
|
|
299
|
+
if (decode.status === "fulfilled" && endedCleanly) {
|
|
300
|
+
entry.complete = true;
|
|
301
|
+
}
|
|
302
|
+
clearTimeout(timeoutId);
|
|
303
|
+
});
|
|
276
304
|
return entry;
|
|
277
305
|
})
|
|
278
306
|
.catch(() => null)
|
|
@@ -31,6 +31,57 @@ 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 { collectHandleData } from "../../handle.js";
|
|
36
|
+
import { Meta } from "../../handles/meta.js";
|
|
37
|
+
import type { MetaDescriptor } from "../../router/types.js";
|
|
38
|
+
import {
|
|
39
|
+
HEAD_RESOLVE_HANDLE_NAMES,
|
|
40
|
+
hasDeferredHandleValue,
|
|
41
|
+
resolveDeferredHandleValues,
|
|
42
|
+
} from "./deferred-handle-resolution.js";
|
|
43
|
+
|
|
44
|
+
/** Meta handle-name key. Meta is the only head-placed handle whose consumer
|
|
45
|
+
* use()s a deferred value above the route <Suspense>, so it must be resolved in
|
|
46
|
+
* the store before apply; every other handle keeps the promise contract. */
|
|
47
|
+
const META = "__rsc_router_meta__";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Carry the previous page's COLLECTED Meta forward so the title is kept (no
|
|
51
|
+
* blank) while a deferred Meta resolves on a soft navigation.
|
|
52
|
+
*
|
|
53
|
+
* Why a carry-forward and not just preserving the previous Meta data: handle
|
|
54
|
+
* collection (useHandle/MetaTags) is driven by the event controller's
|
|
55
|
+
* `segmentOrder`, which becomes the NEW route's order so the synchronous
|
|
56
|
+
* breadcrumbs render immediately. The previous route's title lives under a
|
|
57
|
+
* segment that is NOT in the new order, so it would stop being collected — the
|
|
58
|
+
* title would fall back to the layout default. Re-keying the previous COLLECTED
|
|
59
|
+
* descriptors under a segment that IS in the new order keeps them visible.
|
|
60
|
+
*
|
|
61
|
+
* Title descriptors are wrapped as `{ title: { absolute } }` so re-collection
|
|
62
|
+
* under a (possibly template-bearing) new layout does not re-apply a title
|
|
63
|
+
* template to an already-final title. Promise and default (charSet/viewport)
|
|
64
|
+
* descriptors are dropped: Promise ones would suspend MetaTags, and the defaults
|
|
65
|
+
* are re-added by collectMeta.
|
|
66
|
+
*/
|
|
67
|
+
function carriedPreviousMeta(prev: MetaDescriptor[]): MetaDescriptor[] {
|
|
68
|
+
const out: MetaDescriptor[] = [];
|
|
69
|
+
for (const d of prev) {
|
|
70
|
+
if (d && typeof (d as { then?: unknown }).then === "function") continue;
|
|
71
|
+
const base = d as Exclude<MetaDescriptor, Promise<unknown>>;
|
|
72
|
+
if ("charSet" in base) continue;
|
|
73
|
+
if ("name" in base && (base as { name?: unknown }).name === "viewport") {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if ("title" in base) {
|
|
77
|
+
const t = (base as { title: unknown }).title;
|
|
78
|
+
out.push({ title: { absolute: typeof t === "string" ? t : String(t) } });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
out.push(base);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
34
85
|
|
|
35
86
|
/**
|
|
36
87
|
* Process handles from an async generator, updating the event controller
|
|
@@ -66,6 +117,19 @@ async function processHandles(
|
|
|
66
117
|
historyKey,
|
|
67
118
|
} = opts;
|
|
68
119
|
|
|
120
|
+
// This nav's instance token, captured before any await — processHandles runs
|
|
121
|
+
// right after its own commit, so this is that commit's token. generateHistoryKey
|
|
122
|
+
// is URL-only, so an A->B->A revisit reuses the key; the token lets a late
|
|
123
|
+
// resolution tell its own visit apart from a newer same-URL visit, so a stale
|
|
124
|
+
// nav can never clobber a fresher one's live state or cache (P1).
|
|
125
|
+
const myInstance = store.getNavInstance();
|
|
126
|
+
|
|
127
|
+
// True while this nav still owns the live page: same history key AND the most
|
|
128
|
+
// recent commit is still ours (no newer nav has committed since).
|
|
129
|
+
const stillLive = (): boolean =>
|
|
130
|
+
historyKey === store.getHistoryKey() &&
|
|
131
|
+
myInstance === store.getNavInstance();
|
|
132
|
+
|
|
69
133
|
let yieldCount = 0;
|
|
70
134
|
for await (const handleData of handlesGenerator) {
|
|
71
135
|
// Check if user navigated away before each update.
|
|
@@ -79,7 +143,132 @@ async function processHandles(
|
|
|
79
143
|
}
|
|
80
144
|
|
|
81
145
|
yieldCount++;
|
|
82
|
-
|
|
146
|
+
|
|
147
|
+
// Resolve ONLY Meta in the store before applying. Meta is the sole
|
|
148
|
+
// head-placed handle whose consumer use()s a deferred value above the route
|
|
149
|
+
// <Suspense>; an uncontained suspension there would revert the just-committed
|
|
150
|
+
// route and hide its loading fallback. Every other handle (Breadcrumbs,
|
|
151
|
+
// custom handles) keeps the DeferredHandleEntry contract: its deferred values
|
|
152
|
+
// reach the consumer AS A PROMISE and are narrowed via isThenable(). So sync
|
|
153
|
+
// handles AND non-Meta deferred promises apply/stream through immediately —
|
|
154
|
+
// only Meta is held back and swapped in once resolved.
|
|
155
|
+
const metaDeferred = hasDeferredHandleValue(
|
|
156
|
+
handleData,
|
|
157
|
+
HEAD_RESOLVE_HANDLE_NAMES,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Apply now. The non-deferred-Meta case applies the whole snapshot in one
|
|
161
|
+
// call (Meta included). When Meta IS deferred, replace the deferred Meta with
|
|
162
|
+
// the previous page's COLLECTED Meta (stale-while-revalidate — never a blank
|
|
163
|
+
// title) keyed under one of the NEW route's Meta segments, so it stays
|
|
164
|
+
// collected under the new segment order while the synchronous and non-Meta
|
|
165
|
+
// deferred handles update with normal cleanup. The resolved Meta is swapped
|
|
166
|
+
// in by the partial merge below.
|
|
167
|
+
if (metaDeferred) {
|
|
168
|
+
const immediate: HandleData = { ...handleData };
|
|
169
|
+
const metaSegments = handleData[META] ?? {};
|
|
170
|
+
// Anchor: the last new Meta segment in matched order (collected after the
|
|
171
|
+
// shared layout, so its carried title wins). Falls back to any new Meta
|
|
172
|
+
// segment if matched ordering does not surface one.
|
|
173
|
+
const metaSegmentIds = Object.keys(metaSegments);
|
|
174
|
+
const ordered = (matched ?? []).filter((id) =>
|
|
175
|
+
metaSegmentIds.includes(id),
|
|
176
|
+
);
|
|
177
|
+
const anchor = ordered.at(-1) ?? metaSegmentIds.at(-1);
|
|
178
|
+
|
|
179
|
+
const prevState = eventController.getHandleState();
|
|
180
|
+
const prevCollected = collectHandleData(
|
|
181
|
+
Meta,
|
|
182
|
+
prevState.data,
|
|
183
|
+
prevState.segmentOrder,
|
|
184
|
+
) as MetaDescriptor[];
|
|
185
|
+
const carried = carriedPreviousMeta(prevCollected);
|
|
186
|
+
|
|
187
|
+
if (anchor && carried.length > 0) {
|
|
188
|
+
immediate[META] = { [anchor]: carried };
|
|
189
|
+
} else {
|
|
190
|
+
// No previous Meta to carry and/or no anchor: leave Meta unset until it
|
|
191
|
+
// resolves (the documented no-previous-Meta behavior).
|
|
192
|
+
delete immediate[META];
|
|
193
|
+
}
|
|
194
|
+
eventController.setHandleData(immediate, matched, isPartial, resolvedIds);
|
|
195
|
+
} else {
|
|
196
|
+
eventController.setHandleData(
|
|
197
|
+
handleData,
|
|
198
|
+
matched,
|
|
199
|
+
isPartial,
|
|
200
|
+
resolvedIds,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Snapshot of the nav's full applied handle state (sync handles, non-Meta
|
|
205
|
+
// deferred promises, and — when Meta is deferred — the carried previous Meta).
|
|
206
|
+
// Captured AFTER applying so it reflects what is actually on screen now.
|
|
207
|
+
const baseSnapshot = cloneHandleData(eventController.getHandleState().data);
|
|
208
|
+
|
|
209
|
+
if (!metaDeferred) {
|
|
210
|
+
// Non-deferred: the applied snapshot is final. Keep the cache in sync and
|
|
211
|
+
// fresh. The token guard stops a stale same-URL nav writing a newer entry.
|
|
212
|
+
if (store.getCacheEntryInstance(historyKey) === myInstance) {
|
|
213
|
+
store.updateCacheHandleData(historyKey, baseSnapshot, false);
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Meta is deferred-pending. The applied snapshot carries the PREVIOUS page's
|
|
219
|
+
// Meta (or none), not this route's final title, so the cache entry must NOT
|
|
220
|
+
// be served as fresh on a popstate return. Mark it STALE and handlesPending
|
|
221
|
+
// (token-guarded). This is the P1 fix: the deferred Meta is a SERVER-side
|
|
222
|
+
// promise streamed via Flight, so a navigate-away ABORTS the stream and the
|
|
223
|
+
// client's deferred-Meta promise never resolves — the .then below never
|
|
224
|
+
// fires. stale makes a popstate return revalidate; handlesPending makes that
|
|
225
|
+
// revalidation a FULL re-render (no client segment IDs) so the server
|
|
226
|
+
// re-streams the handles. A diff-only revalidation would omit the unchanged
|
|
227
|
+
// segments' handles and the deferred Meta would never land — see the
|
|
228
|
+
// segmentIds branch in navigation-bridge.ts.
|
|
229
|
+
if (store.getCacheEntryInstance(historyKey) === myInstance) {
|
|
230
|
+
store.updateCacheHandleData(historyKey, baseSnapshot, true, true);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Resolve Meta late, then swap it in. The swap is a PARTIAL merge with
|
|
234
|
+
// resolvedIds=undefined so the stale-clear loop (which scans all handle
|
|
235
|
+
// names under resolvedIds) cannot wipe the non-Meta buckets we already
|
|
236
|
+
// applied. When the deferred Meta DOES resolve while this nav still owns the
|
|
237
|
+
// entry (no navigate-away abort), write the resolved handle data and clear
|
|
238
|
+
// stale + handlesPending — the entry is now complete, so a popstate return
|
|
239
|
+
// serves it without revalidating.
|
|
240
|
+
//
|
|
241
|
+
// Order-safety: each stream yield is a full cumulative snapshot and a
|
|
242
|
+
// segment's handle array is atomic, so concurrent Meta resolutions of
|
|
243
|
+
// different yields write identical per-segment arrays or touch disjoint
|
|
244
|
+
// segments — neither can clobber the other.
|
|
245
|
+
void resolveDeferredHandleValues(
|
|
246
|
+
handleData,
|
|
247
|
+
HEAD_RESOLVE_HANDLE_NAMES,
|
|
248
|
+
).then((resolved) => {
|
|
249
|
+
const cacheValue = { ...baseSnapshot, [META]: resolved[META] };
|
|
250
|
+
if (stillLive()) {
|
|
251
|
+
// Still on the live page: swap Meta in and refresh the cache as fresh.
|
|
252
|
+
eventController.setHandleData(
|
|
253
|
+
{ [META]: resolved[META] },
|
|
254
|
+
matched,
|
|
255
|
+
true,
|
|
256
|
+
undefined,
|
|
257
|
+
);
|
|
258
|
+
store.updateCacheHandleData(
|
|
259
|
+
historyKey,
|
|
260
|
+
eventController.getHandleState().data,
|
|
261
|
+
false,
|
|
262
|
+
false,
|
|
263
|
+
);
|
|
264
|
+
} else if (store.getCacheEntryInstance(historyKey) === myInstance) {
|
|
265
|
+
// Navigated away, but THIS nav still owns the target cache entry: write
|
|
266
|
+
// the resolved data and clear stale + handlesPending so a popstate return
|
|
267
|
+
// is fresh.
|
|
268
|
+
store.updateCacheHandleData(historyKey, cacheValue, false, false);
|
|
269
|
+
}
|
|
270
|
+
// else: a newer nav to the same URL superseded us — do nothing.
|
|
271
|
+
});
|
|
83
272
|
}
|
|
84
273
|
|
|
85
274
|
// Check again before final updates
|
|
@@ -97,8 +286,9 @@ async function processHandles(
|
|
|
97
286
|
// After handles processing completes, update the cache's handleData.
|
|
98
287
|
// This fixes a race condition where commit() caches stale handleData before
|
|
99
288
|
// the async handles processing completes.
|
|
100
|
-
// Only update if we're still on the same page
|
|
101
|
-
|
|
289
|
+
// Only update if we're still on the same page AND this is still the live nav
|
|
290
|
+
// (the token guard stops a stale same-URL nav writing a newer nav's state).
|
|
291
|
+
if (stillLive()) {
|
|
102
292
|
const finalHandleData = eventController.getHandleState().data;
|
|
103
293
|
store.updateCacheHandleData(historyKey, finalHandleData);
|
|
104
294
|
}
|
|
@@ -362,7 +552,8 @@ export function NavigationProvider({
|
|
|
362
552
|
payload.root instanceof Promise ? use(payload.root) : payload.root;
|
|
363
553
|
|
|
364
554
|
// Wrap content in RootErrorBoundary to catch:
|
|
365
|
-
// 1. Errors from
|
|
555
|
+
// 1. Errors from RenderErrorThrower (network failures and unprocessable
|
|
556
|
+
// navigation responses, routed here by the navigation bridge)
|
|
366
557
|
// 2. Client component errors that occur before/outside the segment tree's error boundary
|
|
367
558
|
// 3. Errors during promise resolution or navigation state updates
|
|
368
559
|
// This acts as a safety net - the segment tree has its own RootErrorBoundary that
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { HandleData } from "../types.js";
|
|
2
|
+
import { isThenable } from "../../handles/is-thenable.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The set of handle names whose deferred (Promise) values MUST be resolved in
|
|
6
|
+
* the store BEFORE the snapshot is applied during client navigation.
|
|
7
|
+
*
|
|
8
|
+
* The boundary: a handle belongs here only if its consumer `use()`s a promise in
|
|
9
|
+
* <head>, above the route's <Suspense>. Suspending there would revert the
|
|
10
|
+
* just-committed route and hide its loading fallback. Today that is Meta alone
|
|
11
|
+
* (MetaTags lives in <head> and use()s deferred descriptors). Every OTHER handle
|
|
12
|
+
* keeps the public DeferredHandleEntry contract: its deferred value reaches the
|
|
13
|
+
* consumer AS A PROMISE during soft navigation, narrowed via isThenable().
|
|
14
|
+
*
|
|
15
|
+
* If a future head-placed handle starts use()-ing promises, add its name here.
|
|
16
|
+
*/
|
|
17
|
+
export const HEAD_RESOLVE_HANDLE_NAMES: readonly string[] = [
|
|
18
|
+
"__rsc_router_meta__",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* True when a handle value in this snapshot is a deferred (Promise) value.
|
|
23
|
+
*
|
|
24
|
+
* When `onlyHandleNames` is given, only those handle buckets are considered;
|
|
25
|
+
* deferred values under any other handle are ignored (they pass through to the
|
|
26
|
+
* consumer as promises, by contract).
|
|
27
|
+
*/
|
|
28
|
+
export function hasDeferredHandleValue(
|
|
29
|
+
data: HandleData,
|
|
30
|
+
onlyHandleNames?: readonly string[],
|
|
31
|
+
): boolean {
|
|
32
|
+
const scope = onlyHandleNames ? new Set(onlyHandleNames) : null;
|
|
33
|
+
for (const [handleName, segments] of Object.entries(data)) {
|
|
34
|
+
if (scope && !scope.has(handleName)) continue;
|
|
35
|
+
for (const values of Object.values(segments)) {
|
|
36
|
+
if (values.some(isThenable)) return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Snapshot with deferred (Promise) values awaited; a rejected deferred is
|
|
44
|
+
* dropped (it contributes nothing), mirroring the render-side REJECTED_META.
|
|
45
|
+
* Promise.allSettled treats non-promise values as already-fulfilled, so plain
|
|
46
|
+
* values pass through unchanged.
|
|
47
|
+
*
|
|
48
|
+
* When `onlyHandleNames` is given, ONLY those handle buckets are resolved; every
|
|
49
|
+
* other bucket is copied through by reference (its deferred values keep their
|
|
50
|
+
* promise identity so the consumer can narrow them).
|
|
51
|
+
*/
|
|
52
|
+
export async function resolveDeferredHandleValues(
|
|
53
|
+
data: HandleData,
|
|
54
|
+
onlyHandleNames?: readonly string[],
|
|
55
|
+
): Promise<HandleData> {
|
|
56
|
+
const scope = onlyHandleNames ? new Set(onlyHandleNames) : null;
|
|
57
|
+
const out: HandleData = {};
|
|
58
|
+
await Promise.all(
|
|
59
|
+
Object.entries(data).flatMap(([handleName, segments]) => {
|
|
60
|
+
// Out-of-scope buckets pass through untouched (promise identity kept).
|
|
61
|
+
if (scope && !scope.has(handleName)) {
|
|
62
|
+
out[handleName] = segments;
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
out[handleName] = {};
|
|
66
|
+
return Object.entries(segments).map(async ([segmentId, values]) => {
|
|
67
|
+
const settled = await Promise.allSettled(values);
|
|
68
|
+
out[handleName][segmentId] = settled
|
|
69
|
+
.filter((r) => r.status === "fulfilled")
|
|
70
|
+
.map((r) => (r as PromiseFulfilledResult<unknown>).value);
|
|
71
|
+
});
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
@@ -2,6 +2,16 @@ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
|
2
2
|
|
|
3
3
|
type HeaderResult = { url: string } | "blocked" | null;
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Null-body statuses: the Fetch spec forbids pairing these with a body, so
|
|
7
|
+
* `new Response(body, { status })` throws ("Response with null body status
|
|
8
|
+
* cannot have body"). fetch() can still surface one WITH a body straight from
|
|
9
|
+
* the network layer (never the JS constructor): a 304 stale-while-revalidate
|
|
10
|
+
* prefetch revalidated to Not Modified (body from cache), or a 204 soft
|
|
11
|
+
* redirect. teeWithCompletion must not re-run those through `new Response`.
|
|
12
|
+
*/
|
|
13
|
+
const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
|
|
14
|
+
|
|
5
15
|
/**
|
|
6
16
|
* Extract and validate an RSC response header URL (X-RSC-Reload, X-RSC-Redirect).
|
|
7
17
|
* Returns { url } if valid, "blocked" if present but invalid origin, null if absent.
|
|
@@ -74,7 +84,13 @@ export function handleReloadHeader(
|
|
|
74
84
|
* Returns a new Response with one branch; the other is consumed to detect
|
|
75
85
|
* end-of-stream, calling onComplete when done.
|
|
76
86
|
*
|
|
77
|
-
*
|
|
87
|
+
* `onComplete` receives `endedCleanly`: true only on a normal EOF drain, false
|
|
88
|
+
* on a read error or an abort (the signal cancelled the reader). Callers that
|
|
89
|
+
* gate a "fully complete" fast path (e.g. prefetch's `entry.complete`) must
|
|
90
|
+
* treat a non-clean end as incomplete — a broken stream is not complete data.
|
|
91
|
+
*
|
|
92
|
+
* If the response has no body, onComplete fires synchronously with `true`
|
|
93
|
+
* (an empty body is a clean, complete stream).
|
|
78
94
|
* If signal is provided, an abort cancels the tracking reader.
|
|
79
95
|
*
|
|
80
96
|
* `silent` suppresses the stream-error log. Prefetch passes it: a speculative,
|
|
@@ -84,7 +100,7 @@ export function handleReloadHeader(
|
|
|
84
100
|
*/
|
|
85
101
|
export function teeWithCompletion(
|
|
86
102
|
response: Response,
|
|
87
|
-
onComplete: () => void,
|
|
103
|
+
onComplete: (endedCleanly: boolean) => void,
|
|
88
104
|
signal?: AbortSignal,
|
|
89
105
|
silent = false,
|
|
90
106
|
): Response {
|
|
@@ -92,15 +108,18 @@ export function teeWithCompletion(
|
|
|
92
108
|
// rejection's .catch, so onComplete must be settled exactly once across all
|
|
93
109
|
// paths (no-body early return, finally, catch).
|
|
94
110
|
let settled = false;
|
|
95
|
-
const settle = () => {
|
|
111
|
+
const settle = (endedCleanly: boolean) => {
|
|
96
112
|
if (!settled) {
|
|
97
113
|
settled = true;
|
|
98
|
-
onComplete();
|
|
114
|
+
onComplete(endedCleanly);
|
|
99
115
|
}
|
|
100
116
|
};
|
|
101
117
|
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
// Empty body, or a null-body status fetch() paired with a body: either way the
|
|
119
|
+
// body can't be re-attached via `new Response` below. Settle and pass the
|
|
120
|
+
// original response through; its body (when present) stays readable downstream.
|
|
121
|
+
if (!response.body || NULL_BODY_STATUS.has(response.status)) {
|
|
122
|
+
settle(true);
|
|
104
123
|
return response;
|
|
105
124
|
}
|
|
106
125
|
|
|
@@ -110,21 +129,31 @@ export function teeWithCompletion(
|
|
|
110
129
|
const reader = trackingStream.getReader();
|
|
111
130
|
const onAbort = signal ? reader.cancel.bind(reader) : undefined;
|
|
112
131
|
if (onAbort) signal!.addEventListener("abort", onAbort, { once: true });
|
|
132
|
+
// Only a loop that reaches `done` is a clean EOF. A read error rejects out
|
|
133
|
+
// of the try and still runs the finally, so the finally must NOT assume
|
|
134
|
+
// clean — it gates on this flag (false on error) AND on signal.aborted
|
|
135
|
+
// (an abort cancels the reader so read() resolves { done: true } and the
|
|
136
|
+
// loop breaks here normally, NOT in the .catch — re-check the signal to
|
|
137
|
+
// catch that case).
|
|
138
|
+
let cleanEof = false;
|
|
113
139
|
try {
|
|
114
140
|
while (true) {
|
|
115
141
|
const { done } = await reader.read();
|
|
116
|
-
if (done)
|
|
142
|
+
if (done) {
|
|
143
|
+
cleanEof = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
117
146
|
}
|
|
118
147
|
} finally {
|
|
119
148
|
if (onAbort) signal!.removeEventListener("abort", onAbort);
|
|
120
149
|
reader.releaseLock();
|
|
121
|
-
settle();
|
|
150
|
+
settle(cleanEof && !signal?.aborted);
|
|
122
151
|
}
|
|
123
152
|
})().catch((error) => {
|
|
124
153
|
if (!silent && !signal?.aborted) {
|
|
125
154
|
console.error("[Browser] Error reading tracking stream:", error);
|
|
126
155
|
}
|
|
127
|
-
settle();
|
|
156
|
+
settle(false);
|
|
128
157
|
});
|
|
129
158
|
|
|
130
159
|
return new Response(rscStream, {
|
package/src/browser/types.ts
CHANGED
|
@@ -447,6 +447,10 @@ export interface NavigationStore {
|
|
|
447
447
|
// History-based segment cache (for back/forward navigation and partial merging)
|
|
448
448
|
getHistoryKey(): string;
|
|
449
449
|
setHistoryKey(key: string): void;
|
|
450
|
+
/** Monotonic token of the most recently committed navigation. */
|
|
451
|
+
getNavInstance(): number;
|
|
452
|
+
/** Nav-instance token recorded on a cache entry (undefined if absent). */
|
|
453
|
+
getCacheEntryInstance(historyKey: string): number | undefined;
|
|
450
454
|
cacheSegmentsForHistory(
|
|
451
455
|
historyKey: string,
|
|
452
456
|
segments: ResolvedSegment[],
|
|
@@ -458,10 +462,28 @@ export interface NavigationStore {
|
|
|
458
462
|
stale: boolean;
|
|
459
463
|
handleData?: HandleData;
|
|
460
464
|
routerId?: string;
|
|
465
|
+
/**
|
|
466
|
+
* True when the entry's handle data is incomplete (a deferred Meta was
|
|
467
|
+
* still pending at navigate-away). A popstate return must revalidate with
|
|
468
|
+
* a FULL re-render so the server re-streams handles.
|
|
469
|
+
*/
|
|
470
|
+
handlesPending?: boolean;
|
|
461
471
|
}
|
|
462
472
|
| undefined;
|
|
463
473
|
hasHistoryCache(historyKey: string): boolean;
|
|
464
|
-
|
|
474
|
+
/**
|
|
475
|
+
* Update only the handleData (and optionally the stale / handlesPending flags)
|
|
476
|
+
* of an existing cache entry. When a flag is omitted the entry's current value
|
|
477
|
+
* is preserved. `stale=true` marks a single entry stale so a popstate return
|
|
478
|
+
* revalidates it; `handlesPending=true` additionally forces that revalidation
|
|
479
|
+
* to be a full re-render (so a deferred Meta re-streams).
|
|
480
|
+
*/
|
|
481
|
+
updateCacheHandleData(
|
|
482
|
+
historyKey: string,
|
|
483
|
+
handleData: HandleData,
|
|
484
|
+
stale?: boolean,
|
|
485
|
+
handlesPending?: boolean,
|
|
486
|
+
): void;
|
|
465
487
|
markCacheAsStale(): void;
|
|
466
488
|
markHistoryCacheStale(): void;
|
|
467
489
|
markCacheAsStaleAndBroadcast(): void;
|
|
@@ -521,6 +543,15 @@ export interface FetchPartialResult {
|
|
|
521
543
|
payload: RscPayload;
|
|
522
544
|
/** Promise that resolves when the response stream is fully consumed */
|
|
523
545
|
streamComplete: Promise<void>;
|
|
546
|
+
/**
|
|
547
|
+
* True only when this payload came from a prefetch-cache hit whose stream had
|
|
548
|
+
* ALREADY fully drained at fetch time (the route was fully prefetched). The
|
|
549
|
+
* commit then runs in a startTransition so loading()/Suspense content — already
|
|
550
|
+
* resolved — swaps in directly without flashing a fallback. A partially-warmed
|
|
551
|
+
* (still-streaming) prefetch hit and a cold fetch leave this false so their
|
|
552
|
+
* fallbacks stream as usual.
|
|
553
|
+
*/
|
|
554
|
+
fullyPrefetched?: boolean;
|
|
524
555
|
}
|
|
525
556
|
|
|
526
557
|
/**
|