@rangojs/router 0.28.0 → 0.30.0

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 (84) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/browser/action-fence.d.ts +2 -1
  3. package/dist/types/browser/event-controller.d.ts +6 -1
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/use-action.d.ts +10 -4
  6. package/dist/types/browser/react/use-handle.d.ts +20 -16
  7. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  8. package/dist/types/browser/react/use-route-state.d.ts +45 -0
  9. package/dist/types/browser/segment-reconciler.d.ts +26 -0
  10. package/dist/types/browser/types.d.ts +30 -8
  11. package/dist/types/browser/wire-protocol.d.ts +1 -0
  12. package/dist/types/cache/tag-invalidation.d.ts +0 -10
  13. package/dist/types/router/error-handling.d.ts +7 -1
  14. package/dist/types/router/match-context.d.ts +2 -0
  15. package/dist/types/router/match-middleware/background-revalidation.d.ts +3 -2
  16. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  17. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  18. package/dist/types/router/segment-resolution/resolution-pass.d.ts +2 -0
  19. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  20. package/dist/types/router/types.d.ts +1 -1
  21. package/dist/types/server/request-context.d.ts +30 -1
  22. package/dist/types/testing/render-route.d.ts +43 -8
  23. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  24. package/dist/types/types/error-types.d.ts +27 -8
  25. package/dist/types/wire-params.d.ts +5 -0
  26. package/dist/vite/index.js +62 -67
  27. package/package.json +1 -1
  28. package/skills/client-urls/SKILL.md +6 -8
  29. package/skills/hooks/SKILL.md +1 -1
  30. package/skills/hooks/data.md +8 -1
  31. package/skills/hooks/handle-and-actions.md +40 -2
  32. package/skills/hooks/navigation.md +121 -15
  33. package/skills/hooks/state.md +6 -4
  34. package/skills/hooks/urls.md +17 -5
  35. package/skills/intercept/SKILL.md +6 -0
  36. package/skills/observability/SKILL.md +30 -0
  37. package/skills/server-actions/SKILL.md +8 -1
  38. package/skills/testing/client-components.md +25 -18
  39. package/skills/testing/serve-shell-request.md +7 -7
  40. package/skills/view-transitions/SKILL.md +1 -1
  41. package/src/browser/action-fence.ts +2 -1
  42. package/src/browser/event-controller.ts +78 -19
  43. package/src/browser/navigation-bridge.ts +45 -13
  44. package/src/browser/navigation-client.ts +2 -0
  45. package/src/browser/network-error-handler.ts +4 -0
  46. package/src/browser/partial-update.ts +33 -6
  47. package/src/browser/react/use-action.ts +27 -50
  48. package/src/browser/react/use-handle.ts +27 -18
  49. package/src/browser/react/use-link-status.ts +48 -60
  50. package/src/browser/react/use-navigation.ts +76 -51
  51. package/src/browser/react/use-params.ts +6 -35
  52. package/src/browser/react/use-pathname.ts +3 -32
  53. package/src/browser/react/use-route-state.ts +141 -0
  54. package/src/browser/react/use-search-params.ts +7 -40
  55. package/src/browser/segment-reconciler.ts +43 -0
  56. package/src/browser/server-action-bridge.ts +35 -8
  57. package/src/browser/types.ts +31 -8
  58. package/src/browser/wire-protocol.ts +2 -0
  59. package/src/cache/cache-runtime.ts +28 -31
  60. package/src/cache/document-cache.ts +34 -37
  61. package/src/cache/read-through-swr.ts +20 -18
  62. package/src/cache/tag-invalidation.ts +3 -18
  63. package/src/router/error-handling.ts +18 -6
  64. package/src/router/intercept-resolution.ts +9 -4
  65. package/src/router/match-api.ts +1 -0
  66. package/src/router/match-context.ts +3 -0
  67. package/src/router/match-middleware/background-revalidation.ts +5 -4
  68. package/src/router/match-middleware/cache-lookup.ts +5 -3
  69. package/src/router/navigation-snapshot.ts +10 -0
  70. package/src/router/segment-resolution/helpers.ts +49 -13
  71. package/src/router/segment-resolution/loader-cache.ts +4 -3
  72. package/src/router/segment-resolution/resolution-pass.ts +2 -0
  73. package/src/router/segment-resolution/revalidation.ts +57 -28
  74. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  75. package/src/router/types.ts +1 -1
  76. package/src/router.ts +7 -4
  77. package/src/rsc/request-stages.ts +4 -2
  78. package/src/rsc/shell-capture.ts +63 -58
  79. package/src/server/request-context.ts +49 -0
  80. package/src/testing/dispatch.ts +3 -0
  81. package/src/testing/render-route.tsx +204 -16
  82. package/src/testing/serve-shell-request.ts +19 -10
  83. package/src/types/error-types.ts +27 -8
  84. package/src/wire-params.ts +5 -0
@@ -92,6 +92,11 @@ export interface ActionEntry {
92
92
  startedAt: number;
93
93
  /** Whether action processing is complete (may still be streaming) */
94
94
  completed?: boolean;
95
+ /**
96
+ * Set when a later call started (see startAction): "id" hides this call
97
+ * from every reader, "name" from readers that match by export name.
98
+ */
99
+ supersededBy?: "id" | "name";
95
100
  }
96
101
 
97
102
  /**
@@ -104,7 +109,7 @@ export interface DerivedNavigationState {
104
109
  isStreaming: boolean;
105
110
  /** Whether a navigation is active (fetching or streaming, before commit) */
106
111
  isNavigating: boolean;
107
- /** Current committed location */
112
+ /** The location of the last page flushed to React (useNavigation keeps the page on screen) */
108
113
  location: NavigationLocation;
109
114
  /** URL being navigated to (null if idle) */
110
115
  pendingUrl: string | null;
@@ -537,6 +542,28 @@ export function createEventController(
537
542
  let unsettledNavigation: NavigationEntry | null = null;
538
543
 
539
544
  const inflightActions = new Map<string, ActionEntry>();
545
+ // Each action id's last settled call, which useAction reports until the
546
+ // action's next call starts (startAction deletes it). A call moves here
547
+ // when doSettle's cleanup takes it out of inflightActions. A trimmed
548
+ // snapshot, so no abort controller outlives the call. abortAllActions leaves
549
+ // settled calls: they persist through navigations until the action's next
550
+ // call.
551
+ type TrackedCall = Pick<
552
+ ActionEntry,
553
+ | "actionId"
554
+ | "phase"
555
+ | "payload"
556
+ | "result"
557
+ | "error"
558
+ | "revalidationError"
559
+ | "supersededBy"
560
+ >;
561
+ const settledActions = new Map<string, TrackedCall>();
562
+
563
+ function* trackedCalls(): Generator<TrackedCall> {
564
+ yield* inflightActions.values();
565
+ yield* settledActions.values();
566
+ }
540
567
 
541
568
  let location: NavigationLocation =
542
569
  config?.initialLocation ??
@@ -708,19 +735,24 @@ export function createEventController(
708
735
  }
709
736
 
710
737
  function getActionState(actionId: string): TrackedActionState {
711
- // Nothing inflight — skip building/scanning the list and return the shared
712
- // idle snapshot (the same reference use-action falls back to).
713
- if (inflightActions.size === 0) return DEFAULT_ACTION_STATE;
714
-
715
- const entry = [...inflightActions.values()]
716
- .filter((a) => matchesActionId(actionId, a.actionId))
717
- .reduce<ActionEntry | undefined>((best, a) => {
718
- if (!best) return a;
719
- const aActive = a.phase !== "settling";
720
- const bActive = best.phase !== "settling";
721
- if (aActive !== bActive) return aActive ? a : best;
722
- return a.startedAt > best.startedAt ? a : best;
723
- }, undefined);
738
+ // No call tracked: the shared idle snapshot (the same reference use-action
739
+ // falls back to).
740
+ if (inflightActions.size === 0 && settledActions.size === 0) {
741
+ return DEFAULT_ACTION_STATE;
742
+ }
743
+
744
+ // The latest call this reader matches, in flight or settled, or none once
745
+ // a later call was aborted: an earlier call is never reported again.
746
+ const byName = !actionId.includes("#");
747
+ let entry: TrackedCall | undefined;
748
+ for (const a of trackedCalls()) {
749
+ if (
750
+ matchesActionId(actionId, a.actionId) &&
751
+ (byName ? !a.supersededBy : a.supersededBy !== "id")
752
+ ) {
753
+ entry = a;
754
+ }
755
+ }
724
756
 
725
757
  if (!entry) {
726
758
  return { ...DEFAULT_ACTION_STATE };
@@ -899,9 +931,9 @@ export function createEventController(
899
931
  // after that point is strictly ordered behind the prior action's server
900
932
  // execution, and there is no skipped render or order uncertainty for
901
933
  // consolidation to repair. Completed entries still linger in the map for
902
- // the 100ms doSettle window (useAction reads) and, on a slow connection,
903
- // while the Flight stream drains its EOF after complete(). Counting them
904
- // latched hadAnyConcurrentActions for back-to-back sequential actions,
934
+ // the 100ms doSettle window and, on a slow connection, while the Flight
935
+ // stream drains its EOF after complete(). Counting them latched
936
+ // hadAnyConcurrentActions for back-to-back sequential actions,
905
937
  // which made the LAST action classify as consolidation-needed; the
906
938
  // consolidation refetch omits every concurrently-revalidated segment id
907
939
  // from _rsc_segments, so the server re-ran gated loaders as "new-segment"
@@ -913,6 +945,22 @@ export function createEventController(
913
945
  hadAnyConcurrentActions = true;
914
946
  }
915
947
 
948
+ // useAction reports the latest call. Every earlier call that a reader of
949
+ // this one also matches, in flight or settled, is hidden from that reader
950
+ // for good, so it never shows again, also once this call is aborted: a
951
+ // call of the same action from every reader, a call of another action
952
+ // with the same export name from readers that match by name
953
+ // (matchesActionId). So at most one call matches a reader (getActionState).
954
+ const hash = actionId.lastIndexOf("#");
955
+ const name = hash === -1 ? null : actionId.slice(hash + 1);
956
+ settledActions.delete(actionId);
957
+ for (const a of trackedCalls()) {
958
+ if (a.actionId === actionId) a.supersededBy = "id";
959
+ else if (name !== null && matchesActionId(name, a.actionId)) {
960
+ a.supersededBy ??= "name";
961
+ }
962
+ }
963
+
916
964
  const entry: ActionEntry = {
917
965
  id,
918
966
  actionId,
@@ -956,9 +1004,20 @@ export function createEventController(
956
1004
  if (settled) return;
957
1005
  settled = true;
958
1006
 
959
- // Cleanup after brief delay (allow useAction to read result)
960
1007
  setTimeout(() => {
961
- inflightActions.delete(id);
1008
+ // The action's last settled call until its next call starts, unless
1009
+ // that call already started.
1010
+ if (inflightActions.delete(id) && entry.supersededBy !== "id") {
1011
+ settledActions.set(actionId, {
1012
+ actionId: entry.actionId,
1013
+ phase: entry.phase,
1014
+ payload: entry.payload,
1015
+ result: entry.result,
1016
+ error: entry.error,
1017
+ revalidationError: entry.revalidationError,
1018
+ supersededBy: entry.supersededBy,
1019
+ });
1020
+ }
962
1021
  // Free this cohort's arbitration once its last action has settled, so
963
1022
  // a long-running action elsewhere cannot pin keys from a drained entry.
964
1023
  releaseCohort();
@@ -45,6 +45,7 @@ import {
45
45
  } from "./navigation-store.js";
46
46
  import type { EventController } from "./event-controller.js";
47
47
  import { isInterceptOnlyCache } from "./intercept-utils.js";
48
+ import { actionBoundaryAt, idsAboveBoundary } from "./segment-reconciler.js";
48
49
  import {
49
50
  toNetworkError,
50
51
  emitNetworkError,
@@ -206,7 +207,10 @@ export function createNavigationBridge(
206
207
  error: ServerRedirect,
207
208
  url: string,
208
209
  hops: number,
209
- options: { replace?: boolean; transition?: boolean },
210
+ options: Pick<
211
+ NavigateOptionsInternal,
212
+ "replace" | "transition" | "_redirectFrom"
213
+ >,
210
214
  ): Promise<void> {
211
215
  const redirectUrl = validateRedirectOrigin(
212
216
  error.url,
@@ -228,6 +232,7 @@ export function createNavigationBridge(
228
232
  ...options,
229
233
  _skipCache: true,
230
234
  _redirectHops: hops + 1,
235
+ _redirectFrom: options._redirectFrom ?? url,
231
236
  });
232
237
  }
233
238
 
@@ -414,8 +419,11 @@ export function createNavigationBridge(
414
419
  // 3. when leaving intercept - we need fresh non-intercept segments from server
415
420
  // 4. redirect-with-state - force re-render so hooks read fresh state
416
421
  // 5. stale cache - server action invalidated it, need fresh data with loading state
422
+ // 6. an error boundary in the list - a link renders the page again; its
423
+ // ids would tell the server the client holds what the error replaced
417
424
  const hasUsableCache =
418
425
  isRestorable(cachedSegments) &&
426
+ !cachedSegments.some((s) => s.type === "error") &&
419
427
  !isInterceptOnlyCache(cachedSegments) &&
420
428
  !isLeavingIntercept &&
421
429
  !cached?.stale &&
@@ -538,7 +546,11 @@ export function createNavigationBridge(
538
546
  error,
539
547
  url,
540
548
  options?._redirectHops ?? 0,
541
- { replace: options?.replace, transition: options?.transition },
549
+ {
550
+ replace: options?.replace,
551
+ transition: options?.transition,
552
+ _redirectFrom: options?._redirectFrom,
553
+ },
542
554
  );
543
555
  }
544
556
 
@@ -569,9 +581,19 @@ export function createNavigationBridge(
569
581
  },
570
582
 
571
583
  /**
572
- * Refresh current route
584
+ * Refresh current route. A cross-tab refresh holds the action error
585
+ * boundary the page shows (actionBoundaryAt), as the follow-up does: it
586
+ * is this tab's own outcome, not a read another tab's write changed. A
587
+ * refresh the app asks for renders it again.
573
588
  */
574
- async refresh(): Promise<void> {
589
+ async refresh(crossTab?: boolean): Promise<void> {
590
+ const shown =
591
+ (crossTab &&
592
+ !store.getInterceptSourceUrl() &&
593
+ store.getCachedSegments(store.getHistoryKey())?.segments) ||
594
+ [];
595
+ const at = actionBoundaryAt(shown);
596
+ if (at === 0) return;
575
597
  eventController.abortNavigation();
576
598
 
577
599
  const tx = createNavigationTransaction(
@@ -589,7 +611,7 @@ export function createNavigationBridge(
589
611
  false,
590
612
  tx.handle.signal,
591
613
  tx.with({ url: window.location.href, replace: true, scroll: false }),
592
- { type: "navigate", refresh: true },
614
+ { type: "navigate", refresh: true, boundary: shown[at]?.id },
593
615
  );
594
616
  } catch (error) {
595
617
  // Aborted or superseded: bail without rendering a boundary (see navigate()).
@@ -717,9 +739,20 @@ export function createNavigationBridge(
717
739
  }),
718
740
  });
719
741
 
742
+ // An action error boundary the entry shows (actionBoundaryAt) is
743
+ // held: the page holds the ids above it, so a link renders it again,
744
+ // and the revalidation below renders above it. Never under an
745
+ // intercept, as the follow-up (server-action-bridge.ts).
746
+ const boundaryAt = isIntercept ? -1 : actionBoundaryAt(cachedSegments);
747
+ const boundary = cachedSegments[boundaryAt]?.id;
748
+ const heldIds = idsAboveBoundary(
749
+ cachedSegments.map((s) => s.id),
750
+ boundary,
751
+ );
752
+
720
753
  // Update store to point to this history entry
721
754
  store.setHistoryKey(historyKey);
722
- store.setSegmentIds(cachedSegments.map((s) => s.id));
755
+ store.setSegmentIds(heldIds);
723
756
  store.setCurrentUrl(url);
724
757
  store.setPath(new URL(url).pathname);
725
758
 
@@ -798,8 +831,9 @@ export function createNavigationBridge(
798
831
  onUpdate({ ...popstateUpdate, inTransition: true });
799
832
  });
800
833
  }
801
- // SWR: If stale, trigger background revalidation
802
- if (isStale) {
834
+ // SWR: If stale, trigger background revalidation. Nothing is
835
+ // above a boundary at the page's first segment.
836
+ if (isStale && boundaryAt !== 0) {
803
837
  debugLog("[Browser] Cache is stale, background revalidating...");
804
838
  // Background revalidation - don't await, just fire and forget.
805
839
  // When the entry's handles are incomplete (a deferred Meta was still
@@ -811,9 +845,7 @@ export function createNavigationBridge(
811
845
  // would stay the pre-resolution carry. handlesPending is set only for
812
846
  // the deferred-Meta-aborted case, so action/cross-tab SWR keeps the
813
847
  // cheap diff path.
814
- const segmentIds = cached?.handlesPending
815
- ? []
816
- : cachedSegments.map((s) => s.id);
848
+ const segmentIds = cached?.handlesPending ? [] : heldIds;
817
849
 
818
850
  const tx = createNavigationTransaction(
819
851
  store,
@@ -835,7 +867,7 @@ export function createNavigationBridge(
835
867
  interceptSourceUrl,
836
868
  cacheOnly: true,
837
869
  }),
838
- { type: "stale-revalidation", interceptSourceUrl },
870
+ { type: "stale-revalidation", interceptSourceUrl, boundary },
839
871
  )
840
872
  .catch((error) => {
841
873
  if (isBackgroundSuppressible(error)) return;
@@ -990,7 +1022,7 @@ export function createNavigationBridge(
990
1022
 
991
1023
  // Register cross-tab refresh callback with the store
992
1024
  store.setCrossTabRefreshCallback(() => {
993
- this.refresh();
1025
+ this.refresh(true);
994
1026
  });
995
1027
 
996
1028
  window.addEventListener("popstate", handlePopstate);
@@ -82,6 +82,7 @@ export function createNavigationClient(
82
82
  rewriteTarget,
83
83
  signal,
84
84
  staleRevalidation,
85
+ boundary,
85
86
  interceptSourceUrl,
86
87
  version,
87
88
  routerId,
@@ -114,6 +115,7 @@ export function createNavigationClient(
114
115
  segmentIds,
115
116
  emptySegments: "send",
116
117
  stale: staleRevalidation,
118
+ boundary,
117
119
  version,
118
120
  routerId,
119
121
  rewriteTarget,
@@ -28,6 +28,9 @@ export function toNetworkError(
28
28
  * Render an error into the segment tree via the onUpdate subscriber so the
29
29
  * nearest error boundary catches it. Wrapped in startTransition; RenderErrorThrower
30
30
  * throws during render (async rejections do not reach boundaries on their own).
31
+ * Flagged inTransition like every tree handed over in a transition, so route
32
+ * state that arrives after it (the failed navigation's idle) waits for its
33
+ * commit (NavigationProvider's "anchor").
31
34
  */
32
35
  function emitErrorToBoundary(
33
36
  onUpdate: UpdateSubscriber,
@@ -43,6 +46,7 @@ function emitErrorToBoundary(
43
46
  segments: [],
44
47
  isError: true,
45
48
  },
49
+ inTransition: true,
46
50
  });
47
51
  });
48
52
  }
@@ -14,7 +14,7 @@ export const addTransitionType: ((type: string) => void) | undefined =
14
14
  "addTransitionType" in React ? (React as any).addTransitionType : undefined;
15
15
 
16
16
  import type { RenderSegmentsOptions } from "../segment-system.js";
17
- import { reconcileSegments } from "./segment-reconciler.js";
17
+ import { idsAboveBoundary, reconcileSegments } from "./segment-reconciler.js";
18
18
  import type { ReconcileActor } from "./segment-reconciler.js";
19
19
  import { Defer } from "./defer.js";
20
20
  import {
@@ -236,15 +236,28 @@ export type UpdateMode =
236
236
  * transition({ when }) sees kind "revalidate", not "replace".
237
237
  */
238
238
  refresh?: boolean;
239
+ /** The cross-tab refresh's held boundary (see `action`). */
240
+ boundary?: string;
241
+ }
242
+ | { type: "leave-intercept"; interceptSourceUrl?: string; boundary?: never }
243
+ | {
244
+ type: "stale-revalidation";
245
+ interceptSourceUrl?: string;
246
+ /** Back/Forward's held boundary (see `action`). */
247
+ boundary?: string;
239
248
  }
240
- | { type: "leave-intercept"; interceptSourceUrl?: string }
241
- | { type: "stale-revalidation"; interceptSourceUrl?: string }
242
249
  | {
243
250
  type: "action";
244
251
  interceptSourceUrl?: string;
245
252
  actionId?: string;
246
253
  /** The action's FormData and result, for transition({ when }) `action`. */
247
254
  action?: TransitionWhenActionInput;
255
+ /**
256
+ * An action's error boundary the page shows (actionBoundaryAt): the
257
+ * server renders above it, its cached segment stays, and the page
258
+ * holds only the ids above it.
259
+ */
260
+ boundary?: string;
248
261
  };
249
262
 
250
263
  /**
@@ -569,6 +582,7 @@ export function createPartialUpdater(
569
582
  const historyKeyAtStart = store.getHistoryKey();
570
583
 
571
584
  const interceptSourceUrl = mode.interceptSourceUrl;
585
+ const boundary = mode.boundary;
572
586
 
573
587
  let segments: string[];
574
588
  if (mode.type === "leave-intercept") {
@@ -690,6 +704,7 @@ export function createPartialUpdater(
690
704
  : store.getHistoryEntryMemory()?.rewriteTarget,
691
705
  staleRevalidation:
692
706
  mode.type === "stale-revalidation" || segments.length === 0,
707
+ boundary,
693
708
  version: getVersion(),
694
709
  routerId: store.getRouterId?.(),
695
710
  });
@@ -874,14 +889,14 @@ export function createPartialUpdater(
874
889
  // in place of each missing value, and this navigation fills them from
875
890
  // one fill request once it has committed the tree.
876
891
  let defers: Holes["defers"] | undefined;
877
- let heldIds = matchedIds;
892
+ let heldIds = idsAboveBoundary(matchedIds, boundary);
878
893
  const placeholders = serverSegments.filter(isPlaceholder);
879
894
  if (placeholders.length > 0) {
880
895
  defers = armHoles(placeholders);
881
896
  // A placeholder is not held: every later request from this page
882
897
  // leaves its id out, so the server renders it.
883
898
  const deferredIds = new Set(placeholders.map((s) => s.id));
884
- heldIds = matchedIds.filter((id) => !deferredIds.has(id));
899
+ heldIds = heldIds.filter((id) => !deferredIds.has(id));
885
900
  }
886
901
 
887
902
  const reconcile = () =>
@@ -895,7 +910,10 @@ export function createPartialUpdater(
895
910
  });
896
911
  let reconciled = reconcile();
897
912
 
898
- const missing = missingIds(matchedIds, reconciled.segments);
913
+ // Not the ids at or below a held boundary: its loaders may never have
914
+ // reached the client (the route failed on its GET), and nothing renders
915
+ // them under the error.
916
+ const missing = missingIds(heldIds, reconciled.segments);
899
917
 
900
918
  if (missing.length > 0) {
901
919
  const missingCount = missing.length;
@@ -1119,6 +1137,15 @@ export function createPartialUpdater(
1119
1137
  }),
1120
1138
  ...(serverLocationState && { serverState: serverLocationState }),
1121
1139
  };
1140
+ // A refetch that held an action's boundary commits only while the page
1141
+ // on screen still shows it: a later commit there (an action's success)
1142
+ // replaced it, and this response would put the error back.
1143
+ if (
1144
+ boundary &&
1145
+ !entryOnScreen()?.includes(cachedSegs.find((s) => s.id === boundary)!)
1146
+ ) {
1147
+ return;
1148
+ }
1122
1149
  const cachedHandleData = readCachedHandleData?.();
1123
1150
  const { scroll: navScroll } = tx.commit(
1124
1151
  heldIds,
@@ -1,13 +1,6 @@
1
1
  "use client";
2
2
 
3
- import {
4
- useContext,
5
- useState,
6
- useEffect,
7
- useRef,
8
- useOptimistic,
9
- startTransition,
10
- } from "react";
3
+ import { useContext, useState, useEffect, useRef } from "react";
11
4
  import { NavigationStoreContext } from "./context.js";
12
5
  import { shallowEqual } from "./shallow-equal.js";
13
6
  import type { TrackedActionState } from "../types.js";
@@ -78,19 +71,25 @@ export type ServerActionFunction = ((...args: any[]) => Promise<any>) & {
78
71
  * tracks the state of individual server action invocations.
79
72
  *
80
73
  * Uses the event controller for reactive state management.
81
- * State is derived from the inflight actions tracked by the controller.
74
+ * State is derived from the calls the controller tracks: those in flight,
75
+ * and each action's last settled call.
82
76
  *
83
77
  * Features:
84
78
  * - Tracks action lifecycle: idle → loading → streaming → idle
85
- * - Captures result/error locally (React handles cleanup)
86
- * - If multiple actions fire, tracks only the last one
79
+ * - Keeps the settled call's state until the action is called again.
80
+ * - If the same action fires several times, reports the latest call to
81
+ * start. An earlier call that settles later changes nothing it reports;
82
+ * a latest call that was aborted reports idle with nothing. One call at a
83
+ * time, not one per argument: a reader filtering on `payload` cannot track
84
+ * an earlier call once a later one starts.
87
85
  * - Supports selector pattern like useNavigation
88
86
  *
89
87
  * Matching behavior:
90
88
  * - **Function reference**: Uses full $$id for exact matching. This is precise
91
89
  * and distinguishes between actions with the same name in different files.
92
90
  * - **String**: Matches by suffix (action name after #). This is convenient
93
- * but may be ambiguous if multiple files export the same action name.
91
+ * but may be ambiguous if multiple files export the same action name: it
92
+ * follows the latest call of any action with that export name.
94
93
  *
95
94
  * @param action - Either a server action function or a string action name.
96
95
  * - **Function**: Must be directly imported in the client component.
@@ -138,8 +137,10 @@ export function useAction<T>(
138
137
  ? getActionId(action)
139
138
  : "";
140
139
 
141
- // Base state for useOptimistic
142
- const [baseState, setBaseState] = useState<T | TrackedActionState>(() => {
140
+ // Set urgently from notifyAction's task, also while React holds a page: a
141
+ // transition of its own would take the held page's transition types (see
142
+ // useNavigation).
143
+ const [value, setValue] = useState<T | TrackedActionState>(() => {
143
144
  if (!ctx) {
144
145
  return selector ? selector(DEFAULT_ACTION_STATE) : DEFAULT_ACTION_STATE;
145
146
  }
@@ -149,11 +150,8 @@ export function useAction<T>(
149
150
  : ctx.eventController.getActionState(actionId);
150
151
  return selector ? selector(state) : state;
151
152
  });
152
- const prevSelected = useRef(baseState);
153
- prevSelected.current = baseState;
154
- const [optimisticState, setOptimisticState] = useOptimistic<
155
- T | TrackedActionState
156
- >(null!);
153
+ const prevSelected = useRef(value);
154
+ prevSelected.current = value;
157
155
 
158
156
  // Ref keeps the latest selector for subscription callbacks without
159
157
  // re-subscribing on every render. Selector changes themselves are not
@@ -166,41 +164,20 @@ export function useAction<T>(
166
164
  useEffect(() => {
167
165
  if (!ctx) return;
168
166
 
167
+ const sync = (state: TrackedActionState) => {
168
+ const selected = selectorRef.current ? selectorRef.current(state) : state;
169
+ if (!shallowEqual(selected, prevSelected.current)) {
170
+ prevSelected.current = selected;
171
+ setValue(selected);
172
+ }
173
+ };
169
174
  // Sync current state for the (possibly new) actionId so that switching
170
175
  // actions on an idle page doesn't leave stale data from the old action.
171
- const currentState = ctx.eventController.getActionState(actionId);
172
- const currentSelected = selectorRef.current
173
- ? selectorRef.current(currentState)
174
- : currentState;
175
- if (!shallowEqual(currentSelected, prevSelected.current)) {
176
- prevSelected.current = currentSelected;
177
- setBaseState(currentSelected);
178
- }
179
-
180
- // Subscribe to action-specific updates
181
- const unsubscribe = ctx.eventController.subscribeToAction(
182
- actionId,
183
- (state) => {
184
- const selectedState = selectorRef.current
185
- ? selectorRef.current(state)
186
- : state;
187
-
188
- if (!shallowEqual(selectedState, prevSelected.current)) {
189
- prevSelected.current = selectedState;
190
- setBaseState(selectedState);
191
- startTransition(() => {
192
- setOptimisticState(selectedState);
193
- });
194
- }
195
- },
196
- );
197
-
198
- return () => {
199
- unsubscribe();
200
- };
176
+ sync(ctx.eventController.getActionState(actionId));
177
+ return ctx.eventController.subscribeToAction(actionId, sync);
201
178
  }, [actionId]);
202
179
 
203
- return (optimisticState ?? baseState) as T | TrackedActionState;
180
+ return value;
204
181
  }
205
182
 
206
183
  export type { TrackedActionState };
@@ -6,6 +6,7 @@ import { collectHandleData } from "../../handle.js";
6
6
  import type { HandleData } from "../types.js";
7
7
  import { NavigationStoreContext } from "./context.js";
8
8
  import { shallowEqual } from "./shallow-equal.js";
9
+ import { useCommitted } from "./use-route-state.js";
9
10
  import type { RouteStateSignal } from "../event-controller.js";
10
11
 
11
12
  /**
@@ -53,7 +54,7 @@ export function useHandle<T, A, S>(
53
54
  return selectorRef.current ? selectorRef.current(collected) : collected;
54
55
  };
55
56
 
56
- const [value, setValue] = useCommitAnchoredState(collect);
57
+ const [value, setValue] = useCommitAnchoredState(collect, true);
57
58
 
58
59
  useEffect(() => {
59
60
  if (!ctx) return;
@@ -68,25 +69,30 @@ export function useHandle<T, A, S>(
68
69
  }
69
70
 
70
71
  /**
71
- * Route state the controller's handle notifications drive (useHandle,
72
- * useSegments) commits with the page it belongs to. NavigationProvider
73
- * flushes the controller inside every payload update, so a value known then
74
- * is set in the page's lane. A page handed over urgently (signal "release")
75
- * takes a value that arrives before it commits the same way: set at default
76
- * priority, it renders in the page's lane, and the page's effects read it.
77
- * A page in a transition (signal "anchor") does not: until it commits
72
+ * Route state the controller's handle notifications drive commits with the
73
+ * page it belongs to. NavigationProvider flushes the controller inside every
74
+ * payload update, with signal "anchor" for a page in a transition and
75
+ * "release" for an urgent one, so the page's own change is set in the page's
76
+ * lane and the page's effects read it. From the anchor until the page commits
78
77
  * (releaseRouteState() from the provider's layout effect, signal "release"),
79
- * a new value waits, and is applied in the commit's task, right after the
80
- * page's own commit. In the page's lane, a value that suspends in a revealed
81
- * <Suspense> (a crumb's content) would hold the page; in a lane of its own,
82
- * it commits over the page being left and takes the page's transition types
83
- * (#1165); and a transition entangled with the page misses its commit when it
84
- * is set after React rendered the page, before its view transition's
85
- * mutation, and starts a view transition of its own. A value shallow-equal to
86
- * the last one sets nothing, so a reader renders only when its value moves.
78
+ * a later change waits and is applied in the commit's task, right after the
79
+ * page's own commit: set in a lane of its own, it would commit over the page
80
+ * being left (#1165).
81
+ *
82
+ * `waitForRelease`, set by useHandle only: the anchor's own change waits too,
83
+ * so a handle value lands right after its page's commit, never in its lane. A
84
+ * handle value can carry a promise that suspends, and in the page's lane one
85
+ * that suspends in a revealed <Suspense> would hold the page. Nothing
86
+ * useSegments reads can suspend, so it leaves it off and its path and
87
+ * location commit with their page.
88
+ *
89
+ * A value shallow-equal to the last one sets nothing, so a reader renders only
90
+ * when its value moves. A release is compared with what React committed, as in
91
+ * useRouteState.
87
92
  */
88
93
  export function useCommitAnchoredState<T>(
89
94
  init: () => T,
95
+ waitForRelease?: boolean,
90
96
  ): [T, (value: T, signal?: RouteStateSignal) => void] {
91
97
  // An app that enables the React Compiler compiles the router too; its memo
92
98
  // cache gains nothing here and cost 107 B gzip in the router chunk.
@@ -95,15 +101,18 @@ export function useCommitAnchoredState<T>(
95
101
  const ref = useRef<{ last: T; held?: boolean; waiting?: boolean }>({
96
102
  last: value,
97
103
  });
104
+ const committed = useCommitted(value);
98
105
  const [set] = useState(() => (next: T, signal?: RouteStateSignal): void => {
99
106
  const r = ref.current;
100
107
  if (signal) r.waiting = signal === "anchor";
101
108
  if (!shallowEqual(next, r.last)) {
102
109
  r.last = next;
103
110
  r.held = true;
111
+ } else if (signal === "release" && r.last !== committed.current) {
112
+ r.held = true;
104
113
  }
105
- // The anchor's own change goes in the page's lane, with the page.
106
- if (!r.held || (r.waiting && signal !== "anchor")) return;
114
+ if (!r.held || (r.waiting && (waitForRelease || signal !== "anchor")))
115
+ return;
107
116
  r.held = false;
108
117
  setValue(() => r.last);
109
118
  });