@rangojs/router 0.0.0-experimental.132 → 0.0.0-experimental.133
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 +8 -0
- package/README.md +43 -2
- package/dist/bin/rango.js +92 -16
- package/dist/vite/index.js +166 -70
- package/package.json +19 -18
- package/skills/breadcrumbs/SKILL.md +1 -1
- package/skills/bundle-analysis/SKILL.md +2 -2
- package/skills/cache-guide/SKILL.md +2 -2
- package/skills/caching/SKILL.md +16 -9
- package/skills/debug-manifest/SKILL.md +4 -2
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/handler-use/SKILL.md +1 -1
- package/skills/hooks/SKILL.md +2 -2
- package/skills/host-router/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +1 -1
- package/skills/loader/SKILL.md +2 -0
- package/skills/migrate-react-router/SKILL.md +4 -2
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/prerender/SKILL.md +2 -0
- package/skills/rango/SKILL.md +12 -11
- package/skills/response-routes/SKILL.md +2 -2
- package/skills/route/SKILL.md +4 -0
- package/skills/router-setup/SKILL.md +3 -0
- package/skills/scripts/SKILL.md +179 -0
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/bindings.md +20 -6
- package/skills/testing/cache-prerender.md +5 -2
- package/skills/testing/client-components.md +2 -0
- package/skills/testing/e2e-parity.md +1 -1
- package/skills/testing/flight.md +8 -9
- package/skills/testing/render-handler.md +1 -1
- package/skills/testing/response-routes.md +1 -1
- package/skills/testing/server-actions.md +11 -11
- package/skills/testing/setup.md +3 -0
- package/skills/typesafety/SKILL.md +3 -2
- package/skills/use-cache/SKILL.md +10 -9
- package/src/browser/event-controller.ts +109 -2
- package/src/browser/partial-update.ts +12 -0
- package/src/browser/prefetch/cache.ts +17 -0
- package/src/browser/prefetch/fetch.ts +69 -2
- package/src/browser/react/Link.tsx +30 -5
- package/src/browser/react/NavigationProvider.tsx +12 -2
- package/src/browser/react/location-state-shared.ts +14 -2
- package/src/browser/react/use-href.tsx +8 -1
- package/src/browser/react/use-link-status.ts +23 -2
- package/src/browser/response-adapter.ts +14 -3
- package/src/browser/rsc-router.tsx +3 -0
- package/src/browser/scroll-restoration.ts +8 -3
- package/src/browser/server-action-bridge.ts +46 -11
- package/src/browser/types.ts +6 -0
- package/src/build/generate-route-types.ts +0 -1
- package/src/build/route-trie.ts +33 -9
- package/src/build/route-types/include-resolution.ts +7 -1
- package/src/build/route-types/router-processing.ts +0 -6
- package/src/build/route-types/source-scan.ts +105 -7
- package/src/cache/cache-policy.ts +42 -8
- package/src/cache/cache-runtime.ts +65 -5
- package/src/cache/cache-scope.ts +71 -11
- package/src/cache/cache-tag.ts +7 -2
- package/src/cache/cf/cf-base64.ts +33 -0
- package/src/cache/cf/cf-cache-constants.ts +127 -0
- package/src/cache/cf/cf-cache-store.ts +85 -613
- package/src/cache/cf/cf-cache-types.ts +349 -0
- package/src/cache/cf/cf-kv-utils.ts +46 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
- package/src/cache/document-cache.ts +11 -0
- package/src/cache/handle-snapshot.ts +8 -1
- package/src/cache/profile-registry.ts +25 -1
- package/src/cache/segment-codec.ts +9 -1
- package/src/cache/types.ts +4 -0
- package/src/client.rsc.tsx +38 -0
- package/src/client.tsx +11 -0
- package/src/components/DefaultDocument.tsx +8 -2
- package/src/context-var.ts +1 -1
- package/src/decode-loader-results.ts +7 -1
- package/src/escape-script.ts +52 -0
- package/src/handles/MetaTags.tsx +56 -5
- package/src/handles/Scripts.tsx +183 -0
- package/src/handles/breadcrumbs.ts +29 -11
- package/src/handles/is-thenable.ts +19 -0
- package/src/handles/meta.ts +46 -0
- package/src/handles/script.ts +244 -0
- package/src/host/cookie-handler.ts +7 -3
- package/src/host/pattern-matcher.ts +16 -2
- package/src/index.rsc.ts +5 -0
- package/src/index.ts +5 -0
- package/src/response-utils.ts +25 -0
- package/src/route-definition/dsl-helpers.ts +7 -0
- package/src/route-definition/redirect.ts +1 -2
- package/src/router/content-negotiation.ts +58 -10
- package/src/router/intercept-resolution.ts +9 -0
- package/src/router/match-middleware/cache-store.ts +10 -1
- package/src/router/middleware.ts +10 -3
- package/src/router/pattern-matching.ts +25 -23
- package/src/router/prefetch-cache-ttl.ts +51 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +23 -0
- package/src/router/segment-resolution/fresh.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +35 -1
- package/src/router/segment-resolution/loader-cache.ts +10 -6
- package/src/router/segment-resolution/revalidation.ts +6 -0
- package/src/router/segment-resolution.ts +1 -0
- package/src/router/trie-matching.ts +14 -9
- package/src/router.ts +18 -10
- package/src/rsc/handler.ts +52 -13
- package/src/rsc/helpers.ts +7 -1
- package/src/rsc/index.ts +1 -4
- package/src/rsc/loader-fetch.ts +107 -37
- package/src/rsc/progressive-enhancement.ts +18 -6
- package/src/rsc/response-cache-serve.ts +238 -0
- package/src/rsc/response-route-handler.ts +16 -133
- package/src/rsc/rsc-rendering.ts +13 -4
- package/src/rsc/server-action.ts +52 -6
- package/src/rsc/types.ts +7 -0
- package/src/search-params.ts +24 -5
- package/src/segment-loader-promise.ts +17 -2
- package/src/server/loader-registry.ts +16 -18
- package/src/server/request-context.ts +47 -20
- package/src/testing/dispatch.ts +108 -25
- package/src/testing/flight.ts +25 -0
- package/src/testing/internal/context.ts +25 -2
- package/src/testing/render-handler.ts +3 -1
- package/src/testing/render-route.tsx +15 -0
- package/src/testing/run-loader.ts +10 -3
- package/src/theme/ThemeProvider.tsx +20 -6
- package/src/theme/ThemeScript.tsx +7 -3
- package/src/theme/constants.ts +54 -3
- package/src/theme/theme-script.ts +22 -7
- package/src/types/request-scope.ts +8 -3
- package/src/vite/plugins/cjs-to-esm.ts +8 -1
- package/src/vite/plugins/expose-id-utils.ts +10 -1
- package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
- package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
- package/src/vite/plugins/expose-internal-ids.ts +0 -1
- package/src/vite/plugins/version-plugin.ts +5 -17
- package/src/vite/plugins/virtual-entries.ts +12 -2
- package/src/vite/rango.ts +15 -6
- package/src/vite/utils/ast-handler-extract.ts +11 -4
- package/src/vite/utils/directive-prologue.ts +40 -0
- package/src/vite/utils/prerender-utils.ts +17 -2
|
@@ -52,8 +52,9 @@ export async function ProductCard({ id }: { id: string }) {
|
|
|
52
52
|
|
|
53
53
|
## Named Cache Profiles
|
|
54
54
|
|
|
55
|
-
Define profiles in createRouter. Profile names map to `"use cache: <name>"`
|
|
56
|
-
`cache(
|
|
55
|
+
Define profiles in createRouter. Profile names map to `"use cache: <name>"` in
|
|
56
|
+
the directive. The DSL `cache()` does not accept a string profile name; use an
|
|
57
|
+
options object (`cache({ ttl: 60 })`) or the `"use cache: <name>"` directive.
|
|
57
58
|
|
|
58
59
|
```typescript
|
|
59
60
|
createRouter({
|
|
@@ -340,13 +341,13 @@ that same namespace). The separate `revalidate()` export is the client-update ax
|
|
|
340
341
|
|
|
341
342
|
## Interaction with Other Caching
|
|
342
343
|
|
|
343
|
-
| Mechanism
|
|
344
|
-
|
|
|
345
|
-
| `"use cache"`
|
|
346
|
-
| `cache()` DSL
|
|
347
|
-
| `cache(
|
|
348
|
-
| `Static()`
|
|
349
|
-
| `Prerender()`
|
|
344
|
+
| Mechanism | Granularity | When | Use case |
|
|
345
|
+
| -------------------- | ------------------ | ---------- | ----------------------------------------------- |
|
|
346
|
+
| `"use cache"` | Function/component | Runtime | Cache individual data fetches or components |
|
|
347
|
+
| `cache()` DSL | Route segment | Runtime | Cache entire route subtrees with children |
|
|
348
|
+
| `cache({ ttl })` DSL | Route segment | Runtime | Cache a route subtree with explicit options |
|
|
349
|
+
| `Static()` | Route segment | Build-time | Render once, never re-render |
|
|
350
|
+
| `Prerender()` | Route segment | Build-time | Pre-render known params, optional live fallback |
|
|
350
351
|
|
|
351
352
|
## Dev Mode
|
|
352
353
|
|
|
@@ -168,6 +168,22 @@ export interface ActionHandle extends Disposable {
|
|
|
168
168
|
startStreaming(): StreamingToken;
|
|
169
169
|
/** Record segments that were revalidated */
|
|
170
170
|
recordRevalidatedSegments(segmentIds: string[]): void;
|
|
171
|
+
/**
|
|
172
|
+
* Claim the subset of a location-state payload this action may write. A key
|
|
173
|
+
* is claimed only if no later-initiated action in the SAME cohort has already
|
|
174
|
+
* claimed it, so same-key concurrent writes resolve to the last-initiated
|
|
175
|
+
* action while distinct keys from every action survive. Recording the claim
|
|
176
|
+
* stops a later-settling earlier action from overwriting it. Arbitration is
|
|
177
|
+
* scoped to the action's cohort (its originating history entry, captured at
|
|
178
|
+
* startAction), so an action on one entry cannot suppress an action on
|
|
179
|
+
* another that happens to write the same slot.
|
|
180
|
+
*
|
|
181
|
+
* Contract: this guarantees the FINAL value is the last-initiated action's,
|
|
182
|
+
* not that the loser is never momentarily visible. When an earlier-initiated
|
|
183
|
+
* action settles first, its value is merged and observable until the
|
|
184
|
+
* later-initiated winner's response lands and overwrites it.
|
|
185
|
+
*/
|
|
186
|
+
claimLocationState(state: Record<string, unknown>): Record<string, unknown>;
|
|
171
187
|
/** Complete the action with result */
|
|
172
188
|
complete(result?: unknown): void;
|
|
173
189
|
/** Fail the action with error */
|
|
@@ -194,7 +210,12 @@ export interface EventController {
|
|
|
194
210
|
abortNavigation(): void;
|
|
195
211
|
|
|
196
212
|
// Action operations
|
|
197
|
-
startAction(
|
|
213
|
+
startAction(
|
|
214
|
+
actionId: string,
|
|
215
|
+
args: unknown[],
|
|
216
|
+
/** Originating history entry key; scopes location-state arbitration. */
|
|
217
|
+
cohort?: string,
|
|
218
|
+
): ActionHandle;
|
|
198
219
|
abortAllActions(): void;
|
|
199
220
|
|
|
200
221
|
// State access
|
|
@@ -308,6 +329,28 @@ export function createEventController(
|
|
|
308
329
|
|
|
309
330
|
const concurrentRevalidatedSegments = new Set<string>();
|
|
310
331
|
|
|
332
|
+
// Monotonic dispatch counter: every startAction() takes the next value
|
|
333
|
+
// (private), so a larger sequence means the action was initiated later.
|
|
334
|
+
let actionDispatchSeq = 0;
|
|
335
|
+
|
|
336
|
+
// Concurrent location-state arbitration, scoped PER COHORT (history entry).
|
|
337
|
+
// Each cohort owns a slotKey->winningDispatchSeq map plus a refcount of its
|
|
338
|
+
// inflight actions; claimLocationState() consults its own cohort's map so
|
|
339
|
+
// same-key writes within one entry resolve to the last-initiated action
|
|
340
|
+
// regardless of settle order, while actions on different entries never
|
|
341
|
+
// compete. A cohort's map is freed once its LAST action's cleanup runs — the
|
|
342
|
+
// same brief post-settle grace (doSettle's 100ms timer) as other action
|
|
343
|
+
// teardown, not when every action everywhere settles — so a long-running
|
|
344
|
+
// action in one cohort can never retain arbitration keys from other cohorts
|
|
345
|
+
// that have since drained. It is never cleared in clearConsolidation, which
|
|
346
|
+
// fires
|
|
347
|
+
// per-action on divert/error and would let a later-settling earlier action
|
|
348
|
+
// wrongly reclaim a key a sibling already won.
|
|
349
|
+
const cohortArbitration = new Map<
|
|
350
|
+
string,
|
|
351
|
+
{ keySeq: Map<string, number>; inflight: number }
|
|
352
|
+
>();
|
|
353
|
+
|
|
311
354
|
let activeStreamCount = 0;
|
|
312
355
|
|
|
313
356
|
let handleData: HandleData = {};
|
|
@@ -510,10 +553,29 @@ export function createEventController(
|
|
|
510
553
|
// Action Operations
|
|
511
554
|
// ========================================================================
|
|
512
555
|
|
|
513
|
-
function startAction(
|
|
556
|
+
function startAction(
|
|
557
|
+
actionId: string,
|
|
558
|
+
args: unknown[],
|
|
559
|
+
cohort?: string,
|
|
560
|
+
): ActionHandle {
|
|
514
561
|
const id = `${actionId}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
562
|
+
// Private to this handle: never exposed on the returned ActionHandle.
|
|
563
|
+
const dispatchSeq = actionDispatchSeq++;
|
|
515
564
|
const abort = new AbortController();
|
|
516
565
|
|
|
566
|
+
// Register this action under its cohort (originating history entry). The
|
|
567
|
+
// cohort's arbitration is created on first use and freed when its last
|
|
568
|
+
// action settles (see doSettle). Keyless entries share the "" cohort.
|
|
569
|
+
const cohortId = cohort ?? "";
|
|
570
|
+
let arb = cohortArbitration.get(cohortId);
|
|
571
|
+
if (!arb) {
|
|
572
|
+
arb = { keySeq: new Map<string, number>(), inflight: 0 };
|
|
573
|
+
cohortArbitration.set(cohortId, arb);
|
|
574
|
+
}
|
|
575
|
+
// const so the captured reference stays non-undefined inside doSettle.
|
|
576
|
+
const arbitration = arb;
|
|
577
|
+
arbitration.inflight++;
|
|
578
|
+
|
|
517
579
|
// Track if this action started while others were pending (concurrent)
|
|
518
580
|
const hadConcurrent = inflightActions.size > 0;
|
|
519
581
|
if (hadConcurrent) {
|
|
@@ -537,11 +599,28 @@ export function createEventController(
|
|
|
537
599
|
let settled = false;
|
|
538
600
|
let streamingEnded = false;
|
|
539
601
|
let actionCompleted = false;
|
|
602
|
+
let cohortReleased = false;
|
|
540
603
|
let pendingResult:
|
|
541
604
|
| { type: "success"; value?: unknown }
|
|
542
605
|
| { type: "error"; value: unknown }
|
|
543
606
|
| null = null;
|
|
544
607
|
|
|
608
|
+
// Release this action's hold on its cohort arbitration exactly once: drop
|
|
609
|
+
// the refcount and, only if the map still points at THIS arbitration object,
|
|
610
|
+
// delete it. A newer generation may have replaced it (e.g. abortAllActions
|
|
611
|
+
// cleared the map and a fresh action recreated the same cohort id), so a
|
|
612
|
+
// stale settlement must never delete the newer one by id.
|
|
613
|
+
function releaseCohort() {
|
|
614
|
+
if (cohortReleased) return;
|
|
615
|
+
cohortReleased = true;
|
|
616
|
+
if (
|
|
617
|
+
--arbitration.inflight <= 0 &&
|
|
618
|
+
cohortArbitration.get(cohortId) === arbitration
|
|
619
|
+
) {
|
|
620
|
+
cohortArbitration.delete(cohortId);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
545
624
|
function doSettle() {
|
|
546
625
|
if (settled) return;
|
|
547
626
|
settled = true;
|
|
@@ -549,6 +628,9 @@ export function createEventController(
|
|
|
549
628
|
// Cleanup after brief delay (allow useAction to read result)
|
|
550
629
|
setTimeout(() => {
|
|
551
630
|
inflightActions.delete(id);
|
|
631
|
+
// Free this cohort's arbitration once its last action has settled, so
|
|
632
|
+
// a long-running action elsewhere cannot pin keys from a drained entry.
|
|
633
|
+
releaseCohort();
|
|
552
634
|
// Check for consolidation
|
|
553
635
|
if (inflightActions.size === 0) {
|
|
554
636
|
// All actions done - reset tracking
|
|
@@ -625,6 +707,26 @@ export function createEventController(
|
|
|
625
707
|
segmentIds.forEach((id) => concurrentRevalidatedSegments.add(id));
|
|
626
708
|
},
|
|
627
709
|
|
|
710
|
+
claimLocationState(state: Record<string, unknown>) {
|
|
711
|
+
const winning: Record<string, unknown> = {};
|
|
712
|
+
// Arbitrate against this action's OWN captured arbitration object, not a
|
|
713
|
+
// live map lookup: a concurrent map clear/replace (abortAllActions, a
|
|
714
|
+
// stale settlement) must not make this action silently stop recording
|
|
715
|
+
// and accept every key.
|
|
716
|
+
const keySeq = arbitration.keySeq;
|
|
717
|
+
for (const key of Object.keys(state)) {
|
|
718
|
+
const prevSeq = keySeq.get(key);
|
|
719
|
+
// Strictly-greater: a later-initiated action wins a key over an
|
|
720
|
+
// earlier one in the same cohort regardless of arrival order. Equal
|
|
721
|
+
// cannot happen (dispatchSeq is unique per action).
|
|
722
|
+
if (prevSeq === undefined || dispatchSeq > prevSeq) {
|
|
723
|
+
keySeq.set(key, dispatchSeq);
|
|
724
|
+
winning[key] = state[key];
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return winning;
|
|
728
|
+
},
|
|
729
|
+
|
|
628
730
|
complete(result?: unknown) {
|
|
629
731
|
settleWith({ type: "success", value: result });
|
|
630
732
|
},
|
|
@@ -646,6 +748,10 @@ export function createEventController(
|
|
|
646
748
|
[Symbol.dispose]() {
|
|
647
749
|
// If aborted, another navigation/error took over - don't touch state
|
|
648
750
|
if (abort.signal.aborted) {
|
|
751
|
+
// Aborted actions skip doSettle, so release the cohort hold here to
|
|
752
|
+
// keep the per-cohort refcount balanced (no leak when an action is
|
|
753
|
+
// aborted individually rather than via abortAllActions).
|
|
754
|
+
releaseCohort();
|
|
649
755
|
inflightActions.delete(id);
|
|
650
756
|
notify();
|
|
651
757
|
notifyAction(actionId);
|
|
@@ -680,6 +786,7 @@ export function createEventController(
|
|
|
680
786
|
}
|
|
681
787
|
hadAnyConcurrentActions = false;
|
|
682
788
|
concurrentRevalidatedSegments.clear();
|
|
789
|
+
cohortArbitration.clear();
|
|
683
790
|
notify();
|
|
684
791
|
// Notify all action listeners directly by subscription ID.
|
|
685
792
|
// actionListeners keys are subscription IDs (possibly short names like
|
|
@@ -545,6 +545,18 @@ export function createPartialUpdater(
|
|
|
545
545
|
|
|
546
546
|
if (mode.type === "stale-revalidation") {
|
|
547
547
|
await rawStreamComplete;
|
|
548
|
+
// Mirror the partial branch's history-key staleness guard (above): the
|
|
549
|
+
// await above is a real async suspension, so the user may have navigated
|
|
550
|
+
// away while this background revalidation was draining. Dropping a late
|
|
551
|
+
// full-update here prevents it from clobbering the freshly committed UI
|
|
552
|
+
// of the page the user moved to.
|
|
553
|
+
const historyKeyNow = store.getHistoryKey();
|
|
554
|
+
if (historyKeyNow !== historyKeyAtStart) {
|
|
555
|
+
debugLog(
|
|
556
|
+
`[Browser] Stale revalidation (full update): history key changed (${historyKeyAtStart} -> ${historyKeyNow}), skipping UI update`,
|
|
557
|
+
);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
548
560
|
startTransition(() => {
|
|
549
561
|
if (fullHasTransition && addTransitionType) {
|
|
550
562
|
addTransitionType("action");
|
|
@@ -268,6 +268,23 @@ export function storePrefetch(
|
|
|
268
268
|
cache.set(key, { entry, timestamp: now });
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Remove a single stored prefetch entry. Used to evict an entry whose body
|
|
273
|
+
* stream stalled after headers arrived (its payload / streamComplete never
|
|
274
|
+
* settle), so future prefetches and navigation refetch instead of dedupe-ing
|
|
275
|
+
* against — and awaiting — a stuck entry.
|
|
276
|
+
*
|
|
277
|
+
* Identity-guarded: only evicts when the entry CURRENTLY stored under `key` is
|
|
278
|
+
* the exact `entry` we published. A generation check alone is insufficient —
|
|
279
|
+
* after this entry is consumed (consumePrefetch) and a fresh prefetch
|
|
280
|
+
* republishes under the SAME key in the SAME generation, a gen-only guard would
|
|
281
|
+
* delete that valid newer entry. Reference identity drops only our own stalled
|
|
282
|
+
* entry.
|
|
283
|
+
*/
|
|
284
|
+
export function removePrefetch(key: string, entry: DecodedPrefetch): void {
|
|
285
|
+
if (cache.get(key)?.entry === entry) cache.delete(key);
|
|
286
|
+
}
|
|
287
|
+
|
|
271
288
|
/**
|
|
272
289
|
* Capture the current generation. The returned value is passed to
|
|
273
290
|
* storePrefetch so it can detect stale completions.
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
markPrefetchInflight,
|
|
23
23
|
setInflightPromiseWithAliases,
|
|
24
24
|
storePrefetch,
|
|
25
|
+
removePrefetch,
|
|
25
26
|
clearPrefetchInflight,
|
|
26
27
|
currentGeneration,
|
|
27
28
|
type DecodedPrefetch,
|
|
@@ -44,6 +45,19 @@ type PrefetchDecoder = (response: Promise<Response>) => Promise<RscPayload>;
|
|
|
44
45
|
|
|
45
46
|
let decoder: PrefetchDecoder | null = null;
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Hard ceiling for ANY prefetch fetch (hover/direct AND queue-driven). A server
|
|
50
|
+
* that stalls leaves the fetch pending forever — its `.finally()` never runs,
|
|
51
|
+
* `clearPrefetchInflight` never fires, and `hasPrefetch(key)` stays true,
|
|
52
|
+
* permanently deduping every future prefetch of that URL. The hover path passes
|
|
53
|
+
* no signal; the queue passes its own AbortController signal that only aborts on
|
|
54
|
+
* navigation, never on a stall — so the timeout is layered on BOTH (combined
|
|
55
|
+
* with any caller signal via AbortSignal.any) and aborts the stalled fetch so it
|
|
56
|
+
* settles (rejects) and the inflight key is always released. Generous so a
|
|
57
|
+
* slow-but-live response is never cut short.
|
|
58
|
+
*/
|
|
59
|
+
const PREFETCH_FETCH_TIMEOUT_MS = 30_000;
|
|
60
|
+
|
|
47
61
|
/**
|
|
48
62
|
* Wire the RSC decoder used to eagerly decode prefetched responses. Called
|
|
49
63
|
* once from initBrowserApp with the same createFromFetch the navigation client
|
|
@@ -149,6 +163,47 @@ function executePrefetchFetch(
|
|
|
149
163
|
: [wildcardKey, sourceKey];
|
|
150
164
|
for (const k of inflightKeys) markPrefetchInflight(k);
|
|
151
165
|
|
|
166
|
+
// Always layer a stall timeout. It covers BOTH "no response ever arrives"
|
|
167
|
+
// (strands the inflight key) AND "the body stalls after headers" (leaves a
|
|
168
|
+
// published entry whose payload/streamComplete never settle, that future
|
|
169
|
+
// prefetches dedupe against and navigation awaits forever). Applies to the
|
|
170
|
+
// hover/direct path (no caller signal) and the queue-driven path (whose caller
|
|
171
|
+
// signal only aborts on navigation, never on a stall). On fire it aborts the
|
|
172
|
+
// fetch/stream and evicts the published entry if one exists; it is NOT cleared
|
|
173
|
+
// when headers arrive (see below) — it is cleared when the stream completes, or
|
|
174
|
+
// in `.finally()` for paths that publish no streaming entry.
|
|
175
|
+
let publishedKey: string | undefined;
|
|
176
|
+
let publishedEntry: DecodedPrefetch | undefined;
|
|
177
|
+
const timeoutController = new AbortController();
|
|
178
|
+
const timeoutId: ReturnType<typeof setTimeout> = setTimeout(() => {
|
|
179
|
+
timeoutController.abort();
|
|
180
|
+
// Body stalled after headers: evict the published-but-never-settling entry
|
|
181
|
+
// so future prefetches/navigation refetch. Identity-guarded (pass the exact
|
|
182
|
+
// entry) so a fresh entry republished under the same key — after this one was
|
|
183
|
+
// consumed — is NOT dropped. The abort cancels the tee (its finally resolves
|
|
184
|
+
// streamComplete) and rejects the eager decode.
|
|
185
|
+
if (publishedKey !== undefined && publishedEntry !== undefined) {
|
|
186
|
+
removePrefetch(publishedKey, publishedEntry);
|
|
187
|
+
}
|
|
188
|
+
}, PREFETCH_FETCH_TIMEOUT_MS);
|
|
189
|
+
let effectiveSignal: AbortSignal;
|
|
190
|
+
if (!signal) {
|
|
191
|
+
effectiveSignal = timeoutController.signal;
|
|
192
|
+
} else if (typeof AbortSignal.any === "function") {
|
|
193
|
+
// Combine the caller's signal (navigation-abort) with the timeout so either
|
|
194
|
+
// can settle the fetch.
|
|
195
|
+
effectiveSignal = AbortSignal.any([signal, timeoutController.signal]);
|
|
196
|
+
} else {
|
|
197
|
+
// Legacy runtime without AbortSignal.any: forward the caller's abort onto the
|
|
198
|
+
// timeout controller so a single signal carries both reasons.
|
|
199
|
+
effectiveSignal = timeoutController.signal;
|
|
200
|
+
if (signal.aborted) timeoutController.abort();
|
|
201
|
+
else
|
|
202
|
+
signal.addEventListener("abort", () => timeoutController.abort(), {
|
|
203
|
+
once: true,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
152
207
|
const promise: Promise<DecodedPrefetch | null> = fetch(fetchUrl, {
|
|
153
208
|
priority: "low" as RequestPriority,
|
|
154
209
|
// During an action's flight the state is not rotated, so the old
|
|
@@ -157,7 +212,7 @@ function executePrefetchFetch(
|
|
|
157
212
|
// fence's HTTP-cache-bypass requirement applies to prefetch as well as
|
|
158
213
|
// navigation fetches).
|
|
159
214
|
...(isActionFenceActive() && { cache: "no-store" as RequestCache }),
|
|
160
|
-
signal,
|
|
215
|
+
signal: effectiveSignal,
|
|
161
216
|
headers: {
|
|
162
217
|
"X-Rango-State": getRangoState(),
|
|
163
218
|
"X-RSC-Router-Client-Path": window.location.href,
|
|
@@ -197,7 +252,7 @@ function executePrefetchFetch(
|
|
|
197
252
|
const tracked = teeWithCompletion(
|
|
198
253
|
response,
|
|
199
254
|
() => resolveStreamComplete(),
|
|
200
|
-
|
|
255
|
+
effectiveSignal,
|
|
201
256
|
// Speculative prefetch: a never-consumed/aborted stream error is benign.
|
|
202
257
|
true,
|
|
203
258
|
);
|
|
@@ -211,11 +266,23 @@ function executePrefetchFetch(
|
|
|
211
266
|
|
|
212
267
|
const entry: DecodedPrefetch = { payload, streamComplete, scope };
|
|
213
268
|
storePrefetch(storageKey, entry, gen);
|
|
269
|
+
// The stall timeout now owns the body stream: arm eviction (publishedKey)
|
|
270
|
+
// and clear the timer once the stream completes. The tee's finally resolves
|
|
271
|
+
// streamComplete on normal completion AND on abort, so a healthy body pays
|
|
272
|
+
// no lingering timer while a stalled one is evicted when the timer fires.
|
|
273
|
+
publishedKey = storageKey;
|
|
274
|
+
publishedEntry = entry;
|
|
275
|
+
streamComplete.then(() => clearTimeout(timeoutId));
|
|
214
276
|
return entry;
|
|
215
277
|
})
|
|
216
278
|
.catch(() => null)
|
|
217
279
|
.finally(() => {
|
|
218
280
|
clearPrefetchInflight(inflightKeys[0]!);
|
|
281
|
+
// Clear the stall timer here ONLY for paths that published no streaming
|
|
282
|
+
// entry (null return / fetch error / abort): the operation is fully done.
|
|
283
|
+
// When an entry WAS published, the timer stays armed to bound the body
|
|
284
|
+
// stream and is cleared on streamComplete (above) or on fire (eviction).
|
|
285
|
+
if (publishedKey === undefined) clearTimeout(timeoutId);
|
|
219
286
|
});
|
|
220
287
|
|
|
221
288
|
setInflightPromiseWithAliases(inflightKeys, promise);
|
|
@@ -39,8 +39,18 @@ import {
|
|
|
39
39
|
unobserveForPrefetch,
|
|
40
40
|
} from "../prefetch/observer.js";
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Read current touch/no-hover capability. Evaluated at the point of use (per
|
|
44
|
+
* render) rather than once at module load, so `prefetch="adaptive"` reacts to
|
|
45
|
+
* input-capability changes on hybrid devices (touch laptops, tablets gaining or
|
|
46
|
+
* losing a pointer) and after SSR -> hydrate. The SSR guard returns a stable
|
|
47
|
+
* `false` (pointer/hover default) so the resolved strategy doesn't drift on the
|
|
48
|
+
* server vs the first client render.
|
|
49
|
+
*/
|
|
50
|
+
function isTouchDevice(): boolean {
|
|
51
|
+
if (typeof window === "undefined") return false;
|
|
52
|
+
return window.matchMedia("(hover: none)").matches;
|
|
53
|
+
}
|
|
44
54
|
|
|
45
55
|
/**
|
|
46
56
|
* Prefetch strategy for the Link component
|
|
@@ -57,6 +67,20 @@ export type PrefetchStrategy =
|
|
|
57
67
|
| "adaptive"
|
|
58
68
|
| "none";
|
|
59
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Resolve a prefetch strategy, expanding "adaptive" to the concrete strategy
|
|
72
|
+
* for the CURRENT input capability: "viewport" on touch (no-hover) devices,
|
|
73
|
+
* "hover" on pointer devices. Non-adaptive strategies pass through unchanged.
|
|
74
|
+
* Reads touch capability live (not a module-load snapshot) so the result
|
|
75
|
+
* tracks input-capability changes.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveAdaptiveStrategy(
|
|
78
|
+
prefetch: PrefetchStrategy,
|
|
79
|
+
): PrefetchStrategy {
|
|
80
|
+
if (prefetch !== "adaptive") return prefetch;
|
|
81
|
+
return isTouchDevice() ? "viewport" : "hover";
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
/**
|
|
61
85
|
* Link component props
|
|
62
86
|
*/
|
|
@@ -228,9 +252,10 @@ export const Link: ForwardRefExoticComponent<
|
|
|
228
252
|
return to === "/" ? bn : bn + to;
|
|
229
253
|
}, [to, isExternal, ctx?.basename]);
|
|
230
254
|
|
|
231
|
-
// Resolve adaptive: viewport on touch devices, hover on pointer devices
|
|
232
|
-
|
|
233
|
-
|
|
255
|
+
// Resolve adaptive: viewport on touch devices, hover on pointer devices.
|
|
256
|
+
// isTouchDevice() is read here (per render), not from a module-load snapshot,
|
|
257
|
+
// so a device whose input capability changes resolves to the current value.
|
|
258
|
+
const resolvedStrategy = resolveAdaptiveStrategy(prefetch);
|
|
234
259
|
|
|
235
260
|
// Internal ref for viewport observation; merge with forwarded ref
|
|
236
261
|
const internalRef = useRef<HTMLAnchorElement | null>(null);
|
|
@@ -166,6 +166,14 @@ export interface NavigationProviderProps {
|
|
|
166
166
|
* load (X-RSC-Reload), so the target app establishes its own shell on load.
|
|
167
167
|
*/
|
|
168
168
|
appShellRef?: AppShellRef;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* CSP nonce to expose via NonceContext. Production leaves this undefined — the
|
|
172
|
+
* browser has no nonce (it is a server-side HTML concern), and SSR provides the
|
|
173
|
+
* nonce through its own NonceContext.Provider. Test harnesses (renderRoute) set
|
|
174
|
+
* it to seed a nonce so components calling useNonce() can be exercised.
|
|
175
|
+
*/
|
|
176
|
+
nonce?: string;
|
|
169
177
|
}
|
|
170
178
|
|
|
171
179
|
/**
|
|
@@ -200,6 +208,7 @@ export function NavigationProvider({
|
|
|
200
208
|
version,
|
|
201
209
|
basename,
|
|
202
210
|
appShellRef,
|
|
211
|
+
nonce,
|
|
203
212
|
}: NavigationProviderProps): ReactNode {
|
|
204
213
|
// Track current payload for rendering (this triggers re-renders)
|
|
205
214
|
const [payload, setPayload] = useState(initialPayload);
|
|
@@ -377,9 +386,10 @@ export function NavigationProvider({
|
|
|
377
386
|
|
|
378
387
|
// Match SSR tree shape: NonceContext.Provider is always present so
|
|
379
388
|
// hydration sees the same component tree. Value is undefined on the
|
|
380
|
-
// client — CSP nonces are a server-side HTML concern
|
|
389
|
+
// client — CSP nonces are a server-side HTML concern — unless a test
|
|
390
|
+
// harness seeded one via the `nonce` prop.
|
|
381
391
|
content = (
|
|
382
|
-
<NonceContext.Provider value={
|
|
392
|
+
<NonceContext.Provider value={nonce}>{content}</NonceContext.Provider>
|
|
383
393
|
);
|
|
384
394
|
|
|
385
395
|
return (
|
|
@@ -255,7 +255,14 @@ export function createLocationState<TState>(
|
|
|
255
255
|
);
|
|
256
256
|
}
|
|
257
257
|
const key = getKey();
|
|
258
|
-
|
|
258
|
+
// history.state may be a non-null primitive (string/number/boolean) if
|
|
259
|
+
// non-Rango code called pushState/replaceState with one. `?? {}` only
|
|
260
|
+
// catches null/undefined, so spreading a primitive would yield indexed
|
|
261
|
+
// char/no keys and corrupt history.state. Coerce any non-object to a fresh
|
|
262
|
+
// dict — mirrors the delete() guard.
|
|
263
|
+
const existing = window.history.state;
|
|
264
|
+
const current =
|
|
265
|
+
existing !== null && typeof existing === "object" ? existing : {};
|
|
259
266
|
window.history.replaceState(
|
|
260
267
|
{ ...current, [key]: value },
|
|
261
268
|
"",
|
|
@@ -275,7 +282,12 @@ export function createLocationState<TState>(
|
|
|
275
282
|
}
|
|
276
283
|
const key = getKey();
|
|
277
284
|
const current = window.history.state;
|
|
278
|
-
|
|
285
|
+
// history.state may be a non-null primitive (string/number/boolean) if
|
|
286
|
+
// non-Rango code called pushState/replaceState with one. `key in
|
|
287
|
+
// <primitive>` throws, so require an object before the `in` check; a
|
|
288
|
+
// primitive carries no slots, so deletion is a no-op.
|
|
289
|
+
if (current === null || typeof current !== "object" || !(key in current))
|
|
290
|
+
return;
|
|
279
291
|
const next = { ...current };
|
|
280
292
|
delete next[key];
|
|
281
293
|
window.history.replaceState(next, "", window.location.href);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useCallback } from "react";
|
|
3
4
|
import { href, type ValidPaths } from "../../href-client.js";
|
|
4
5
|
import { useMount } from "./use-mount.js";
|
|
5
6
|
|
|
@@ -36,5 +37,11 @@ import { useMount } from "./use-mount.js";
|
|
|
36
37
|
*/
|
|
37
38
|
export function useHref(): (path: `/${string}`) => string {
|
|
38
39
|
const mount = useMount();
|
|
39
|
-
|
|
40
|
+
// Memoize on `mount` (stable within a route) so the returned function is
|
|
41
|
+
// referentially stable across re-renders — a consumer can safely pass it as a
|
|
42
|
+
// dependency or a prop to a memoized child without forcing re-renders.
|
|
43
|
+
return useCallback(
|
|
44
|
+
(path: `/${string}`) => href(path as ValidPaths, mount),
|
|
45
|
+
[mount],
|
|
46
|
+
);
|
|
40
47
|
}
|
|
@@ -95,6 +95,13 @@ export function useLinkStatus(): LinkStatus {
|
|
|
95
95
|
|
|
96
96
|
const prevPending = useRef(basePending);
|
|
97
97
|
|
|
98
|
+
// Tracks whether the most recent setOptimisticPending call pinned the value
|
|
99
|
+
// to a non-idle (loading) state. Used to decide whether to emit a release
|
|
100
|
+
// update when returning to idle, so the optimistic store doesn't stay pinned
|
|
101
|
+
// to `true` if a parent transition (e.g. the <Link> click / view transition
|
|
102
|
+
// commit) is still pending. Mirrors useNavigation's optimisticPinnedRef.
|
|
103
|
+
const optimisticPinnedRef = useRef(false);
|
|
104
|
+
|
|
98
105
|
const [pending, setOptimisticPending] = useOptimistic(basePending);
|
|
99
106
|
|
|
100
107
|
useEffect(() => {
|
|
@@ -109,11 +116,25 @@ export function useLinkStatus(): LinkStatus {
|
|
|
109
116
|
if (isPending !== prevPending.current) {
|
|
110
117
|
prevPending.current = isPending;
|
|
111
118
|
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
const shouldPin = isPending && state.state !== "idle";
|
|
120
|
+
|
|
121
|
+
if (shouldPin) {
|
|
122
|
+
// Pin the optimistic value so the spinner shows immediately even if
|
|
123
|
+
// a parent transition (e.g. <Link> click) defers the urgent
|
|
124
|
+
// setBasePending commit.
|
|
125
|
+
startTransition(() => {
|
|
126
|
+
setOptimisticPending(isPending);
|
|
127
|
+
});
|
|
128
|
+
optimisticPinnedRef.current = true;
|
|
129
|
+
} else if (optimisticPinnedRef.current) {
|
|
130
|
+
// Release a previously-pinned optimistic value. Without this,
|
|
131
|
+
// useOptimistic keeps returning the stale `true` while any parent
|
|
132
|
+
// transition is still pending, even after basePending flipped to
|
|
133
|
+
// false at navigation completion — leaving the link spinner stuck.
|
|
114
134
|
startTransition(() => {
|
|
115
135
|
setOptimisticPending(isPending);
|
|
116
136
|
});
|
|
137
|
+
optimisticPinnedRef.current = false;
|
|
117
138
|
}
|
|
118
139
|
|
|
119
140
|
// Always update base state
|
|
@@ -88,8 +88,19 @@ export function teeWithCompletion(
|
|
|
88
88
|
signal?: AbortSignal,
|
|
89
89
|
silent = false,
|
|
90
90
|
): Response {
|
|
91
|
+
// Once-guard: a mid-stream read error runs both the finally block and the
|
|
92
|
+
// rejection's .catch, so onComplete must be settled exactly once across all
|
|
93
|
+
// paths (no-body early return, finally, catch).
|
|
94
|
+
let settled = false;
|
|
95
|
+
const settle = () => {
|
|
96
|
+
if (!settled) {
|
|
97
|
+
settled = true;
|
|
98
|
+
onComplete();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
91
102
|
if (!response.body) {
|
|
92
|
-
|
|
103
|
+
settle();
|
|
93
104
|
return response;
|
|
94
105
|
}
|
|
95
106
|
|
|
@@ -107,13 +118,13 @@ export function teeWithCompletion(
|
|
|
107
118
|
} finally {
|
|
108
119
|
if (onAbort) signal!.removeEventListener("abort", onAbort);
|
|
109
120
|
reader.releaseLock();
|
|
110
|
-
|
|
121
|
+
settle();
|
|
111
122
|
}
|
|
112
123
|
})().catch((error) => {
|
|
113
124
|
if (!silent && !signal?.aborted) {
|
|
114
125
|
console.error("[Browser] Error reading tracking stream:", error);
|
|
115
126
|
}
|
|
116
|
-
|
|
127
|
+
settle();
|
|
117
128
|
});
|
|
118
129
|
|
|
119
130
|
return new Response(rscStream, {
|
|
@@ -116,6 +116,8 @@ export interface BrowserAppContext {
|
|
|
116
116
|
initialTheme?: Theme;
|
|
117
117
|
/** Whether connection warmup is enabled */
|
|
118
118
|
warmupEnabled?: boolean;
|
|
119
|
+
/** Whether the hydrated tree should be wrapped in React.StrictMode */
|
|
120
|
+
strictMode?: boolean;
|
|
119
121
|
/** App version for prefetch version mismatch detection */
|
|
120
122
|
version?: string;
|
|
121
123
|
/**
|
|
@@ -476,6 +478,7 @@ export async function initBrowserApp(
|
|
|
476
478
|
themeConfig: effectiveThemeConfig,
|
|
477
479
|
initialTheme: effectiveInitialTheme,
|
|
478
480
|
warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
|
|
481
|
+
strictMode: initialPayload.metadata?.strictMode ?? true,
|
|
479
482
|
version,
|
|
480
483
|
appShellRef,
|
|
481
484
|
};
|
|
@@ -191,10 +191,15 @@ export function saveCurrentScrollPosition(): void {
|
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
193
|
* Persist scroll positions to sessionStorage.
|
|
194
|
-
* If the write fails
|
|
195
|
-
* entries and retry
|
|
194
|
+
* If the write fails (typically QuotaExceededError), evict the oldest ~1/4 of
|
|
195
|
+
* entries ONCE and retry the write a single time; if that still fails, remove
|
|
196
|
+
* our storage key entirely so we don't block other sessionStorage consumers.
|
|
197
|
+
* This is a single evict-then-retry-then-clear ladder, not a loop.
|
|
198
|
+
*
|
|
199
|
+
* Exported so that single eviction/retry/clear ladder is unit-testable directly.
|
|
200
|
+
* The browser drives it from the `pagehide` handler.
|
|
196
201
|
*/
|
|
197
|
-
function persistToSessionStorage(): void {
|
|
202
|
+
export function persistToSessionStorage(): void {
|
|
198
203
|
try {
|
|
199
204
|
sessionStorage.setItem(
|
|
200
205
|
SCROLL_STORAGE_KEY,
|