@rangojs/router 0.0.0-experimental.fb4fdc18 → 0.0.0-experimental.fce7fbd1

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 (214) hide show
  1. package/README.md +9 -9
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +48 -0
  4. package/dist/vite/index.js +914 -485
  5. package/package.json +55 -11
  6. package/skills/bundle-analysis/SKILL.md +159 -0
  7. package/skills/cache-guide/SKILL.md +220 -30
  8. package/skills/caching/SKILL.md +116 -8
  9. package/skills/composability/SKILL.md +27 -2
  10. package/skills/document-cache/SKILL.md +78 -55
  11. package/skills/handler-use/SKILL.md +3 -1
  12. package/skills/hooks/SKILL.md +214 -18
  13. package/skills/host-router/SKILL.md +45 -20
  14. package/skills/intercept/SKILL.md +26 -4
  15. package/skills/layout/SKILL.md +6 -7
  16. package/skills/links/SKILL.md +173 -17
  17. package/skills/loader/SKILL.md +149 -6
  18. package/skills/middleware/SKILL.md +13 -9
  19. package/skills/migrate-nextjs/SKILL.md +1 -1
  20. package/skills/mime-routes/SKILL.md +27 -0
  21. package/skills/observability/SKILL.md +137 -0
  22. package/skills/parallel/SKILL.md +5 -6
  23. package/skills/prerender/SKILL.md +14 -33
  24. package/skills/rango/SKILL.md +242 -26
  25. package/skills/react-compiler/SKILL.md +168 -0
  26. package/skills/response-routes/SKILL.md +58 -9
  27. package/skills/route/SKILL.md +13 -4
  28. package/skills/router-setup/SKILL.md +3 -3
  29. package/skills/server-actions/SKILL.md +53 -41
  30. package/skills/testing/SKILL.md +599 -0
  31. package/skills/typesafety/SKILL.md +310 -26
  32. package/skills/use-cache/SKILL.md +34 -5
  33. package/skills/view-transitions/SKILL.md +294 -0
  34. package/src/__augment-tests__/augment.ts +81 -0
  35. package/src/__augment-tests__/augmented.check.ts +117 -0
  36. package/src/browser/action-coordinator.ts +53 -36
  37. package/src/browser/event-controller.ts +42 -66
  38. package/src/browser/history-state.ts +21 -0
  39. package/src/browser/index.ts +3 -3
  40. package/src/browser/navigation-bridge.ts +6 -6
  41. package/src/browser/navigation-client.ts +12 -15
  42. package/src/browser/navigation-store.ts +7 -8
  43. package/src/browser/navigation-transaction.ts +10 -28
  44. package/src/browser/partial-update.ts +9 -19
  45. package/src/browser/react/NavigationProvider.tsx +29 -40
  46. package/src/browser/react/index.ts +3 -0
  47. package/src/browser/react/location-state-shared.ts +175 -4
  48. package/src/browser/react/location-state.ts +39 -13
  49. package/src/browser/react/use-handle.ts +17 -9
  50. package/src/browser/react/use-params.ts +3 -4
  51. package/src/browser/react/use-reverse.ts +106 -0
  52. package/src/browser/react/use-router.ts +14 -1
  53. package/src/browser/response-adapter.ts +25 -0
  54. package/src/browser/rsc-router.tsx +30 -16
  55. package/src/browser/scroll-restoration.ts +22 -14
  56. package/src/browser/segment-structure-assert.ts +2 -2
  57. package/src/browser/server-action-bridge.ts +23 -30
  58. package/src/browser/types.ts +2 -0
  59. package/src/build/collect-fallback-refs.ts +107 -0
  60. package/src/build/generate-manifest.ts +60 -35
  61. package/src/build/generate-route-types.ts +2 -0
  62. package/src/build/index.ts +2 -0
  63. package/src/build/route-types/codegen.ts +4 -4
  64. package/src/build/route-types/include-resolution.ts +1 -1
  65. package/src/build/route-types/per-module-writer.ts +7 -4
  66. package/src/build/route-types/router-processing.ts +55 -14
  67. package/src/build/route-types/scan-filter.ts +1 -1
  68. package/src/build/route-types/source-scan.ts +118 -0
  69. package/src/build/runtime-discovery.ts +9 -20
  70. package/src/cache/cache-scope.ts +28 -42
  71. package/src/cache/cf/cf-cache-store.ts +49 -6
  72. package/src/client.rsc.tsx +3 -0
  73. package/src/client.tsx +10 -8
  74. package/src/context-var.ts +5 -5
  75. package/src/decode-loader-results.ts +36 -0
  76. package/src/errors.ts +30 -1
  77. package/src/handle.ts +26 -13
  78. package/src/host/index.ts +2 -2
  79. package/src/host/router.ts +129 -57
  80. package/src/host/types.ts +31 -2
  81. package/src/host/utils.ts +1 -1
  82. package/src/href-client.ts +140 -20
  83. package/src/index.rsc.ts +6 -4
  84. package/src/index.ts +13 -6
  85. package/src/loader-store.ts +500 -0
  86. package/src/loader.rsc.ts +2 -5
  87. package/src/loader.ts +3 -10
  88. package/src/missing-id-error.ts +68 -0
  89. package/src/prerender.ts +4 -4
  90. package/src/response-utils.ts +9 -0
  91. package/src/reverse.ts +65 -41
  92. package/src/route-content-wrapper.tsx +6 -28
  93. package/src/route-definition/dsl-helpers.ts +238 -263
  94. package/src/route-definition/helper-factories.ts +29 -139
  95. package/src/route-definition/helpers-types.ts +37 -14
  96. package/src/route-definition/use-item-types.ts +32 -0
  97. package/src/route-types.ts +19 -41
  98. package/src/router/basename.ts +14 -0
  99. package/src/router/content-negotiation.ts +15 -2
  100. package/src/router/error-handling.ts +1 -1
  101. package/src/router/handler-context.ts +4 -42
  102. package/src/router/intercept-resolution.ts +4 -18
  103. package/src/router/lazy-includes.ts +2 -2
  104. package/src/router/loader-resolution.ts +16 -2
  105. package/src/router/match-handlers.ts +62 -20
  106. package/src/router/match-middleware/cache-lookup.ts +44 -91
  107. package/src/router/match-middleware/cache-store.ts +3 -2
  108. package/src/router/match-result.ts +32 -30
  109. package/src/router/metrics.ts +1 -1
  110. package/src/router/middleware-types.ts +1 -1
  111. package/src/router/middleware.ts +46 -78
  112. package/src/router/prerender-match.ts +1 -1
  113. package/src/router/preview-match.ts +3 -1
  114. package/src/router/request-classification.ts +4 -28
  115. package/src/router/revalidation.ts +43 -1
  116. package/src/router/router-interfaces.ts +45 -28
  117. package/src/router/router-options.ts +40 -1
  118. package/src/router/router-registry.ts +2 -5
  119. package/src/router/segment-resolution/fresh.ts +19 -6
  120. package/src/router/segment-resolution/revalidation.ts +19 -6
  121. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  122. package/src/router/substitute-pattern-params.ts +56 -0
  123. package/src/router/telemetry.ts +99 -0
  124. package/src/router/types.ts +8 -0
  125. package/src/router.ts +37 -21
  126. package/src/rsc/handler-context.ts +2 -2
  127. package/src/rsc/handler.ts +20 -65
  128. package/src/rsc/helpers.ts +22 -2
  129. package/src/rsc/index.ts +1 -1
  130. package/src/rsc/origin-guard.ts +28 -10
  131. package/src/rsc/response-route-handler.ts +32 -52
  132. package/src/rsc/rsc-rendering.ts +27 -53
  133. package/src/rsc/runtime-warnings.ts +9 -10
  134. package/src/rsc/server-action.ts +13 -37
  135. package/src/rsc/ssr-setup.ts +16 -0
  136. package/src/rsc/types.ts +2 -2
  137. package/src/search-params.ts +4 -4
  138. package/src/segment-system.tsx +121 -65
  139. package/src/serialize.ts +243 -0
  140. package/src/server/context.ts +118 -51
  141. package/src/server/cookie-store.ts +28 -4
  142. package/src/server/request-context.ts +10 -0
  143. package/src/static-handler.ts +1 -1
  144. package/src/testing/cache-status.ts +166 -0
  145. package/src/testing/collect-handle.ts +63 -0
  146. package/src/testing/dispatch.ts +440 -0
  147. package/src/testing/dom.entry.ts +22 -0
  148. package/src/testing/e2e/fixture.ts +154 -0
  149. package/src/testing/e2e/index.ts +149 -0
  150. package/src/testing/e2e/matchers.ts +51 -0
  151. package/src/testing/e2e/page-helpers.ts +272 -0
  152. package/src/testing/e2e/parity.ts +306 -0
  153. package/src/testing/e2e/server.ts +183 -0
  154. package/src/testing/flight-matchers.ts +104 -0
  155. package/src/testing/flight-runtime.d.ts +21 -0
  156. package/src/testing/flight.entry.ts +22 -0
  157. package/src/testing/flight.ts +182 -0
  158. package/src/testing/generated-routes.ts +223 -0
  159. package/src/testing/index.ts +105 -0
  160. package/src/testing/internal/context.ts +193 -0
  161. package/src/testing/render-route.tsx +536 -0
  162. package/src/testing/run-loader.ts +296 -0
  163. package/src/testing/run-middleware.ts +170 -0
  164. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  165. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  166. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  167. package/src/testing/vitest-stubs/version.ts +5 -0
  168. package/src/testing/vitest.ts +183 -0
  169. package/src/types/global-namespace.ts +39 -26
  170. package/src/types/handler-context.ts +56 -11
  171. package/src/types/index.ts +1 -0
  172. package/src/types/segments.ts +18 -1
  173. package/src/urls/include-helper.ts +10 -53
  174. package/src/urls/index.ts +0 -3
  175. package/src/urls/path-helper-types.ts +11 -3
  176. package/src/urls/path-helper.ts +17 -52
  177. package/src/urls/pattern-types.ts +36 -19
  178. package/src/urls/response-types.ts +20 -19
  179. package/src/urls/type-extraction.ts +26 -116
  180. package/src/urls/urls-function.ts +1 -5
  181. package/src/use-loader.tsx +413 -42
  182. package/src/vite/debug.ts +1 -0
  183. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  184. package/src/vite/discovery/discover-routers.ts +70 -48
  185. package/src/vite/discovery/discovery-errors.ts +194 -0
  186. package/src/vite/discovery/prerender-collection.ts +19 -25
  187. package/src/vite/discovery/route-types-writer.ts +40 -84
  188. package/src/vite/discovery/state.ts +33 -0
  189. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  190. package/src/vite/index.ts +2 -0
  191. package/src/vite/plugin-types.ts +67 -0
  192. package/src/vite/plugins/cjs-to-esm.ts +3 -7
  193. package/src/vite/plugins/client-ref-hashing.ts +12 -1
  194. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -1
  195. package/src/vite/plugins/expose-action-id.ts +2 -2
  196. package/src/vite/plugins/expose-id-utils.ts +12 -8
  197. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  198. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  199. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  200. package/src/vite/plugins/expose-internal-ids.ts +47 -67
  201. package/src/vite/plugins/performance-tracks.ts +12 -16
  202. package/src/vite/plugins/use-cache-transform.ts +13 -11
  203. package/src/vite/plugins/version-injector.ts +2 -12
  204. package/src/vite/plugins/version-plugin.ts +59 -2
  205. package/src/vite/plugins/virtual-entries.ts +2 -2
  206. package/src/vite/rango.ts +67 -15
  207. package/src/vite/router-discovery.ts +208 -63
  208. package/src/vite/utils/ast-handler-extract.ts +15 -15
  209. package/src/vite/utils/bundle-analysis.ts +4 -2
  210. package/src/vite/utils/client-chunks.ts +190 -0
  211. package/src/vite/utils/forward-user-plugins.ts +193 -0
  212. package/src/vite/utils/manifest-utils.ts +21 -5
  213. package/src/vite/utils/shared-utils.ts +107 -26
  214. package/src/browser/action-response-classifier.ts +0 -99
@@ -268,6 +268,20 @@ function matchesActionId(
268
268
  return entryActionId.endsWith(`#${subscriptionId}`);
269
269
  }
270
270
 
271
+ // Coalesce rapid notifications into one microtask-deferred fan-out; the
272
+ // setTimeout(0) batching prevents render storms. Each notifier owns its timer
273
+ // so listener kinds coalesce independently.
274
+ function makeDebouncedNotifier(listeners: Set<() => void>): () => void {
275
+ let timeout: ReturnType<typeof setTimeout> | null = null;
276
+ return () => {
277
+ if (timeout !== null) clearTimeout(timeout);
278
+ timeout = setTimeout(() => {
279
+ timeout = null;
280
+ listeners.forEach((listener) => listener());
281
+ }, 0);
282
+ };
283
+ }
284
+
271
285
  // ============================================================================
272
286
  // Implementation
273
287
  // ============================================================================
@@ -334,18 +348,7 @@ export function createEventController(
334
348
  const actionListeners = new Map<string, Set<ActionStateListener>>();
335
349
  const handleListeners = new Set<HandleListener>();
336
350
 
337
- // Debounce state notifications to batch rapid updates
338
- let notifyTimeout: ReturnType<typeof setTimeout> | null = null;
339
-
340
- function notify() {
341
- if (notifyTimeout !== null) {
342
- clearTimeout(notifyTimeout);
343
- }
344
- notifyTimeout = setTimeout(() => {
345
- notifyTimeout = null;
346
- stateListeners.forEach((listener) => listener());
347
- }, 0);
348
- }
351
+ const notify = makeDebouncedNotifier(stateListeners);
349
352
 
350
353
  // Debounce per-action notifications
351
354
  const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
@@ -371,18 +374,7 @@ export function createEventController(
371
374
  );
372
375
  }
373
376
 
374
- // Debounce handle notifications
375
- let handleNotifyTimeout: ReturnType<typeof setTimeout> | null = null;
376
-
377
- function notifyHandles() {
378
- if (handleNotifyTimeout !== null) {
379
- clearTimeout(handleNotifyTimeout);
380
- }
381
- handleNotifyTimeout = setTimeout(() => {
382
- handleNotifyTimeout = null;
383
- handleListeners.forEach((listener) => listener());
384
- }, 0);
385
- }
377
+ const notifyHandles = makeDebouncedNotifier(handleListeners);
386
378
 
387
379
  // ========================================================================
388
380
  // Derived State
@@ -429,22 +421,17 @@ export function createEventController(
429
421
  }
430
422
 
431
423
  function getActionState(actionId: string): TrackedActionState {
432
- // Find the most recent action with this ID that's not settling
433
- // Uses suffix matching when actionId is just a name (no #)
434
- const activeEntry = [...inflightActions.values()]
435
- .filter(
436
- (a) => matchesActionId(actionId, a.actionId) && a.phase !== "settling",
437
- )
438
- .sort((a, b) => b.startedAt - a.startedAt)[0];
439
-
440
- // Also check for settling entries to get result/error
441
- const settlingEntry = [...inflightActions.values()]
442
- .filter(
443
- (a) => matchesActionId(actionId, a.actionId) && a.phase === "settling",
444
- )
445
- .sort((a, b) => b.startedAt - a.startedAt)[0];
446
-
447
- const entry = activeEntry || settlingEntry;
424
+ // Prefer the most-recent non-settling entry; fall back to most-recent
425
+ // settling so a just-settled action's result/error stays readable.
426
+ const entry = [...inflightActions.values()]
427
+ .filter((a) => matchesActionId(actionId, a.actionId))
428
+ .reduce<ActionEntry | undefined>((best, a) => {
429
+ if (!best) return a;
430
+ const aActive = a.phase !== "settling";
431
+ const bActive = best.phase !== "settling";
432
+ if (aActive !== bActive) return aActive ? a : best;
433
+ return a.startedAt > best.startedAt ? a : best;
434
+ }, undefined);
448
435
 
449
436
  if (!entry) {
450
437
  return { ...DEFAULT_ACTION_STATE };
@@ -632,6 +619,19 @@ export function createEventController(
632
619
  doSettle();
633
620
  }
634
621
 
622
+ // streamingEnded is forced here for the "streaming never started" case so
623
+ // tryFinalize can run; otherwise the streaming token's end() finalizes.
624
+ function settleWith(result: NonNullable<typeof pendingResult>) {
625
+ if (!inflightActions.has(id) || settled) return;
626
+ actionCompleted = true;
627
+ entry.completed = true;
628
+ pendingResult = result;
629
+ if (entry.phase === "fetching" || streamingEnded) {
630
+ streamingEnded = true;
631
+ tryFinalize();
632
+ }
633
+ }
634
+
635
635
  return {
636
636
  id,
637
637
  abort,
@@ -668,35 +668,11 @@ export function createEventController(
668
668
  },
669
669
 
670
670
  complete(result?: unknown) {
671
- if (!inflightActions.has(id) || settled) return;
672
-
673
- actionCompleted = true;
674
- entry.completed = true;
675
- pendingResult = { type: "success", value: result };
676
-
677
- // If streaming never started or already ended, finalize immediately
678
- // Otherwise wait for streaming to end
679
- if (entry.phase === "fetching" || streamingEnded) {
680
- streamingEnded = true; // Mark as ended if never started
681
- tryFinalize();
682
- }
683
- // If streaming is in progress, tryFinalize() will be called when streaming ends
671
+ settleWith({ type: "success", value: result });
684
672
  },
685
673
 
686
674
  fail(error: unknown) {
687
- if (!inflightActions.has(id) || settled) return;
688
-
689
- actionCompleted = true;
690
- entry.completed = true;
691
- pendingResult = { type: "error", value: error };
692
-
693
- // If streaming never started or already ended, finalize immediately
694
- // Otherwise wait for streaming to end
695
- if (entry.phase === "fetching" || streamingEnded) {
696
- streamingEnded = true; // Mark as ended if never started
697
- tryFinalize();
698
- }
699
- // If streaming is in progress, tryFinalize() will be called when streaming ends
675
+ settleWith({ type: "error", value: error });
700
676
  },
701
677
 
702
678
  getRevalidatedSegments(): Set<string> {
@@ -61,6 +61,27 @@ export function buildHistoryState(
61
61
  return Object.keys(result).length > 0 ? result : null;
62
62
  }
63
63
 
64
+ /**
65
+ * Stamp an `idx` on the next history entry's state and call push/replaceState.
66
+ * Push increments the current idx; replace keeps it. Initial entry idx is 0.
67
+ * Used by useRouter().back() to detect "first entry in this session" without
68
+ * relying on the Navigation API.
69
+ */
70
+ export function pushHistoryWithIdx(
71
+ state: Record<string, unknown> | null,
72
+ url: string,
73
+ replace: boolean,
74
+ ): void {
75
+ const oldIdx = (window.history.state as { idx?: number } | null)?.idx ?? 0;
76
+ const newIdx = replace ? oldIdx : oldIdx + 1;
77
+ const finalState = { ...(state ?? {}), idx: newIdx };
78
+ if (replace) {
79
+ window.history.replaceState(finalState, "", url);
80
+ } else {
81
+ window.history.pushState(finalState, "", url);
82
+ }
83
+ }
84
+
64
85
  /**
65
86
  * Merge server-set location state into the current history entry.
66
87
  * Replaces the current history state and dispatches notification event
@@ -1,9 +1,9 @@
1
1
  // ============================================================================
2
- // Browser Module - Browser entry point for RSC Router
2
+ // Browser Module - Browser entry point for Rango
3
3
  // ============================================================================
4
4
  //
5
5
  // Usage:
6
- // import { initBrowserApp, RSCRouter } from "rsc-router/browser";
6
+ // import { initBrowserApp, Rango } from "rsc-router/browser";
7
7
  //
8
8
  // For React components (Link, useNavigation, etc.):
9
9
  // import { Link, useNavigation, useAction, href } from "rsc-router/client";
@@ -13,6 +13,6 @@
13
13
  // Browser app initialization
14
14
  export {
15
15
  initBrowserApp,
16
- RSCRouter,
16
+ Rango,
17
17
  type InitBrowserAppOptions,
18
18
  } from "./rsc-router.js";
@@ -13,7 +13,7 @@ import {
13
13
  createNavigationTransaction,
14
14
  resolveNavigationState,
15
15
  } from "./navigation-transaction.js";
16
- import { buildHistoryState } from "./history-state.js";
16
+ import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
17
17
  import {
18
18
  handleNavigationStart,
19
19
  handleNavigationEnd,
@@ -204,11 +204,7 @@ export function createNavigationBridge(
204
204
  },
205
205
  {},
206
206
  );
207
- if (options.replace) {
208
- window.history.replaceState(historyState, "", url);
209
- } else {
210
- window.history.pushState(historyState, "", url);
211
- }
207
+ pushHistoryWithIdx(historyState, url, options?.replace ?? false);
212
208
 
213
209
  // Ensure new history entry has a scroll restoration key
214
210
  ensureHistoryKey();
@@ -711,6 +707,10 @@ export function createNavigationBridge(
711
707
  };
712
708
  },
713
709
 
710
+ getVersion(): string | undefined {
711
+ return version;
712
+ },
713
+
714
714
  updateVersion(newVersion: string): void {
715
715
  version = newVersion;
716
716
  setAppVersion(newVersion);
@@ -15,6 +15,7 @@ import { getRangoState } from "./rango-state.js";
15
15
  import {
16
16
  extractRscHeaderUrl,
17
17
  emptyResponse,
18
+ handleReloadHeader,
18
19
  teeWithCompletion,
19
20
  } from "./response-adapter.js";
20
21
  import {
@@ -148,21 +149,17 @@ export function createNavigationClient(
148
149
  source: string,
149
150
  ): Response | Promise<Response> => {
150
151
  // Version mismatch — server wants a full page reload
151
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
152
- if (reload === "blocked") {
153
- resolveStreamComplete();
154
- return emptyResponse();
155
- }
156
- if (reload) {
157
- if (tx) {
158
- browserDebugLog(tx, `version mismatch, reloading (${source})`, {
159
- reloadUrl: reload.url,
160
- });
161
- }
162
- window.location.href = reload.url;
163
- // Block further processing — page is reloading
164
- return new Promise<Response>(() => {});
165
- }
152
+ const reloadResult = handleReloadHeader(response, {
153
+ onBlocked: resolveStreamComplete,
154
+ onReload: (url) => {
155
+ if (tx) {
156
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
157
+ reloadUrl: url,
158
+ });
159
+ }
160
+ },
161
+ });
162
+ if (reloadResult) return reloadResult;
166
163
 
167
164
  // Server-side redirect without state: the server returned 204 with
168
165
  // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
@@ -283,18 +283,17 @@ export function createNavigationStore(
283
283
  /**
284
284
  * Create a debounced function that batches rapid calls
285
285
  */
286
+ // A non-keyed notifier is the keyed one restricted to a single constant key;
287
+ // its own keyed instance means the "" key never collides with action keys.
286
288
  function createDebouncedNotifier<T extends (...args: any[]) => void>(
287
289
  fn: T,
288
290
  ms: number = 20,
289
291
  ): T {
290
- let timeout: ReturnType<typeof setTimeout> | null = null;
291
- return ((...args: Parameters<T>) => {
292
- if (timeout !== null) clearTimeout(timeout);
293
- timeout = setTimeout(() => {
294
- timeout = null;
295
- fn(...args);
296
- }, ms);
297
- }) as T;
292
+ const keyed = createKeyedDebouncedNotifier(
293
+ (_key: string, ...args: any[]) => fn(...args),
294
+ ms,
295
+ );
296
+ return ((...args: Parameters<T>) => keyed("", ...args)) as T;
298
297
  }
299
298
 
300
299
  /**
@@ -11,7 +11,7 @@ import {
11
11
  } from "./scroll-restoration.js";
12
12
  import type { EventController, NavigationHandle } from "./event-controller.js";
13
13
  import { debugLog } from "./logging.js";
14
- import { buildHistoryState } from "./history-state.js";
14
+ import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
15
15
 
16
16
  // Re-export for consumers that import from navigation-transaction
17
17
  export { resolveNavigationState } from "./history-state.js";
@@ -186,12 +186,8 @@ export function createNavigationTransaction(
186
186
  // Used to detect when location state is being cleared.
187
187
  const oldState = window.history.state;
188
188
 
189
- // Update browser URL
190
- if (replace) {
191
- window.history.replaceState(historyState, "", url);
192
- } else {
193
- window.history.pushState(historyState, "", url);
194
- }
189
+ // Update browser URL (stamps history.state.idx for back() first-entry detection)
190
+ pushHistoryWithIdx(historyState, url, replace ?? false);
195
191
  // Ensure new history entry has a scroll restoration key
196
192
  ensureHistoryKey();
197
193
 
@@ -240,30 +236,16 @@ export function createNavigationTransaction(
240
236
  segments: ResolvedSegment[],
241
237
  overrides?: BoundCommitOverrides,
242
238
  ) => {
243
- // Allow overrides to disable scroll (e.g., for intercepts)
244
- const finalScroll =
245
- overrides?.scroll !== undefined ? overrides.scroll : opts.scroll;
246
- // Allow overrides to force replace (e.g., for intercepts)
247
- const finalReplace =
248
- overrides?.replace !== undefined ? overrides.replace : opts.replace;
249
- // Intercept info: overrides take precedence, fallback to opts
250
- const intercept =
251
- overrides?.intercept !== undefined
252
- ? overrides.intercept
253
- : opts.intercept;
239
+ const finalScroll = overrides?.scroll ?? opts.scroll;
240
+ const finalReplace = overrides?.replace ?? opts.replace;
241
+ const intercept = overrides?.intercept ?? opts.intercept;
254
242
  const interceptSourceUrl =
255
- overrides?.interceptSourceUrl !== undefined
256
- ? overrides.interceptSourceUrl
257
- : opts.interceptSourceUrl;
258
- // Cache-only mode: overrides take precedence, fallback to opts
259
- const cacheOnly =
260
- overrides?.cacheOnly !== undefined
261
- ? overrides.cacheOnly
262
- : opts.cacheOnly;
263
- // User state: overrides take precedence, fallback to opts
243
+ overrides?.interceptSourceUrl ?? opts.interceptSourceUrl;
244
+ const cacheOnly = overrides?.cacheOnly ?? opts.cacheOnly;
245
+ // state is `unknown` (null is meaningful) so `??` would wrongly drop a
246
+ // null override; serverState always comes from overrides, never opts.
264
247
  const state =
265
248
  overrides?.state !== undefined ? overrides.state : opts.state;
266
- // Server-set location state: only from overrides (set by partial-update)
267
249
  const serverState = overrides?.serverState;
268
250
  return commit({
269
251
  ...opts,
@@ -103,7 +103,7 @@ export type UpdateMode =
103
103
  /** Source URL for intercept restore (popstate cache miss) */
104
104
  interceptSourceUrl?: string;
105
105
  }
106
- | { type: "leave-intercept" }
106
+ | { type: "leave-intercept"; interceptSourceUrl?: string }
107
107
  | { type: "stale-revalidation"; interceptSourceUrl?: string }
108
108
  | { type: "action"; interceptSourceUrl?: string };
109
109
 
@@ -169,13 +169,7 @@ export function createPartialUpdater(
169
169
  // Capture history key at start for stale revalidation consistency check
170
170
  const historyKeyAtStart = store.getHistoryKey();
171
171
 
172
- // Derive interceptSourceUrl from modes that carry it
173
- const interceptSourceUrl =
174
- mode.type === "stale-revalidation" ||
175
- mode.type === "action" ||
176
- mode.type === "navigate"
177
- ? mode.interceptSourceUrl
178
- : undefined;
172
+ const interceptSourceUrl = mode.interceptSourceUrl;
179
173
 
180
174
  // When leaving intercept, filter out intercept-specific segments
181
175
  let segments: string[];
@@ -218,13 +212,11 @@ export function createPartialUpdater(
218
212
  // When navigating with targetCacheSegments, use those for consistency.
219
213
  // Otherwise fall back to current page's segments (for same-route revalidation).
220
214
  const targetCache =
221
- mode.type === "navigate" ? mode.targetCacheSegments : undefined;
222
- const cachedSegs =
223
- targetCache && targetCache.length > 0
224
- ? targetCache
225
- : getCurrentCachedSegments();
226
- const cachedSegsSource =
227
- targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
215
+ mode.type === "navigate" && mode.targetCacheSegments?.length
216
+ ? mode.targetCacheSegments
217
+ : undefined;
218
+ const cachedSegs = targetCache ?? getCurrentCachedSegments();
219
+ const cachedSegsSource = targetCache ? "history-cache" : "current-page";
228
220
  debugLog(
229
221
  `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
230
222
  );
@@ -318,7 +310,7 @@ export function createPartialUpdater(
318
310
  .filter(Boolean) as ResolvedSegment[];
319
311
 
320
312
  // When navigating with cached segments to a different route, render them.
321
- if (mode.type === "navigate" && targetCache && targetCache.length > 0) {
313
+ if (mode.type === "navigate" && targetCache) {
322
314
  debugLog(
323
315
  "[Browser] No diff but navigating with cached segments - rendering target route",
324
316
  );
@@ -606,9 +598,7 @@ export function createPartialUpdater(
606
598
  })
607
599
  : tx.commit(segmentIds, segments);
608
600
 
609
- const fullHasTransition = segments.some(
610
- (s: ResolvedSegment) => s.transition,
611
- );
601
+ const fullHasTransition = shouldStartViewTransition(segments);
612
602
  const fullScrollPayload = toScrollPayload(fullScroll);
613
603
 
614
604
  if (mode.type === "stale-revalidation") {
@@ -28,7 +28,7 @@ import { NonceContext } from "./nonce-context.js";
28
28
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
29
29
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
30
  import { handleNavigationEnd } from "../scroll-restoration.js";
31
- import type { AppShellRef } from "../app-shell.js";
31
+ import { createAppShellRef, type AppShellRef } from "../app-shell.js";
32
32
 
33
33
  /**
34
34
  * Process handles from an async generator, updating the event controller
@@ -217,38 +217,33 @@ export function NavigationProvider({
217
217
  await bridge.refresh();
218
218
  }, []);
219
219
 
220
- // Context value is stable (store, eventController, navigate, refresh never
221
- // change). When an appShellRef is supplied, `basename` and `version` are
222
- // installed as live getters so app-switch transitions (which update the ref)
223
- // propagate to consumers without forcing a tree-wide rerender.
220
+ // basename/version are always read through a shell ref so the context value
221
+ // has a single shape: a supplied appShellRef stays live (app-switch updates
222
+ // it), the standalone fallback is a frozen ref over the mount-time props.
223
+ const fallbackShellRef = useRef<AppShellRef | null>(null);
224
+ if (!fallbackShellRef.current) {
225
+ fallbackShellRef.current = createAppShellRef({ basename, version });
226
+ }
227
+ const shellRef = appShellRef ?? fallbackShellRef.current;
228
+
224
229
  const contextValue = useMemo<NavigationStoreContextValue>(() => {
225
- if (appShellRef) {
226
- const value = {
227
- store,
228
- eventController,
229
- navigate,
230
- refresh,
231
- } as NavigationStoreContextValue;
232
- Object.defineProperty(value, "basename", {
233
- configurable: true,
234
- enumerable: true,
235
- get: () => appShellRef.get().basename,
236
- });
237
- Object.defineProperty(value, "version", {
238
- configurable: true,
239
- enumerable: true,
240
- get: () => appShellRef.get().version,
241
- });
242
- return value;
243
- }
244
- return {
230
+ const value = {
245
231
  store,
246
232
  eventController,
247
233
  navigate,
248
234
  refresh,
249
- version,
250
- basename,
251
- };
235
+ } as NavigationStoreContextValue;
236
+ Object.defineProperty(value, "basename", {
237
+ configurable: true,
238
+ enumerable: true,
239
+ get: () => shellRef.get().basename,
240
+ });
241
+ Object.defineProperty(value, "version", {
242
+ configurable: true,
243
+ enumerable: true,
244
+ get: () => shellRef.get().version,
245
+ });
246
+ return value;
252
247
  }, []);
253
248
 
254
249
  // Connection warmup: keep TLS alive after idle periods.
@@ -410,21 +405,15 @@ export function NavigationProvider({
410
405
  }).catch((err) =>
411
406
  console.error("[NavigationProvider] Error consuming handles:", err),
412
407
  );
413
- } else if (update.metadata.cachedHandleData) {
414
- // For back/forward navigation from cache, restore the cached handleData
415
- // This restores breadcrumbs to the exact state they were when the page was cached
416
- eventController.setHandleData(
417
- update.metadata.cachedHandleData,
418
- update.metadata.matched,
419
- false, // full replace - restore entire cached state
420
- );
421
408
  } else if (update.metadata.matched) {
422
- // For cached navigations without handleData, update segmentOrder to clean up stale data
409
+ // cachedHandleData present -> full restore (back/forward); absent ->
410
+ // partial cleanup of segments no longer matched.
411
+ const cached = update.metadata.cachedHandleData;
423
412
  eventController.setHandleData(
424
- {}, // Empty data - all existing data not in matched will be cleaned up
413
+ cached ?? {},
425
414
  update.metadata.matched,
426
- true, // partial update - will clean up segments not in matched
427
- update.metadata.resolvedIds,
415
+ cached === undefined,
416
+ cached === undefined ? update.metadata.resolvedIds : undefined,
428
417
  );
429
418
  }
430
419
  });
@@ -20,6 +20,9 @@ export { useSegments, type SegmentsState } from "./use-segments.js";
20
20
  // Handle data hook
21
21
  export { useHandle } from "./use-handle.js";
22
22
 
23
+ // Mount-aware reverse hook
24
+ export { useReverse } from "./use-reverse.js";
25
+
23
26
  // Client cache controls hook
24
27
  export {
25
28
  useClientCache,