@timber-js/app 0.2.0-alpha.68 → 0.2.0-alpha.69

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.
@@ -18,6 +18,7 @@ import {
18
18
  ServerErrorResponse,
19
19
  VersionSkewError,
20
20
  } from './rsc-fetch.js';
21
+ import { setHardNavigating } from './navigation-root.js';
21
22
  import type { FetchResult } from './rsc-fetch.js';
22
23
 
23
24
  // ─── Types ───────────────────────────────────────────────────────
@@ -90,7 +91,7 @@ export interface RouterDeps {
90
91
  *
91
92
  * The `perform` callback receives a `wrapPayload` function to wrap the
92
93
  * decoded RSC payload with NavigationProvider + NuqsAdapter before
93
- * TransitionRoot sets it as the new element. The `wrapPayload` function
94
+ * NavigationRoot sets it as the new element. The `wrapPayload` function
94
95
  * receives the NavigationState explicitly — no temporal coupling with
95
96
  * getNavigationState().
96
97
  *
@@ -262,7 +263,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
262
263
  routerPhase = next;
263
264
  // Notify external store listeners (non-React consumers).
264
265
  // React-facing pending state is handled by useOptimistic in
265
- // TransitionRoot via navigateTransition — not this function.
266
+ // NavigationRoot via navigateTransition — not this function.
266
267
  for (const listener of pendingListeners) {
267
268
  listener(value);
268
269
  }
@@ -519,7 +520,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
519
520
  } catch (error) {
520
521
  // Version skew — server has been redeployed. Trigger full page reload
521
522
  // so the browser fetches the new bundle. See TIM-446.
523
+ // Set hard-navigating flag to prevent Navigation API interception
524
+ // and React from rendering during page teardown. See TIM-626.
522
525
  if (error instanceof VersionSkewError) {
526
+ setHardNavigating(true);
523
527
  // Import triggerStaleReload dynamically to avoid circular deps
524
528
  // and keep the reload logic centralized with its loop guard.
525
529
  const { triggerStaleReload } = await import('./stale-reload.js');
@@ -538,7 +542,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
538
542
  // Server 5xx error — hard-navigate so the server renders the
539
543
  // error page as HTML. See design/10-error-handling.md
540
544
  // §"Error Page Rendering for Client Navigation".
545
+ //
546
+ // Set hard-navigating flag BEFORE setting window.location.href:
547
+ // 1. Prevents Navigation API from intercepting → infinite loop
548
+ // 2. Causes NavigationRoot to throw unresolvedThenable → prevents
549
+ // React from rendering children during page teardown (avoids
550
+ // "Rendered more hooks" crashes). See TIM-626.
541
551
  if (error instanceof ServerErrorResponse) {
552
+ setHardNavigating(true);
542
553
  window.location.href = error.url;
543
554
  return new Promise(() => {}) as never;
544
555
  }
@@ -546,6 +557,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
546
557
  if (isAbortError(error)) return;
547
558
  throw error;
548
559
  } finally {
560
+ // Clear the abort controller so we don't abort a completed navigation
561
+ // when the next one starts. In dev mode, the RSC body stream stays
562
+ // open after data arrives (React's Flight client waits for debug rows).
563
+ // Aborting a "completed" navigation kills the open stream reader →
564
+ // "BodyStreamBuffer was aborted". By clearing the controller here,
565
+ // createNavAbort() becomes a no-op for completed navigations.
566
+ if (currentNavAbort === navAbort) {
567
+ currentNavAbort = null;
568
+ }
549
569
  setPending(false);
550
570
  // Resolve the Navigation API deferred — clears the browser's native
551
571
  // loading state (tab spinner) at the same time as the TopLoader.
@@ -576,7 +596,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
576
596
  });
577
597
 
578
598
  applyHead(headElements);
599
+ } catch (error) {
600
+ // Stale transition (superseded by a newer navigation) or aborted
601
+ // fetch — silently ignore. See TIM-629.
602
+ if (isAbortError(error)) return;
603
+ throw error;
579
604
  } finally {
605
+ if (currentNavAbort === navAbort) {
606
+ currentNavAbort = null;
607
+ }
580
608
  setPending(false);
581
609
  deps.completeRouterNavigation?.();
582
610
  }
@@ -619,7 +647,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
619
647
 
620
648
  applyHead(headElements);
621
649
  restoreScrollAfterPaint(scrollY);
650
+ } catch (error) {
651
+ // Stale transition (superseded by a newer navigation) or aborted
652
+ // fetch — silently ignore. See TIM-629.
653
+ if (isAbortError(error)) return;
654
+ throw error;
622
655
  } finally {
656
+ if (currentNavAbort === navAbort) {
657
+ currentNavAbort = null;
658
+ }
623
659
  setPending(false);
624
660
  }
625
661
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Shows an animated progress bar at the top of the viewport while an RSC
5
5
  * navigation is in flight. Injected automatically by the framework into
6
- * TransitionRoot — users never render this component directly.
6
+ * NavigationRoot — users never render this component directly.
7
7
  *
8
8
  * Configuration is via timber.config.ts `topLoader` key. Enabled by default.
9
9
  * Users who want a fully custom progress indicator disable the built-in one
@@ -97,7 +97,7 @@ function ensureKeyframes(): void {
97
97
  // ─── Component ───────────────────────────────────────────────────
98
98
 
99
99
  /**
100
- * Internal top-loader component. Injected by TransitionRoot.
100
+ * Internal top-loader component. Injected by NavigationRoot.
101
101
  *
102
102
  * Reads pending navigation state from PendingNavigationContext.
103
103
  * Phase transitions are derived synchronously during render:
@@ -1,7 +1,7 @@
1
1
  // useNavigationPending — returns true while an RSC navigation is in flight.
2
2
  // See design/19-client-navigation.md §"useNavigationPending()"
3
3
  //
4
- // Reads from PendingNavigationContext (provided by TransitionRoot) so the
4
+ // Reads from PendingNavigationContext (provided by NavigationRoot) so the
5
5
  // pending state shows immediately (urgent update) and clears atomically
6
6
  // with the new tree (same startTransition commit).
7
7
 
@@ -34,6 +34,34 @@ import type { InterceptionContext } from './pipeline.js';
34
34
  import { shouldSkipSegment } from './state-tree-diff.js';
35
35
  import { loadModule } from './safe-load.js';
36
36
 
37
+ // ─── Client Reference Detection ──────────────────────────────────────────
38
+
39
+ /**
40
+ * Symbol used by React Flight to mark client references.
41
+ * Client references are proxy objects created by @vitejs/plugin-rsc for
42
+ * 'use client' modules in the RSC environment. They must be passed to
43
+ * createElement() — calling them as functions throws:
44
+ * "Unexpectedly client reference export 'default' is called on server"
45
+ */
46
+ const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference');
47
+
48
+ /**
49
+ * Detect whether a component is a React client reference.
50
+ * Client references have $$typeof set to Symbol.for('react.client.reference')
51
+ * by registerClientReference() in the React Flight server runtime.
52
+ *
53
+ * Used to skip OTEL tracing wrappers that would call the component as a
54
+ * function. Client components must go through createElement only — they are
55
+ * serialized as references in the RSC Flight stream, not executed on the server.
56
+ */
57
+ export function isClientReference(component: unknown): boolean {
58
+ return (
59
+ component != null &&
60
+ typeof component === 'function' &&
61
+ (component as unknown as Record<string, unknown>).$$typeof === CLIENT_REFERENCE_TAG
62
+ );
63
+ }
64
+
37
65
  // ─── Param Coercion Error ─────────────────────────────────────────────────
38
66
 
39
67
  /**
@@ -308,16 +336,25 @@ export async function buildRouteElement(
308
336
  // Build element tree: page wrapped in layouts (innermost to outermost)
309
337
  const h = createElement as (...args: unknown[]) => React.ReactElement;
310
338
 
311
- // Wrap the page component in an OTEL span
312
- const TracedPage = async (props: Record<string, unknown>) => {
313
- return withSpan(
314
- 'timber.page',
315
- { 'timber.route': match.segments[match.segments.length - 1]?.urlPath ?? '/' },
316
- () => (PageComponent as (props: Record<string, unknown>) => unknown)(props)
317
- );
318
- };
319
-
320
- let element = h(TracedPage, {});
339
+ // Build the page element.
340
+ // Client references ('use client' pages) must NOT be called as functions —
341
+ // they are proxy objects that throw when invoked. They must go through
342
+ // createElement only, which serializes them as client references in the
343
+ // RSC Flight stream. OTEL tracing is skipped for client components.
344
+ // See TIM-627 for the original bug.
345
+ let element: React.ReactElement;
346
+ if (isClientReference(PageComponent)) {
347
+ element = h(PageComponent, {});
348
+ } else {
349
+ const TracedPage = async (props: Record<string, unknown>) => {
350
+ return withSpan(
351
+ 'timber.page',
352
+ { 'timber.route': match.segments[match.segments.length - 1]?.urlPath ?? '/' },
353
+ () => (PageComponent as (props: Record<string, unknown>) => unknown)(props)
354
+ );
355
+ };
356
+ element = h(TracedPage, {});
357
+ }
321
358
 
322
359
  // Build a lookup of layout components by segment for O(1) access.
323
360
  const layoutBySegment = new Map(
@@ -429,22 +466,33 @@ export async function buildRouteElement(
429
466
  ? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
430
467
  : segment.urlPath;
431
468
 
432
- // Wrap the layout component in an OTEL span
433
- const layoutComponentRef = layoutComponent;
434
- const TracedLayout = async (props: Record<string, unknown>) => {
435
- return withSpan('timber.layout', { 'timber.segment': segmentId }, () =>
436
- (layoutComponentRef as (props: Record<string, unknown>) => unknown)(props)
437
- );
438
- };
469
+ // Build the layout element.
470
+ // Same client reference guard as pages — client layouts must not be
471
+ // called as functions. OTEL tracing is skipped for client components.
472
+ let layoutElement: React.ReactElement;
473
+ if (isClientReference(layoutComponent)) {
474
+ layoutElement = h(layoutComponent, {
475
+ ...slotProps,
476
+ children: element,
477
+ });
478
+ } else {
479
+ const layoutComponentRef = layoutComponent;
480
+ const TracedLayout = async (props: Record<string, unknown>) => {
481
+ return withSpan('timber.layout', { 'timber.segment': segmentId }, () =>
482
+ (layoutComponentRef as (props: Record<string, unknown>) => unknown)(props)
483
+ );
484
+ };
485
+ layoutElement = h(TracedLayout, {
486
+ ...slotProps,
487
+ children: element,
488
+ });
489
+ }
439
490
 
440
491
  element = h(SegmentProvider, {
441
492
  segments: segmentPath,
442
493
  segmentId,
443
494
  parallelRouteKeys,
444
- children: h(TracedLayout, {
445
- ...slotProps,
446
- children: element,
447
- }),
495
+ children: layoutElement,
448
496
  });
449
497
  }
450
498
  }
@@ -20,6 +20,7 @@ import { TimberErrorBoundary } from '../client/error-boundary.js';
20
20
  import SlotErrorFallback from '../client/slot-error-fallback.js';
21
21
  import { SlotAccessGate } from './access-gate.js';
22
22
  import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
23
+ import { isClientReference } from './route-element-builder.js';
23
24
  import { loadModule } from './safe-load.js';
24
25
  import type { InterceptionContext, RouteMatch } from './pipeline.js';
25
26
  import { DenySignal, RedirectSignal } from './primitives.js';
@@ -177,44 +178,45 @@ export async function resolveSlotElement(
177
178
  // §"Slot Access Failure = Graceful Degradation"
178
179
  const denyFallback = await renderDefaultFallback(slotNode, h);
179
180
 
180
- // Wrap the slot page to catch ALL errors at the component level.
181
- // This prevents errors from leaving unresolved Flight rows in the
182
- // RSC stream when a slot component throws and the error propagates
183
- // to React's Flight renderer, it may not emit a resolution row for
184
- // the slot's lazy reference. The client's createFromReadableStream
185
- // then throws "Connection closed" when the stream ends with pending
186
- // references. By catching all errors here and returning a fallback,
187
- // React sees a resolved component and emits a proper Flight row.
181
+ // Build the slot page element.
182
+ // Client references ('use client' pages) must NOT be called as functions —
183
+ // they are proxy objects that throw when invoked. For client references,
184
+ // use createElement directly. Error catching is unnecessary because client
185
+ // references are serialized as references in the RSC Flight stream they
186
+ // don't execute on the server. See TIM-627.
188
187
  //
189
- // DenySignal (from notFound() or deny()) returns the deny fallback.
190
- // All other errors return the deny fallback or null — the slot
191
- // gracefully degrades rather than breaking the entire page.
192
- // See TIM-524.
193
- const SafeSlotPage = async (props: Record<string, unknown>) => {
194
- try {
195
- return await (SlotPage as (props: Record<string, unknown>) => unknown)(props);
196
- } catch (error) {
197
- // RedirectSignal must propagate — the pipeline handles redirects
198
- // at the top level. Swallowing it here would silently return
199
- // fallback content instead of redirecting. See TIM-554.
200
- if (error instanceof RedirectSignal) {
201
- throw error;
202
- }
203
- if (error instanceof DenySignal) {
188
+ // For server components, wrap in SafeSlotPage to catch ALL errors at the
189
+ // component level. This prevents errors from leaving unresolved Flight
190
+ // rows in the RSC stream see TIM-524 for details.
191
+ let element: React.ReactElement;
192
+ if (isClientReference(SlotPage)) {
193
+ element = h(SlotPage, {});
194
+ } else {
195
+ const SafeSlotPage = async (props: Record<string, unknown>) => {
196
+ try {
197
+ return await (SlotPage as (props: Record<string, unknown>) => unknown)(props);
198
+ } catch (error) {
199
+ // RedirectSignal must propagate — the pipeline handles redirects
200
+ // at the top level. Swallowing it here would silently return
201
+ // fallback content instead of redirecting. See TIM-554.
202
+ if (error instanceof RedirectSignal) {
203
+ throw error;
204
+ }
205
+ if (error instanceof DenySignal) {
206
+ return denyFallback;
207
+ }
208
+ // Log the error but don't re-throw — returning fallback ensures
209
+ // the Flight row is resolved and the page hydrates correctly.
210
+ logRenderError({
211
+ method: '',
212
+ path: '',
213
+ error,
214
+ });
204
215
  return denyFallback;
205
216
  }
206
- // Log the error but don't re-throw — returning fallback ensures
207
- // the Flight row is resolved and the page hydrates correctly.
208
- logRenderError({
209
- method: '',
210
- path: '',
211
- error,
212
- });
213
- return denyFallback;
214
- }
215
- };
216
-
217
- let element: React.ReactElement = h(SafeSlotPage, {});
217
+ };
218
+ element = h(SafeSlotPage, {});
219
+ }
218
220
 
219
221
  // Wrap with error boundaries and layouts from intermediate slot segments
220
222
  // (everything between slot root and leaf). Process innermost-first, same
@@ -227,7 +227,7 @@ export async function handleSsr(
227
227
  const _decodeEnd = performance.now();
228
228
 
229
229
  // Wrap with the same component tree structure as the client hydration
230
- // tree (TransitionRoot → PendingNavigationProvider → TopLoader →
230
+ // tree (NavigationRoot → PendingNavigationProvider → TopLoader →
231
231
  // TimberNuqsAdapter → NuqsAdapterProvider → NavigationProvider).
232
232
  // This ensures useId() produces matching IDs on both sides, preventing
233
233
  // hydration mismatches in libraries like Radix UI. See TIM-532.
@@ -8,7 +8,7 @@
8
8
  * Radix UI that rely on useId() internally.
9
9
  *
10
10
  * The client tree (browser-entry.ts) wraps the RSC element with:
11
- * TransitionRoot → PendingNavigationProvider → Fragment(TopLoader, ...) →
11
+ * NavigationRoot → PendingNavigationProvider → Fragment(TopLoader, ...) →
12
12
  * TimberNuqsAdapter → NuqsAdapterProvider → NavigationProvider → element
13
13
  *
14
14
  * The SSR tree must produce the same component boundaries. These wrappers
@@ -23,15 +23,15 @@ import { createElement, Fragment, type ReactNode } from 'react';
23
23
  import { withNuqsSsrAdapter } from './nuqs-ssr-provider.js';
24
24
 
25
25
  /**
26
- * SSR equivalent of TransitionRoot.
26
+ * SSR equivalent of NavigationRoot.
27
27
  *
28
- * On the client, TransitionRoot uses useState + useTransition and renders:
28
+ * On the client, NavigationRoot uses useState and standalone startTransition, rendering:
29
29
  * PendingNavigationProvider(Fragment(TopLoader, element))
30
30
  *
31
31
  * This SSR version matches the component boundary depth without client
32
32
  * hooks. It renders SsrPendingProvider → Fragment(SsrTopLoader, children).
33
33
  */
34
- function SsrTransitionRoot({
34
+ function SsrNavigationRoot({
35
35
  children,
36
36
  hasTopLoader,
37
37
  }: {
@@ -97,7 +97,7 @@ function SsrNuqsWrapper({
97
97
  * on both sides.
98
98
  *
99
99
  * Client tree (browser-entry.ts):
100
- * TransitionRoot
100
+ * NavigationRoot
101
101
  * → PendingNavigationProvider
102
102
  * → Fragment(TopLoader, element)
103
103
  * → TimberNuqsAdapter
@@ -106,7 +106,7 @@ function SsrNuqsWrapper({
106
106
  * → [RSC element]
107
107
  *
108
108
  * SSR tree (this function):
109
- * SsrTransitionRoot
109
+ * SsrNavigationRoot
110
110
  * → SsrPendingProvider
111
111
  * → Fragment(SsrTopLoader, element)
112
112
  * → SsrNuqsWrapper
@@ -125,8 +125,8 @@ export function wrapSsrElement(
125
125
  ): ReactNode {
126
126
  // Build inside-out to match the client's createElement chain:
127
127
  // NavigationProvider(TimberNuqsAdapter(element))
128
- // → passed as initial to TransitionRoot
129
- // → TransitionRoot renders PendingNavigationProvider(Fragment(TopLoader, initial))
128
+ // → passed as initial to NavigationRoot
129
+ // → NavigationRoot renders PendingNavigationProvider(Fragment(TopLoader, initial))
130
130
 
131
131
  // 1. Innermost: NavigationProvider equivalent
132
132
  const withNav = createElement(SsrNavigationProvider, null, element);
@@ -134,6 +134,6 @@ export function wrapSsrElement(
134
134
  // 2. TimberNuqsAdapter equivalent (wraps withNuqsSsrAdapter for the actual nuqs provider)
135
135
  const withNuqs = createElement(SsrNuqsWrapper, { searchParams, children: withNav });
136
136
 
137
- // 3. Outermost: TransitionRoot equivalent (PendingNavigationProvider + TopLoader)
138
- return createElement(SsrTransitionRoot, { hasTopLoader, children: withNuqs });
137
+ // 3. Outermost: NavigationRoot equivalent (PendingNavigationProvider + TopLoader)
138
+ return createElement(SsrNavigationRoot, { hasTopLoader, children: withNuqs });
139
139
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"transition-root.d.ts","sourceRoot":"","sources":["../../src/client/transition-root.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAEzF,OAAO,EAAa,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAsBlE;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,eAAe,GAChB,EAAE;IACD,OAAO,EAAE,SAAS,CAAC;IACnB,eAAe,CAAC,EAAE,eAAe,CAAC;CACnC,GAAG,SAAS,CA2DZ;AAID;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,GAAG,IAAI,CAIzD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,OAAO,CAAC,SAAS,CAAC,GAChC,OAAO,CAAC,IAAI,CAAC,CAMf;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,IAAI,OAAO,CAE/C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CAAC,cAAc,EAAE,CAAC,OAAO,EAAE,SAAS,KAAK,IAAI,GAAG,IAAI,CAc5F"}
@@ -1,205 +0,0 @@
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
- * Also manages `pendingUrl` as React state with an urgent/transition split:
15
- * - Navigation START: `setPendingUrl(url)` is an urgent update — React
16
- * commits it before the next paint, showing the spinner immediately.
17
- * - Navigation END: `setPendingUrl(null)` is inside `startTransition`
18
- * alongside `setElement(newTree)` — both commit atomically, so the
19
- * spinner disappears in the same frame as the new content appears.
20
- *
21
- * See design/05-streaming.md §"deferSuspenseFor"
22
- * See design/19-client-navigation.md §"NavigationContext"
23
- */
24
-
25
- import { useState, useTransition, createElement, Fragment, type ReactNode } from 'react';
26
- import { PendingNavigationProvider } from './navigation-context.js';
27
- import { TopLoader, type TopLoaderConfig } from './top-loader.js';
28
- import { getCurrentNavId, resetLinkPending } from './link-pending-store.js';
29
-
30
- // ─── Module-level functions ──────────────────────────────────────
31
-
32
- /**
33
- * Module-level reference to the state setter wrapped in startTransition.
34
- * Used for non-navigation renders (applyRevalidation, popstate replay).
35
- */
36
- let _transitionRender: ((element: ReactNode) => void) | null = null;
37
-
38
- /**
39
- * Module-level reference to the navigation transition function.
40
- * Wraps a full navigation (fetch + render) in a single startTransition
41
- * with useOptimistic for the pending URL.
42
- */
43
- let _navigateTransition:
44
- | ((pendingUrl: string, perform: () => Promise<ReactNode>) => Promise<void>)
45
- | null = null;
46
-
47
- // ─── Component ───────────────────────────────────────────────────
48
-
49
- /**
50
- * Root wrapper component that enables transition-based rendering.
51
- *
52
- * Renders PendingNavigationProvider around children for the pending URL
53
- * context. The DOM tree matches the server-rendered HTML during hydration
54
- * (the provider renders no extra DOM elements).
55
- *
56
- * Usage in browser-entry.ts:
57
- * const rootEl = createElement(TransitionRoot, { initial: wrapped });
58
- * reactRoot = hydrateRoot(document, rootEl);
59
- *
60
- * Subsequent navigations:
61
- * navigateTransition(url, async () => { fetch; return wrappedElement; });
62
- *
63
- * Non-navigation renders:
64
- * transitionRender(newWrappedElement);
65
- */
66
- export function TransitionRoot({
67
- initial,
68
- topLoaderConfig,
69
- }: {
70
- initial: ReactNode;
71
- topLoaderConfig?: TopLoaderConfig;
72
- }): ReactNode {
73
- const [element, setElement] = useState<ReactNode>(initial);
74
- const [pendingUrl, setPendingUrl] = useState<string | null>(null);
75
- const [, startTransition] = useTransition();
76
-
77
- // Non-navigation render (revalidation, popstate cached replay).
78
- _transitionRender = (newElement: ReactNode) => {
79
- startTransition(() => {
80
- setElement(newElement);
81
- });
82
- };
83
-
84
- // Full navigation transition.
85
- // setPendingUrl(url) is an URGENT update — React commits it before the next
86
- // paint, so the pending spinner appears immediately when navigation starts.
87
- // Inside startTransition: the async fetch + setElement + setPendingUrl(null)
88
- // are deferred. When the transition commits, the new tree and pendingUrl=null
89
- // both apply in the same React commit — making the pending→active transition
90
- // atomic (no frame where pending is false but the old tree is still visible).
91
- _navigateTransition = (url: string, perform: () => Promise<ReactNode>) => {
92
- // Urgent: show pending state immediately (for TopLoader / useNavigationPending)
93
- setPendingUrl(url);
94
-
95
- return new Promise<void>((resolve, reject) => {
96
- startTransition(async () => {
97
- // Capture the current nav ID before async work begins.
98
- // Used to guard against stale clears when a newer navigation
99
- // supersedes this one.
100
- const navId = getCurrentNavId();
101
- try {
102
- const newElement = await perform();
103
- setElement(newElement);
104
- // Clear pending inside the transition — commits atomically with new tree
105
- setPendingUrl(null);
106
- // Reset per-link pending state. The navId guard ensures a stale
107
- // transition (T1) doesn't clear a newer navigation's (T2) link.
108
- // The setter call is a transition update — batched with setElement
109
- // and setPendingUrl, so pending clears atomically with new tree.
110
- // See design/19-client-navigation.md §"Per-Link Pending State"
111
- resetLinkPending(navId);
112
- resolve();
113
- } catch (err) {
114
- // Clear pending on error too
115
- setPendingUrl(null);
116
- resetLinkPending(navId);
117
- reject(err);
118
- }
119
- });
120
- });
121
- };
122
-
123
- // Inject TopLoader alongside the element tree inside PendingNavigationProvider.
124
- // The TopLoader reads pendingUrl from context to show/hide the progress bar.
125
- // It is rendered only when not explicitly disabled via config.
126
- const showTopLoader = topLoaderConfig?.enabled !== false;
127
- const children = showTopLoader
128
- ? createElement(Fragment, null, createElement(TopLoader, { config: topLoaderConfig }), element)
129
- : element;
130
- return createElement(PendingNavigationProvider, { value: pendingUrl }, children);
131
- }
132
-
133
- // ─── Public API ──────────────────────────────────────────────────
134
-
135
- /**
136
- * Trigger a transition render for non-navigation updates.
137
- * React keeps the old committed tree visible while any new Suspense
138
- * boundaries in the update resolve.
139
- *
140
- * Used for: applyRevalidation, popstate replay with cached payload.
141
- */
142
- export function transitionRender(element: ReactNode): void {
143
- if (_transitionRender) {
144
- _transitionRender(element);
145
- }
146
- }
147
-
148
- /**
149
- * Run a full navigation inside a React transition with optimistic pending URL.
150
- *
151
- * The `perform` callback runs inside `startTransition` — it should fetch the
152
- * RSC payload, update router state, and return the wrapped React element.
153
- * The pending URL shows immediately (useOptimistic urgent update) and reverts
154
- * to null when the transition commits (atomic with the new tree).
155
- *
156
- * Returns a Promise that resolves when the async work completes (note: the
157
- * React transition may not have committed yet, but all state updates are done).
158
- *
159
- * Used for: navigate(), refresh(), popstate with fetch.
160
- */
161
- export function navigateTransition(
162
- pendingUrl: string,
163
- perform: () => Promise<ReactNode>
164
- ): Promise<void> {
165
- if (_navigateTransition) {
166
- return _navigateTransition(pendingUrl, perform);
167
- }
168
- // Fallback: no TransitionRoot mounted (shouldn't happen in production)
169
- return perform().then(() => {});
170
- }
171
-
172
- /**
173
- * Check if the TransitionRoot is mounted and ready for renders.
174
- * Used by browser-entry.ts to guard against renders before hydration.
175
- */
176
- export function isTransitionRootReady(): boolean {
177
- return _transitionRender !== null;
178
- }
179
-
180
- /**
181
- * Install one-shot deferred callbacks for the no-RSC bootstrap path (TIM-600).
182
- *
183
- * When there's no RSC payload, we can't create a React root immediately —
184
- * `createRoot(document).render(...)` would blank the SSR HTML. Instead,
185
- * this sets up `_transitionRender` and `_navigateTransition` so that the
186
- * first client navigation triggers root creation via `createAndMount`.
187
- *
188
- * After `createAndMount` runs, TransitionRoot renders and overwrites these
189
- * callbacks with its real `startTransition`-based implementations.
190
- */
191
- export function installDeferredNavigation(createAndMount: (initial: ReactNode) => void): void {
192
- let mounted = false;
193
- const mountOnce = (element: ReactNode) => {
194
- if (mounted) return;
195
- mounted = true;
196
- createAndMount(element);
197
- };
198
- _transitionRender = (element: ReactNode) => {
199
- mountOnce(element);
200
- };
201
- _navigateTransition = async (_pendingUrl: string, perform: () => Promise<ReactNode>) => {
202
- const element = await perform();
203
- mountOnce(element);
204
- };
205
- }