@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.
Files changed (141) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +43 -2
  3. package/dist/bin/rango.js +92 -16
  4. package/dist/vite/index.js +166 -70
  5. package/package.json +19 -18
  6. package/skills/breadcrumbs/SKILL.md +1 -1
  7. package/skills/bundle-analysis/SKILL.md +2 -2
  8. package/skills/cache-guide/SKILL.md +2 -2
  9. package/skills/caching/SKILL.md +16 -9
  10. package/skills/debug-manifest/SKILL.md +4 -2
  11. package/skills/document-cache/SKILL.md +2 -2
  12. package/skills/handler-use/SKILL.md +1 -1
  13. package/skills/hooks/SKILL.md +2 -2
  14. package/skills/host-router/SKILL.md +1 -1
  15. package/skills/intercept/SKILL.md +1 -1
  16. package/skills/loader/SKILL.md +2 -0
  17. package/skills/migrate-react-router/SKILL.md +4 -2
  18. package/skills/mime-routes/SKILL.md +1 -1
  19. package/skills/prerender/SKILL.md +2 -0
  20. package/skills/rango/SKILL.md +12 -11
  21. package/skills/response-routes/SKILL.md +2 -2
  22. package/skills/route/SKILL.md +4 -0
  23. package/skills/router-setup/SKILL.md +3 -0
  24. package/skills/scripts/SKILL.md +179 -0
  25. package/skills/testing/SKILL.md +1 -1
  26. package/skills/testing/bindings.md +20 -6
  27. package/skills/testing/cache-prerender.md +5 -2
  28. package/skills/testing/client-components.md +2 -0
  29. package/skills/testing/e2e-parity.md +1 -1
  30. package/skills/testing/flight.md +8 -9
  31. package/skills/testing/render-handler.md +1 -1
  32. package/skills/testing/response-routes.md +1 -1
  33. package/skills/testing/server-actions.md +11 -11
  34. package/skills/testing/setup.md +3 -0
  35. package/skills/typesafety/SKILL.md +3 -2
  36. package/skills/use-cache/SKILL.md +10 -9
  37. package/src/browser/event-controller.ts +109 -2
  38. package/src/browser/partial-update.ts +12 -0
  39. package/src/browser/prefetch/cache.ts +17 -0
  40. package/src/browser/prefetch/fetch.ts +69 -2
  41. package/src/browser/react/Link.tsx +30 -5
  42. package/src/browser/react/NavigationProvider.tsx +12 -2
  43. package/src/browser/react/location-state-shared.ts +14 -2
  44. package/src/browser/react/use-href.tsx +8 -1
  45. package/src/browser/react/use-link-status.ts +23 -2
  46. package/src/browser/response-adapter.ts +14 -3
  47. package/src/browser/rsc-router.tsx +3 -0
  48. package/src/browser/scroll-restoration.ts +8 -3
  49. package/src/browser/server-action-bridge.ts +46 -11
  50. package/src/browser/types.ts +6 -0
  51. package/src/build/generate-route-types.ts +0 -1
  52. package/src/build/route-trie.ts +33 -9
  53. package/src/build/route-types/include-resolution.ts +7 -1
  54. package/src/build/route-types/router-processing.ts +0 -6
  55. package/src/build/route-types/source-scan.ts +105 -7
  56. package/src/cache/cache-policy.ts +42 -8
  57. package/src/cache/cache-runtime.ts +65 -5
  58. package/src/cache/cache-scope.ts +71 -11
  59. package/src/cache/cache-tag.ts +7 -2
  60. package/src/cache/cf/cf-base64.ts +33 -0
  61. package/src/cache/cf/cf-cache-constants.ts +127 -0
  62. package/src/cache/cf/cf-cache-store.ts +85 -613
  63. package/src/cache/cf/cf-cache-types.ts +349 -0
  64. package/src/cache/cf/cf-kv-utils.ts +46 -0
  65. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  66. package/src/cache/document-cache.ts +11 -0
  67. package/src/cache/handle-snapshot.ts +8 -1
  68. package/src/cache/profile-registry.ts +25 -1
  69. package/src/cache/segment-codec.ts +9 -1
  70. package/src/cache/types.ts +4 -0
  71. package/src/client.rsc.tsx +38 -0
  72. package/src/client.tsx +11 -0
  73. package/src/components/DefaultDocument.tsx +8 -2
  74. package/src/context-var.ts +1 -1
  75. package/src/decode-loader-results.ts +7 -1
  76. package/src/escape-script.ts +52 -0
  77. package/src/handles/MetaTags.tsx +56 -5
  78. package/src/handles/Scripts.tsx +183 -0
  79. package/src/handles/breadcrumbs.ts +29 -11
  80. package/src/handles/is-thenable.ts +19 -0
  81. package/src/handles/meta.ts +46 -0
  82. package/src/handles/script.ts +244 -0
  83. package/src/host/cookie-handler.ts +7 -3
  84. package/src/host/pattern-matcher.ts +16 -2
  85. package/src/index.rsc.ts +5 -0
  86. package/src/index.ts +5 -0
  87. package/src/response-utils.ts +25 -0
  88. package/src/route-definition/dsl-helpers.ts +7 -0
  89. package/src/route-definition/redirect.ts +1 -2
  90. package/src/router/content-negotiation.ts +58 -10
  91. package/src/router/intercept-resolution.ts +9 -0
  92. package/src/router/match-middleware/cache-store.ts +10 -1
  93. package/src/router/middleware.ts +10 -3
  94. package/src/router/pattern-matching.ts +25 -23
  95. package/src/router/prefetch-cache-ttl.ts +51 -0
  96. package/src/router/router-interfaces.ts +7 -0
  97. package/src/router/router-options.ts +23 -0
  98. package/src/router/segment-resolution/fresh.ts +10 -0
  99. package/src/router/segment-resolution/helpers.ts +35 -1
  100. package/src/router/segment-resolution/loader-cache.ts +10 -6
  101. package/src/router/segment-resolution/revalidation.ts +6 -0
  102. package/src/router/segment-resolution.ts +1 -0
  103. package/src/router/trie-matching.ts +14 -9
  104. package/src/router.ts +18 -10
  105. package/src/rsc/handler.ts +52 -13
  106. package/src/rsc/helpers.ts +7 -1
  107. package/src/rsc/index.ts +1 -4
  108. package/src/rsc/loader-fetch.ts +107 -37
  109. package/src/rsc/progressive-enhancement.ts +18 -6
  110. package/src/rsc/response-cache-serve.ts +238 -0
  111. package/src/rsc/response-route-handler.ts +16 -133
  112. package/src/rsc/rsc-rendering.ts +13 -4
  113. package/src/rsc/server-action.ts +52 -6
  114. package/src/rsc/types.ts +7 -0
  115. package/src/search-params.ts +24 -5
  116. package/src/segment-loader-promise.ts +17 -2
  117. package/src/server/loader-registry.ts +16 -18
  118. package/src/server/request-context.ts +47 -20
  119. package/src/testing/dispatch.ts +108 -25
  120. package/src/testing/flight.ts +25 -0
  121. package/src/testing/internal/context.ts +25 -2
  122. package/src/testing/render-handler.ts +3 -1
  123. package/src/testing/render-route.tsx +15 -0
  124. package/src/testing/run-loader.ts +10 -3
  125. package/src/theme/ThemeProvider.tsx +20 -6
  126. package/src/theme/ThemeScript.tsx +7 -3
  127. package/src/theme/constants.ts +54 -3
  128. package/src/theme/theme-script.ts +22 -7
  129. package/src/types/request-scope.ts +8 -3
  130. package/src/vite/plugins/cjs-to-esm.ts +8 -1
  131. package/src/vite/plugins/expose-id-utils.ts +10 -1
  132. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -16
  133. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -5
  134. package/src/vite/plugins/expose-ids/router-transform.ts +6 -1
  135. package/src/vite/plugins/expose-internal-ids.ts +0 -1
  136. package/src/vite/plugins/version-plugin.ts +5 -17
  137. package/src/vite/plugins/virtual-entries.ts +12 -2
  138. package/src/vite/rango.ts +15 -6
  139. package/src/vite/utils/ast-handler-extract.ts +11 -4
  140. package/src/vite/utils/directive-prologue.ts +40 -0
  141. 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>"` and
56
- `cache('<name>')` in the DSL.
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 | Granularity | When | Use case |
344
- | ------------------ | ------------------ | ---------- | ----------------------------------------------- |
345
- | `"use cache"` | Function/component | Runtime | Cache individual data fetches or components |
346
- | `cache()` DSL | Route segment | Runtime | Cache entire route subtrees with children |
347
- | `cache('profile')` | Route segment | Runtime | Same as cache() with a named profile |
348
- | `Static()` | Route segment | Build-time | Render once, never re-render |
349
- | `Prerender()` | Route segment | Build-time | Pre-render known params, optional live fallback |
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(actionId: string, args: unknown[]): ActionHandle;
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(actionId: string, args: unknown[]): ActionHandle {
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
- signal,
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
- const isTouchDevice =
43
- typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
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
- const resolvedStrategy =
233
- prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
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={undefined}>{content}</NonceContext.Provider>
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
- const current = window.history.state ?? {};
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
- if (current == null || !(key in current)) return;
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
- return (path: `/${string}`) => href(path as ValidPaths, mount);
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
- // Use optimistic update for immediate feedback during navigation
113
- if (state.state !== "idle") {
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
- onComplete();
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
- onComplete();
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
- onComplete();
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 due to quota exceeded, progressively evict the oldest
195
- * entries and retry until it succeeds or the store is empty.
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,