@timber-js/app 0.1.23 → 0.1.25

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 (61) hide show
  1. package/dist/_chunks/{ssr-data-B2yikEEB.js → ssr-data-DLnbYpj1.js} +2 -4
  2. package/dist/_chunks/{ssr-data-B2yikEEB.js.map → ssr-data-DLnbYpj1.js.map} +1 -1
  3. package/dist/_chunks/{use-cookie-D5aS4slY.js → use-cookie-dDbpCTx-.js} +2 -2
  4. package/dist/_chunks/{use-cookie-D5aS4slY.js.map → use-cookie-dDbpCTx-.js.map} +1 -1
  5. package/dist/adapters/nitro.d.ts.map +1 -1
  6. package/dist/adapters/nitro.js +4 -3
  7. package/dist/adapters/nitro.js.map +1 -1
  8. package/dist/cli.js +1 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/client/browser-dev.d.ts +29 -0
  11. package/dist/client/browser-dev.d.ts.map +1 -0
  12. package/dist/client/browser-links.d.ts +32 -0
  13. package/dist/client/browser-links.d.ts.map +1 -0
  14. package/dist/client/error-boundary.js +1 -1
  15. package/dist/client/index.d.ts +2 -0
  16. package/dist/client/index.d.ts.map +1 -1
  17. package/dist/client/index.js +150 -122
  18. package/dist/client/index.js.map +1 -1
  19. package/dist/client/navigation-context.d.ts +52 -0
  20. package/dist/client/navigation-context.d.ts.map +1 -0
  21. package/dist/client/router.d.ts.map +1 -1
  22. package/dist/client/transition-root.d.ts +54 -0
  23. package/dist/client/transition-root.d.ts.map +1 -0
  24. package/dist/client/use-params.d.ts +35 -25
  25. package/dist/client/use-params.d.ts.map +1 -1
  26. package/dist/client/use-pathname.d.ts +11 -4
  27. package/dist/client/use-pathname.d.ts.map +1 -1
  28. package/dist/client/use-router.d.ts +14 -0
  29. package/dist/client/use-router.d.ts.map +1 -1
  30. package/dist/cookies/index.js +2 -2
  31. package/dist/server/index.js +264 -218
  32. package/dist/server/index.js.map +1 -1
  33. package/dist/server/metadata-platform.d.ts +34 -0
  34. package/dist/server/metadata-platform.d.ts.map +1 -0
  35. package/dist/server/metadata-render.d.ts.map +1 -1
  36. package/dist/server/metadata-social.d.ts +24 -0
  37. package/dist/server/metadata-social.d.ts.map +1 -0
  38. package/dist/server/pipeline-interception.d.ts +32 -0
  39. package/dist/server/pipeline-interception.d.ts.map +1 -0
  40. package/dist/server/pipeline-metadata.d.ts +31 -0
  41. package/dist/server/pipeline-metadata.d.ts.map +1 -0
  42. package/dist/server/pipeline.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/src/adapters/nitro.ts +9 -7
  45. package/src/cli.ts +9 -2
  46. package/src/client/browser-dev.ts +142 -0
  47. package/src/client/browser-entry.ts +73 -223
  48. package/src/client/browser-links.ts +90 -0
  49. package/src/client/index.ts +4 -0
  50. package/src/client/navigation-context.ts +118 -0
  51. package/src/client/router.ts +37 -33
  52. package/src/client/transition-root.tsx +86 -0
  53. package/src/client/use-params.ts +50 -54
  54. package/src/client/use-pathname.ts +31 -24
  55. package/src/client/use-router.ts +17 -15
  56. package/src/server/metadata-platform.ts +229 -0
  57. package/src/server/metadata-render.ts +9 -363
  58. package/src/server/metadata-social.ts +184 -0
  59. package/src/server/pipeline-interception.ts +76 -0
  60. package/src/server/pipeline-metadata.ts +90 -0
  61. package/src/server/pipeline.ts +2 -148
@@ -5,8 +5,8 @@ import { SegmentCache, PrefetchCache, buildSegmentTree } from './segment-cache';
5
5
  import type { SegmentInfo } from './segment-cache';
6
6
  import { HistoryStack } from './history';
7
7
  import type { HeadElement } from './head';
8
- import { flushSync } from 'react-dom';
9
- import { setCurrentParams, notifyParamsListeners } from './use-params.js';
8
+ import { setCurrentParams } from './use-params.js';
9
+ import { setNavigationState } from './navigation-context.js';
10
10
 
11
11
  // ─── Types ───────────────────────────────────────────────────────
12
12
 
@@ -325,9 +325,26 @@ export function createRouter(deps: RouterDeps): RouterInstance {
325
325
  }
326
326
  }
327
327
 
328
- /** Update useParams() with route params from the server response. */
329
- function updateParams(params: Record<string, string | string[]> | null | undefined): void {
330
- setCurrentParams(params ?? {});
328
+ /**
329
+ * Update navigation state (params + pathname) for the next render.
330
+ *
331
+ * Sets both the module-level fallback (for tests and SSR) and the
332
+ * navigation context state (read by renderRoot to wrap the element
333
+ * in NavigationProvider). The context update is atomic with the tree
334
+ * render — both are passed to reactRoot.render() in the same call.
335
+ */
336
+ function updateNavigationState(
337
+ params: Record<string, string | string[]> | null | undefined,
338
+ url: string
339
+ ): void {
340
+ const resolvedParams = params ?? {};
341
+ // Module-level fallback for tests (no NavigationProvider) and SSR
342
+ setCurrentParams(resolvedParams);
343
+ // Navigation context — read by renderRoot to wrap the RSC element
344
+ const pathname = url.startsWith('http')
345
+ ? new URL(url).pathname
346
+ : url.split('?')[0] || '/';
347
+ setNavigationState({ params: resolvedParams, pathname });
331
348
  }
332
349
 
333
350
  /** Apply head elements (title, meta tags) to the DOM if available. */
@@ -394,18 +411,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
394
411
  // header reflects the currently mounted segments.
395
412
  updateSegmentCache(result.segmentInfo);
396
413
 
397
- // Update params, render the new tree, and notify params subscribers
398
- // in a single synchronous flush. Without flushSync, renderPayload()
399
- // (reactRoot.render) and notifyParamsListeners() are separate update
400
- // mechanisms that React may commit in different frames causing a
401
- // flash where both the old and new active rows show simultaneously
402
- // in preserved layouts (the new tree commits before the external
403
- // store re-render deactivates the old row).
404
- updateParams(result.params);
405
- flushSync(() => {
406
- renderPayload(result.payload);
407
- notifyParamsListeners();
408
- });
414
+ // Update navigation state (params + pathname) before rendering.
415
+ // The renderRoot callback reads this state and wraps the RSC element
416
+ // in NavigationProvider — so the context value and the element tree
417
+ // are passed to reactRoot.render() in the same call, making the
418
+ // update atomic. Preserved layouts see new params in the same render
419
+ // pass as the new tree, preventing the dual-active-row flash.
420
+ updateNavigationState(result.params, url);
421
+ renderPayload(result.payload);
409
422
 
410
423
  // Update document.title and <meta> tags with the new page's metadata
411
424
  applyHead(result.headElements);
@@ -465,12 +478,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
465
478
  // Update segment cache with fresh segment info from full render
466
479
  updateSegmentCache(result.segmentInfo);
467
480
 
468
- // Atomic update — see navigate() for rationale on flushSync.
469
- updateParams(result.params);
470
- flushSync(() => {
471
- renderPayload(result.payload);
472
- notifyParamsListeners();
473
- });
481
+ // Atomic update — see navigate() for rationale on NavigationProvider.
482
+ updateNavigationState(result.params, currentUrl);
483
+ renderPayload(result.payload);
474
484
  applyHead(result.headElements);
475
485
  } finally {
476
486
  setPending(false);
@@ -485,11 +495,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
485
495
 
486
496
  if (entry && entry.payload !== null) {
487
497
  // Replay cached payload — no server roundtrip
488
- updateParams(entry.params);
489
- flushSync(() => {
490
- renderPayload(entry.payload);
491
- notifyParamsListeners();
492
- });
498
+ updateNavigationState(entry.params, url);
499
+ renderPayload(entry.payload);
493
500
  applyHead(entry.headElements);
494
501
  afterPaint(() => {
495
502
  deps.scrollTo(0, scrollY);
@@ -505,16 +512,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
505
512
  const stateTree = segmentCache.serializeStateTree();
506
513
  const result = await fetchRscPayload(url, deps, stateTree);
507
514
  updateSegmentCache(result.segmentInfo);
508
- updateParams(result.params);
515
+ updateNavigationState(result.params, url);
509
516
  historyStack.push(url, {
510
517
  payload: result.payload,
511
518
  headElements: result.headElements,
512
519
  params: result.params,
513
520
  });
514
- flushSync(() => {
515
- renderPayload(result.payload);
516
- notifyParamsListeners();
517
- });
521
+ renderPayload(result.payload);
518
522
  applyHead(result.headElements);
519
523
  afterPaint(() => {
520
524
  deps.scrollTo(0, scrollY);
@@ -0,0 +1,86 @@
1
+ /**
2
+ * TransitionRoot — Wrapper component for transition-based rendering.
3
+ *
4
+ * Solves the "new boundary has no old content" problem for client-side
5
+ * navigation. When React renders a completely new Suspense boundary via
6
+ * root.render(), it shows the fallback immediately — root.render() is
7
+ * always an urgent update regardless of startTransition.
8
+ *
9
+ * TransitionRoot holds the current element in React state. Navigation
10
+ * updates call startTransition(() => setState(newElement)), which IS
11
+ * a transition update. React keeps the old committed tree visible while
12
+ * any new Suspense boundaries in the transition resolve.
13
+ *
14
+ * This is the client-side equivalent of deferSuspenseFor on the server:
15
+ * the old content stays visible until the new content is ready, avoiding
16
+ * flash-of-fallback during fast navigations.
17
+ *
18
+ * See design/05-streaming.md §"deferSuspenseFor"
19
+ */
20
+
21
+ import { useState, startTransition, type ReactNode } from 'react';
22
+
23
+ // ─── Module-level render function ────────────────────────────────
24
+
25
+ /**
26
+ * Module-level reference to the state setter wrapped in startTransition.
27
+ * Set during TransitionRoot's render. This is safe because there is
28
+ * exactly one TransitionRoot per application (the document root).
29
+ */
30
+ let _transitionRender: ((element: ReactNode) => void) | null = null;
31
+
32
+ // ─── Component ───────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Root wrapper component that enables transition-based rendering.
36
+ *
37
+ * Renders no DOM elements — returns the current element directly.
38
+ * This means the DOM tree matches the server-rendered HTML during
39
+ * hydration (TransitionRoot is invisible to the DOM).
40
+ *
41
+ * Usage in browser-entry.ts:
42
+ * const rootEl = createElement(TransitionRoot, { initial: wrapped });
43
+ * reactRoot = hydrateRoot(document, rootEl);
44
+ *
45
+ * Subsequent navigations:
46
+ * transitionRender(newWrappedElement);
47
+ */
48
+ export function TransitionRoot({ initial }: { initial: ReactNode }): ReactNode {
49
+ const [element, setElement] = useState<ReactNode>(initial);
50
+
51
+ // Update the module-level ref on every render so it always points
52
+ // to the current component instance's setState.
53
+ _transitionRender = (newElement: ReactNode) => {
54
+ startTransition(() => {
55
+ setElement(newElement);
56
+ });
57
+ };
58
+
59
+ return element;
60
+ }
61
+
62
+ // ─── Public API ──────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Trigger a transition render. React keeps the old committed tree
66
+ * visible while any new Suspense boundaries in the update resolve.
67
+ *
68
+ * This is the function called by the router's renderRoot callback
69
+ * instead of reactRoot.render() directly.
70
+ *
71
+ * Falls back to no-op if TransitionRoot hasn't mounted yet (shouldn't
72
+ * happen in practice — TransitionRoot mounts during hydration).
73
+ */
74
+ export function transitionRender(element: ReactNode): void {
75
+ if (_transitionRender) {
76
+ _transitionRender(element);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Check if the TransitionRoot is mounted and ready for renders.
82
+ * Used by browser-entry.ts to guard against renders before hydration.
83
+ */
84
+ export function isTransitionRootReady(): boolean {
85
+ return _transitionRender !== null;
86
+ }
@@ -18,10 +18,11 @@
18
18
  * (populated by ssr-entry.ts) to ensure correct per-request isolation
19
19
  * across concurrent requests with streaming Suspense.
20
20
  *
21
- * Reactivity: useParams() uses useSyncExternalStore so that components
22
- * in unchanged layouts (e.g., sidebar items) re-render atomically when
23
- * params change during client-side navigation. This matches the pattern
24
- * used by usePathname() and useSearchParams().
21
+ * Reactivity: On the client, useParams() reads from NavigationContext
22
+ * which is updated atomically with the RSC tree render. This replaces
23
+ * the previous useSyncExternalStore approach that suffered from a
24
+ * timing gap between tree render and store notification — causing
25
+ * preserved layout components to briefly show stale active state.
25
26
  *
26
27
  * All mutable state is delegated to client/state.ts for singleton guarantees.
27
28
  * See design/18-build-system.md §"Singleton State Registry"
@@ -29,18 +30,20 @@
29
30
  * Design doc: design/09-typescript.md §"Typed Routes"
30
31
  */
31
32
 
32
- import { useSyncExternalStore } from 'react';
33
33
  import type { Routes } from '#/index.js';
34
34
  import { getSsrData } from './ssr-data.js';
35
35
  import { currentParams, _setCurrentParams, paramsListeners } from './state.js';
36
+ import { useNavigationContext } from './navigation-context.js';
36
37
 
37
38
  // ---------------------------------------------------------------------------
38
- // Module-level subscribe/notify pattern — state lives in state.ts
39
+ // Module-level subscribe/notify pattern — kept for backward compat and tests
39
40
  // ---------------------------------------------------------------------------
40
41
 
41
42
  /**
42
- * Subscribe to params changes. Called by useSyncExternalStore.
43
- * Exported for testing not intended for direct use by app code.
43
+ * Subscribe to params changes.
44
+ * Retained for backward compatibility with tests that verify the
45
+ * subscribe/notify contract. On the client, useParams() reads from
46
+ * NavigationContext instead.
44
47
  */
45
48
  export function subscribe(callback: () => void): () => void {
46
49
  paramsListeners.add(callback);
@@ -48,51 +51,43 @@ export function subscribe(callback: () => void): () => void {
48
51
  }
49
52
 
50
53
  /**
51
- * Get the current params snapshot (client).
52
- * Exported for testing not intended for direct use by app code.
54
+ * Get the current params snapshot (module-level fallback).
55
+ * Used by tests and by the hook when called outside a React component.
53
56
  */
54
57
  export function getSnapshot(): Record<string, string | string[]> {
55
58
  return currentParams;
56
59
  }
57
60
 
58
- /**
59
- * Get the server-side params snapshot (SSR).
60
- * Falls back to the module-level currentParams if no SSR context
61
- * is available (shouldn't happen, but defensive).
62
- */
63
- function getServerSnapshot(): Record<string, string | string[]> {
64
- return getSsrData()?.params ?? currentParams;
65
- }
66
-
67
61
  // ---------------------------------------------------------------------------
68
62
  // Framework API — called by the segment router on each navigation
69
63
  // ---------------------------------------------------------------------------
70
64
 
71
65
  /**
72
- * Set the current route params WITHOUT notifying subscribers.
73
- * Called by the router before renderPayload() so that new components
74
- * in the RSC tree see the updated params via getSnapshot(), but
75
- * preserved layout components don't re-render prematurely with
76
- * {old tree, new params}.
66
+ * Set the current route params in the module-level store.
67
+ *
68
+ * Called by the router on each navigation. This updates the fallback
69
+ * snapshot used by tests and by the hook when called outside a React
70
+ * component (no NavigationContext available).
77
71
  *
78
- * After the React render commits, the router calls notifyParamsListeners()
79
- * to trigger re-renders in preserved layouts that read useParams().
72
+ * On the client, the primary reactivity path is NavigationContext —
73
+ * the router calls setNavigationState() then renderRoot() which wraps
74
+ * the element in NavigationProvider. setCurrentParams is still called
75
+ * for the module-level fallback.
80
76
  *
81
- * On the client, the segment router calls this on each navigation.
82
77
  * During SSR, params are also available via getSsrData().params
83
- * (ALS-backed), but setCurrentParams is still called for the
84
- * module-level fallback path.
78
+ * (ALS-backed).
85
79
  */
86
80
  export function setCurrentParams(params: Record<string, string | string[]>): void {
87
81
  _setCurrentParams(params);
88
82
  }
89
83
 
90
84
  /**
91
- * Notify all useSyncExternalStore subscribers that params have changed.
92
- * Called by the router AFTER renderPayload() so that preserved layout
93
- * components re-render only after the new tree is committed — producing
94
- * an atomic {new tree, new params} update instead of a stale
95
- * {old tree, new params} intermediate state.
85
+ * Notify all legacy subscribers that params have changed.
86
+ *
87
+ * Retained for backward compatibility with tests. On the client,
88
+ * the NavigationContext + renderRoot pattern replaces this params
89
+ * update atomically with the tree render, so explicit notification
90
+ * is no longer needed.
96
91
  */
97
92
  export function notifyParamsListeners(): void {
98
93
  for (const listener of paramsListeners) {
@@ -110,9 +105,15 @@ export function notifyParamsListeners(): void {
110
105
  * The optional `_route` argument exists only for TypeScript narrowing —
111
106
  * it does not affect the runtime return value.
112
107
  *
108
+ * On the client, reads from NavigationContext (provided by
109
+ * NavigationProvider in renderRoot). This ensures params update
110
+ * atomically with the RSC tree — no timing gap.
111
+ *
113
112
  * During SSR, reads from the ALS-backed SSR data context to ensure
114
- * per-request isolation. On the client, subscribes to the module-level
115
- * params store via useSyncExternalStore.
113
+ * per-request isolation across concurrent requests with streaming Suspense.
114
+ *
115
+ * When called outside a React component (e.g., in test assertions),
116
+ * falls back to the module-level snapshot.
116
117
  *
117
118
  * @overload Typed — when a known route path is passed, returns the
118
119
  * exact params shape from the generated Routes interface.
@@ -121,25 +122,20 @@ export function notifyParamsListeners(): void {
121
122
  export function useParams<R extends keyof Routes>(route: R): Routes[R]['params'];
122
123
  export function useParams(route?: string): Record<string, string | string[]>;
123
124
  export function useParams(_route?: string): Record<string, string | string[]> {
124
- // useSyncExternalStore handles both client and SSR:
125
- // - Client: calls getSnapshot() reads currentParams from state.ts
126
- // - SSR: calls getServerSnapshot() reads from ALS-backed getSsrData()
127
- //
128
- // We must always call the hook (Rules of Hooks — no conditional hook calls).
129
- // React picks the right snapshot function based on the environment.
130
- //
131
- // When called outside a React component (e.g., in test assertions),
132
- // useSyncExternalStore throws because there's no dispatcher. In that case,
133
- // fall back to reading the snapshot directly.
125
+ // Try reading from NavigationContext (client-side, inside React tree).
126
+ // During SSR, no NavigationProvider is mounted, so this returns null.
127
+ // When called outside a React component, useContext throws — caught below.
134
128
  try {
135
- return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
129
+ const navContext = useNavigationContext();
130
+ if (navContext !== null) {
131
+ return navContext.params;
132
+ }
136
133
  } catch {
137
- // No React dispatcher available return the best available snapshot.
138
- // This path is hit when useParams() is called outside a component,
139
- // e.g. in test assertions that verify the current params value.
140
- // Use getServerSnapshot() because it checks the ALS-backed SSR context
141
- // first (request-isolated), falling back to module-level currentParams
142
- // only when no SSR context exists (client-side / tests).
143
- return getServerSnapshot();
134
+ // No React dispatcher available (called outside a component).
135
+ // Fall through to module-level snapshot below.
144
136
  }
137
+
138
+ // SSR path: read from ALS-backed SSR data context.
139
+ // Falls back to module-level currentParams for tests.
140
+ return getSsrData()?.params ?? currentParams;
145
141
  }
@@ -4,40 +4,47 @@
4
4
  * Returns the pathname portion of the current URL (e.g. '/dashboard/settings').
5
5
  * Updates when client-side navigation changes the URL.
6
6
  *
7
- * This is a thin wrapper over window.location.pathname, provided for
8
- * Next.js API compatibility (libraries like nuqs import usePathname
9
- * from next/navigation).
7
+ * On the client, reads from NavigationContext which is updated atomically
8
+ * with the RSC tree render. This replaces the previous useSyncExternalStore
9
+ * approach which only subscribed to popstate events — meaning usePathname()
10
+ * did NOT re-render on forward navigation (pushState). The context approach
11
+ * fixes this: pathname updates in the same render pass as the new tree.
10
12
  *
11
13
  * During SSR, reads the request pathname from the SSR ALS context
12
14
  * (populated by ssr-entry.ts) instead of window.location.
15
+ *
16
+ * Compatible with Next.js's `usePathname()` from `next/navigation`.
13
17
  */
14
18
 
15
- import { useSyncExternalStore } from 'react';
16
19
  import { getSsrData } from './ssr-data.js';
17
-
18
- function getPathname(): string {
19
- if (typeof window !== 'undefined') return window.location.pathname;
20
- return getSsrData()?.pathname ?? '/';
21
- }
22
-
23
- function getServerPathname(): string {
24
- return getSsrData()?.pathname ?? '/';
25
- }
26
-
27
- function subscribe(callback: () => void): () => void {
28
- // Listen for popstate (back/forward) and timber's custom navigation events.
29
- // pushState/replaceState don't fire popstate, but timber's router calls
30
- // onPendingChange listeners after navigation — components re-render
31
- // naturally via React's tree update from the new RSC payload.
32
- window.addEventListener('popstate', callback);
33
- return () => window.removeEventListener('popstate', callback);
34
- }
20
+ import { useNavigationContext } from './navigation-context.js';
35
21
 
36
22
  /**
37
23
  * Read the current URL pathname.
38
24
  *
39
- * Compatible with Next.js's `usePathname()` from `next/navigation`.
25
+ * On the client, reads from NavigationContext (provided by
26
+ * NavigationProvider in renderRoot). During SSR, reads from the
27
+ * ALS-backed SSR data context. Falls back to window.location.pathname
28
+ * when called outside a React component (e.g., in tests).
40
29
  */
41
30
  export function usePathname(): string {
42
- return useSyncExternalStore(subscribe, getPathname, getServerPathname);
31
+ // Try reading from NavigationContext (client-side, inside React tree).
32
+ // During SSR, no NavigationProvider is mounted, so this returns null.
33
+ try {
34
+ const navContext = useNavigationContext();
35
+ if (navContext !== null) {
36
+ return navContext.pathname;
37
+ }
38
+ } catch {
39
+ // No React dispatcher available (called outside a component).
40
+ // Fall through to SSR/fallback below.
41
+ }
42
+
43
+ // SSR path: read from ALS-backed SSR data context.
44
+ const ssrData = getSsrData();
45
+ if (ssrData) return ssrData.pathname ?? '/';
46
+
47
+ // Final fallback: window.location (tests, edge cases).
48
+ if (typeof window !== 'undefined') return window.location.pathname;
49
+ return '/';
43
50
  }
@@ -7,9 +7,22 @@
7
7
  *
8
8
  * This wraps timber's internal RouterInstance in the Next.js-compatible
9
9
  * AppRouterInstance shape that ecosystem libraries expect.
10
+ *
11
+ * NOTE: Unlike Next.js, these methods do NOT wrap navigation in
12
+ * startTransition. In Next.js, router state is React state (useReducer)
13
+ * so startTransition defers the update and provides isPending tracking.
14
+ * In timber, navigation calls reactRoot.render() which is a root-level
15
+ * render — startTransition has no effect on root renders.
16
+ *
17
+ * Navigation state (params, pathname) is delivered atomically via
18
+ * NavigationContext embedded in the element tree passed to
19
+ * reactRoot.render(). See design/19-client-navigation.md §"NavigationContext".
20
+ *
21
+ * For loading UI during navigation, use:
22
+ * - useLinkStatus() — per-link pending indicator (inside <Link>)
23
+ * - useNavigationPending() — global navigation pending state
10
24
  */
11
25
 
12
- import { startTransition } from 'react';
13
26
  import { getRouterOrNull } from './router-ref.js';
14
27
 
15
28
  export interface AppRouterInstance {
@@ -54,14 +67,7 @@ export function useRouter(): AppRouterInstance {
54
67
  }
55
68
  return;
56
69
  }
57
- // Wrap in startTransition so React 19 tracks the async navigation.
58
- // React 19's startTransition accepts async callbacks — it keeps
59
- // isPending=true until the returned promise resolves. This means
60
- // useTransition's isPending reflects the full RSC fetch + render
61
- // lifecycle when wrapping router.push() in startTransition.
62
- startTransition(async () => {
63
- await router.navigate(href, { scroll: options?.scroll });
64
- });
70
+ void router.navigate(href, { scroll: options?.scroll });
65
71
  },
66
72
  replace(href: string, options?: { scroll?: boolean }) {
67
73
  const router = getRouterOrNull();
@@ -71,9 +77,7 @@ export function useRouter(): AppRouterInstance {
71
77
  }
72
78
  return;
73
79
  }
74
- startTransition(async () => {
75
- await router.navigate(href, { scroll: options?.scroll, replace: true });
76
- });
80
+ void router.navigate(href, { scroll: options?.scroll, replace: true });
77
81
  },
78
82
  refresh() {
79
83
  const router = getRouterOrNull();
@@ -83,9 +87,7 @@ export function useRouter(): AppRouterInstance {
83
87
  }
84
88
  return;
85
89
  }
86
- startTransition(async () => {
87
- await router.refresh();
88
- });
90
+ void router.refresh();
89
91
  },
90
92
  back() {
91
93
  if (typeof window !== 'undefined') window.history.back();