@timber-js/app 0.1.29 → 0.1.31

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.
@@ -67,53 +67,55 @@ function useLinkStatus() {
67
67
  return useContext(LinkStatusContext);
68
68
  }
69
69
  //#endregion
70
- //#region src/client/router-ref.ts
70
+ //#region src/client/pending-navigation-context.ts
71
71
  /**
72
- * Set the global router instance. Called once during bootstrap.
73
- */
74
- function setGlobalRouter(router) {
75
- _setGlobalRouter(router);
76
- }
77
- /**
78
- * Get the global router instance. Throws if called before bootstrap.
79
- * Used by client-side hooks (useNavigationPending, etc.)
72
+ * PendingNavigationContext React context for the in-flight navigation URL.
73
+ *
74
+ * Provided by TransitionRoot. The value is the URL being navigated to,
75
+ * or null when idle. Used by:
76
+ * - LinkStatusProvider to show per-link pending spinners
77
+ * - useNavigationPending to return a global pending boolean
78
+ *
79
+ * The pending URL is set as an URGENT update (shows immediately) and
80
+ * cleared inside startTransition (commits atomically with the new tree).
81
+ * This ensures pending state appears instantly on navigation start and
82
+ * disappears in the same React commit as the new params/tree.
83
+ *
84
+ * Separate from NavigationContext (which holds params + pathname) because
85
+ * the pending URL is managed as React state in TransitionRoot, while
86
+ * params/pathname are set via module-level state read by renderRoot.
87
+ * Both contexts commit together in the same transition.
88
+ *
89
+ * See design/19-client-navigation.md §"NavigationContext"
80
90
  */
81
- function getRouter() {
82
- if (!globalRouter) throw new Error("[timber] Router not initialized. getRouter() was called before bootstrap().");
83
- return globalRouter;
91
+ var _context$1;
92
+ function getOrCreateContext$1() {
93
+ if (_context$1 !== void 0) return _context$1;
94
+ if (typeof React.createContext === "function") _context$1 = React.createContext(null);
95
+ return _context$1;
84
96
  }
85
97
  /**
86
- * Get the global router instance or null if not yet initialized.
87
- * Used by useRouter() methods to avoid silent failures — callers
88
- * can log a meaningful warning instead of silently no-oping.
98
+ * Read the pending navigation URL from context.
99
+ * Returns null during SSR (no provider) or in the RSC environment.
100
+ * Internal used by LinkStatusProvider and useNavigationPending.
89
101
  */
90
- function getRouterOrNull() {
91
- return globalRouter;
102
+ function usePendingNavigationUrl() {
103
+ const ctx = getOrCreateContext$1();
104
+ if (!ctx) return null;
105
+ if (typeof React.useContext !== "function") return null;
106
+ return React.useContext(ctx);
92
107
  }
93
108
  //#endregion
94
109
  //#region src/client/link-status-provider.tsx
95
110
  var NOT_PENDING = { pending: false };
96
111
  var IS_PENDING = { pending: true };
97
112
  /**
98
- * Client component that subscribes to the router's pending URL and provides
99
- * a scoped LinkStatusContext to children. Renders no extra DOM — just a
100
- * context provider around children.
113
+ * Client component that reads the pending URL from PendingNavigationContext
114
+ * and provides a scoped LinkStatusContext to children. Renders no extra DOM —
115
+ * just a context provider around children.
101
116
  */
102
117
  function LinkStatusProvider({ href, children }) {
103
- const status = useSyncExternalStore((callback) => {
104
- try {
105
- return getRouter().onPendingChange(callback);
106
- } catch {
107
- return () => {};
108
- }
109
- }, () => {
110
- try {
111
- if (getRouter().getPendingUrl() === href) return IS_PENDING;
112
- return NOT_PENDING;
113
- } catch {
114
- return NOT_PENDING;
115
- }
116
- }, () => NOT_PENDING);
118
+ const status = usePendingNavigationUrl() === href ? IS_PENDING : NOT_PENDING;
117
119
  return /* @__PURE__ */ jsx(LinkStatusContext.Provider, {
118
120
  value: status,
119
121
  children
@@ -649,6 +651,26 @@ function createRouter(deps) {
649
651
  pathname: url.startsWith("http") ? new URL(url).pathname : url.split("?")[0] || "/"
650
652
  });
651
653
  }
654
+ /**
655
+ * Render a payload via navigateTransition (production) or renderRoot (tests).
656
+ * The perform callback should fetch data, update state, and return the payload.
657
+ * In production, the entire callback runs inside a React transition with
658
+ * useOptimistic for the pending URL. In tests, the payload is rendered directly.
659
+ */
660
+ async function renderViaTransition(pendingUrl, perform) {
661
+ if (deps.navigateTransition) {
662
+ let headElements = null;
663
+ await deps.navigateTransition(pendingUrl, async (wrapPayload) => {
664
+ const result = await perform();
665
+ headElements = result.headElements;
666
+ return wrapPayload(result.payload);
667
+ });
668
+ return headElements;
669
+ }
670
+ const result = await perform();
671
+ renderPayload(result.payload);
672
+ return result.headElements;
673
+ }
652
674
  /** Apply head elements (title, meta tags) to the DOM if available. */
653
675
  function applyHead(elements) {
654
676
  if (elements && deps.applyHead) deps.applyHead(elements);
@@ -658,6 +680,40 @@ function createRouter(deps) {
658
680
  if (deps.afterPaint) deps.afterPaint(callback);
659
681
  else callback();
660
682
  }
683
+ /**
684
+ * Core navigation logic shared between the transition and fallback paths.
685
+ * Fetches the RSC payload, updates all state, and returns the result.
686
+ */
687
+ async function performNavigationFetch(url, options) {
688
+ const prefetched = prefetchCache.consume(url);
689
+ let result = prefetched ? {
690
+ payload: prefetched.payload,
691
+ headElements: prefetched.headElements,
692
+ segmentInfo: prefetched.segmentInfo ?? null,
693
+ params: prefetched.params ?? null
694
+ } : void 0;
695
+ if (result === void 0) {
696
+ const stateTree = segmentCache.serializeStateTree();
697
+ const rawCurrentUrl = deps.getCurrentUrl();
698
+ result = await fetchRscPayload(url, deps, stateTree, rawCurrentUrl.startsWith("http") ? new URL(rawCurrentUrl).pathname : new URL(rawCurrentUrl, "http://localhost").pathname);
699
+ }
700
+ if (options.replace) deps.replaceState({
701
+ timber: true,
702
+ scrollY: 0
703
+ }, "", url);
704
+ else deps.pushState({
705
+ timber: true,
706
+ scrollY: 0
707
+ }, "", url);
708
+ historyStack.push(url, {
709
+ payload: result.payload,
710
+ headElements: result.headElements,
711
+ params: result.params
712
+ });
713
+ updateSegmentCache(result.segmentInfo);
714
+ updateNavigationState(result.params, url);
715
+ return result;
716
+ }
661
717
  async function navigate(url, options = {}) {
662
718
  const scroll = options.scroll !== false;
663
719
  const replace = options.replace === true;
@@ -668,29 +724,7 @@ function createRouter(deps) {
668
724
  }, "", deps.getCurrentUrl());
669
725
  setPending(true, url);
670
726
  try {
671
- let result = prefetchCache.consume(url);
672
- if (result === void 0) {
673
- const stateTree = segmentCache.serializeStateTree();
674
- const rawCurrentUrl = deps.getCurrentUrl();
675
- result = await fetchRscPayload(url, deps, stateTree, rawCurrentUrl.startsWith("http") ? new URL(rawCurrentUrl).pathname : new URL(rawCurrentUrl, "http://localhost").pathname);
676
- }
677
- if (replace) deps.replaceState({
678
- timber: true,
679
- scrollY: 0
680
- }, "", url);
681
- else deps.pushState({
682
- timber: true,
683
- scrollY: 0
684
- }, "", url);
685
- historyStack.push(url, {
686
- payload: result.payload,
687
- headElements: result.headElements,
688
- params: result.params
689
- });
690
- updateSegmentCache(result.segmentInfo);
691
- updateNavigationState(result.params, url);
692
- renderPayload(result.payload);
693
- applyHead(result.headElements);
727
+ applyHead(await renderViaTransition(url, () => performNavigationFetch(url, { replace })));
694
728
  window.dispatchEvent(new Event("timber:navigation-end"));
695
729
  afterPaint(() => {
696
730
  if (scroll) deps.scrollTo(0, 0);
@@ -713,16 +747,17 @@ function createRouter(deps) {
713
747
  const currentUrl = deps.getCurrentUrl();
714
748
  setPending(true, currentUrl);
715
749
  try {
716
- const result = await fetchRscPayload(currentUrl, deps);
717
- historyStack.push(currentUrl, {
718
- payload: result.payload,
719
- headElements: result.headElements,
720
- params: result.params
721
- });
722
- updateSegmentCache(result.segmentInfo);
723
- updateNavigationState(result.params, currentUrl);
724
- renderPayload(result.payload);
725
- applyHead(result.headElements);
750
+ applyHead(await renderViaTransition(currentUrl, async () => {
751
+ const result = await fetchRscPayload(currentUrl, deps);
752
+ historyStack.push(currentUrl, {
753
+ payload: result.payload,
754
+ headElements: result.headElements,
755
+ params: result.params
756
+ });
757
+ updateSegmentCache(result.segmentInfo);
758
+ updateNavigationState(result.params, currentUrl);
759
+ return result;
760
+ }));
726
761
  } finally {
727
762
  setPending(false);
728
763
  }
@@ -740,16 +775,17 @@ function createRouter(deps) {
740
775
  } else {
741
776
  setPending(true, url);
742
777
  try {
743
- const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree());
744
- updateSegmentCache(result.segmentInfo);
745
- updateNavigationState(result.params, url);
746
- historyStack.push(url, {
747
- payload: result.payload,
748
- headElements: result.headElements,
749
- params: result.params
750
- });
751
- renderPayload(result.payload);
752
- applyHead(result.headElements);
778
+ applyHead(await renderViaTransition(url, async () => {
779
+ const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree());
780
+ updateSegmentCache(result.segmentInfo);
781
+ updateNavigationState(result.params, url);
782
+ historyStack.push(url, {
783
+ payload: result.payload,
784
+ headElements: result.headElements,
785
+ params: result.params
786
+ });
787
+ return result;
788
+ }));
753
789
  afterPaint(() => {
754
790
  deps.scrollTo(0, scrollY);
755
791
  window.dispatchEvent(new Event("timber:scroll-restored"));
@@ -823,15 +859,31 @@ function createRouter(deps) {
823
859
  * ```
824
860
  */
825
861
  function useNavigationPending() {
826
- return useSyncExternalStore((callback) => {
827
- return getRouter().onPendingChange(callback);
828
- }, () => {
829
- try {
830
- return getRouter().isPending();
831
- } catch {
832
- return false;
833
- }
834
- }, () => false);
862
+ return usePendingNavigationUrl() !== null;
863
+ }
864
+ //#endregion
865
+ //#region src/client/router-ref.ts
866
+ /**
867
+ * Set the global router instance. Called once during bootstrap.
868
+ */
869
+ function setGlobalRouter(router) {
870
+ _setGlobalRouter(router);
871
+ }
872
+ /**
873
+ * Get the global router instance. Throws if called before bootstrap.
874
+ * Used by client-side hooks (useNavigationPending, etc.)
875
+ */
876
+ function getRouter() {
877
+ if (!globalRouter) throw new Error("[timber] Router not initialized. getRouter() was called before bootstrap().");
878
+ return globalRouter;
879
+ }
880
+ /**
881
+ * Get the global router instance or null if not yet initialized.
882
+ * Used by useRouter() methods to avoid silent failures — callers
883
+ * can log a meaningful warning instead of silently no-oping.
884
+ */
885
+ function getRouterOrNull() {
886
+ return globalRouter;
835
887
  }
836
888
  //#endregion
837
889
  //#region src/client/use-router.ts