@timber-js/app 0.2.0-alpha.67 → 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.
Files changed (51) hide show
  1. package/LICENSE +8 -0
  2. package/dist/client/history.d.ts +19 -4
  3. package/dist/client/history.d.ts.map +1 -1
  4. package/dist/client/index.js +321 -167
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/client/link-pending-store.d.ts +3 -3
  7. package/dist/client/link.d.ts.map +1 -1
  8. package/dist/client/nav-link-store.d.ts +36 -0
  9. package/dist/client/nav-link-store.d.ts.map +1 -0
  10. package/dist/client/navigation-api-types.d.ts +90 -0
  11. package/dist/client/navigation-api-types.d.ts.map +1 -0
  12. package/dist/client/navigation-api.d.ts +115 -0
  13. package/dist/client/navigation-api.d.ts.map +1 -0
  14. package/dist/client/navigation-context.d.ts +11 -0
  15. package/dist/client/navigation-context.d.ts.map +1 -1
  16. package/dist/client/{transition-root.d.ts → navigation-root.d.ts} +31 -9
  17. package/dist/client/navigation-root.d.ts.map +1 -0
  18. package/dist/client/nuqs-adapter.d.ts.map +1 -1
  19. package/dist/client/router.d.ts +46 -2
  20. package/dist/client/router.d.ts.map +1 -1
  21. package/dist/client/rsc-fetch.d.ts +1 -1
  22. package/dist/client/rsc-fetch.d.ts.map +1 -1
  23. package/dist/client/top-loader.d.ts +2 -2
  24. package/dist/client/top-loader.d.ts.map +1 -1
  25. package/dist/server/index.js.map +1 -1
  26. package/dist/server/route-element-builder.d.ts +10 -0
  27. package/dist/server/route-element-builder.d.ts.map +1 -1
  28. package/dist/server/slot-resolver.d.ts.map +1 -1
  29. package/dist/server/ssr-wrappers.d.ts +3 -3
  30. package/package.json +6 -7
  31. package/src/cli.ts +0 -0
  32. package/src/client/browser-entry.ts +92 -19
  33. package/src/client/history.ts +26 -4
  34. package/src/client/link-pending-store.ts +3 -3
  35. package/src/client/link.tsx +31 -9
  36. package/src/client/nav-link-store.ts +47 -0
  37. package/src/client/navigation-api-types.ts +112 -0
  38. package/src/client/navigation-api.ts +315 -0
  39. package/src/client/navigation-context.ts +22 -2
  40. package/src/client/navigation-root.tsx +346 -0
  41. package/src/client/nuqs-adapter.tsx +16 -3
  42. package/src/client/router.ts +186 -18
  43. package/src/client/rsc-fetch.ts +4 -3
  44. package/src/client/top-loader.tsx +12 -4
  45. package/src/client/use-navigation-pending.ts +1 -1
  46. package/src/server/route-element-builder.ts +69 -21
  47. package/src/server/slot-resolver.ts +37 -35
  48. package/src/server/ssr-entry.ts +1 -1
  49. package/src/server/ssr-wrappers.tsx +10 -10
  50. package/dist/client/transition-root.d.ts.map +0 -1
  51. package/src/client/transition-root.tsx +0 -205
@@ -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 ───────────────────────────────────────────────────────
@@ -27,6 +28,19 @@ export interface NavigationOptions {
27
28
  scroll?: boolean;
28
29
  /** Use replaceState instead of pushState (replaces current history entry) */
29
30
  replace?: boolean;
31
+ /**
32
+ * @internal AbortSignal from the Navigation API's NavigateEvent.
33
+ * When provided, the signal is linked to the router's per-navigation
34
+ * AbortController so in-flight RSC fetches are cancelled when a new
35
+ * navigation starts.
36
+ */
37
+ _signal?: AbortSignal;
38
+ /**
39
+ * @internal Skip pushState/replaceState — the Navigation API has already
40
+ * updated the URL via event.intercept(). Used for external navigations
41
+ * intercepted by the navigate event handler.
42
+ */
43
+ _skipHistory?: boolean;
30
44
  }
31
45
 
32
46
  /**
@@ -77,7 +91,7 @@ export interface RouterDeps {
77
91
  *
78
92
  * The `perform` callback receives a `wrapPayload` function to wrap the
79
93
  * decoded RSC payload with NavigationProvider + NuqsAdapter before
80
- * TransitionRoot sets it as the new element. The `wrapPayload` function
94
+ * NavigationRoot sets it as the new element. The `wrapPayload` function
81
95
  * receives the NavigationState explicitly — no temporal coupling with
82
96
  * getNavigationState().
83
97
  *
@@ -89,6 +103,42 @@ export interface RouterDeps {
89
103
  wrapPayload: (payload: unknown, navState: NavigationState) => unknown
90
104
  ) => Promise<unknown>
91
105
  ) => Promise<void>;
106
+
107
+ /**
108
+ * Whether the Navigation API is active and handling traversals.
109
+ * When true, the popstate handler is a no-op — the Navigation API's
110
+ * navigate event covers back/forward button presses.
111
+ */
112
+ navigationApiActive?: boolean;
113
+
114
+ /**
115
+ * Called around pushState/replaceState to set a flag that prevents
116
+ * the Navigation API's navigate listener from double-handling
117
+ * router-initiated navigations.
118
+ */
119
+ setRouterNavigating?: (value: boolean) => void;
120
+
121
+ /**
122
+ * Save scroll position via the Navigation API's per-entry state.
123
+ * When provided, used instead of history.replaceState for scroll storage.
124
+ */
125
+ saveNavigationEntryScroll?: (scrollY: number) => void;
126
+
127
+ /**
128
+ * Signal that a router-initiated navigation has completed. Resolves the
129
+ * deferred promise that ties the browser's native loading state to the
130
+ * navigation lifecycle. Called in the finally block of navigate/refresh,
131
+ * aligned with when the TopLoader's pendingUrl clears.
132
+ */
133
+ completeRouterNavigation?: () => void;
134
+
135
+ /**
136
+ * Initiate a navigation via the Navigation API (`navigation.navigate()`).
137
+ * Fires the navigate event BEFORE committing the URL, allowing Chrome
138
+ * to show its native loading indicator. Falls back to pushState when
139
+ * unavailable.
140
+ */
141
+ navigationNavigate?: (url: string, replace: boolean) => void;
92
142
  }
93
143
 
94
144
  export interface RouterInstance {
@@ -97,7 +147,7 @@ export interface RouterInstance {
97
147
  /** Full re-render of the current URL — no state tree sent */
98
148
  refresh(): Promise<void>;
99
149
  /** Handle a popstate event (back/forward button). scrollY is read from history.state. */
100
- handlePopState(url: string, scrollY?: number): Promise<void>;
150
+ handlePopState(url: string, scrollY?: number, externalSignal?: AbortSignal): Promise<void>;
101
151
  /** Whether a navigation is currently in flight */
102
152
  isPending(): boolean;
103
153
  /** The URL currently being navigated to, or null if idle */
@@ -167,6 +217,36 @@ export function createRouter(deps: RouterDeps): RouterInstance {
167
217
  let routerPhase: RouterPhase = { phase: 'idle' };
168
218
  const pendingListeners = new Set<(pending: boolean) => void>();
169
219
 
220
+ // AbortController for the current in-flight navigation.
221
+ // When a new navigation starts, the previous controller is aborted,
222
+ // cancelling any in-progress RSC fetch. This provides automatic
223
+ // cancellation of stale fetches regardless of Navigation API support.
224
+ let currentNavAbort: AbortController | null = null;
225
+
226
+ /**
227
+ * Create a new AbortController for a navigation, aborting any
228
+ * previous in-flight navigation. Optionally links to an external
229
+ * signal (e.g., from the Navigation API's NavigateEvent.signal).
230
+ */
231
+ function createNavAbort(externalSignal?: AbortSignal): AbortController {
232
+ // Abort previous navigation's fetch
233
+ currentNavAbort?.abort();
234
+ const controller = new AbortController();
235
+ currentNavAbort = controller;
236
+
237
+ // If an external signal is provided (e.g., Navigation API),
238
+ // forward its abort to our controller.
239
+ if (externalSignal) {
240
+ if (externalSignal.aborted) {
241
+ controller.abort();
242
+ } else {
243
+ externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
244
+ }
245
+ }
246
+
247
+ return controller;
248
+ }
249
+
170
250
  function setPending(value: boolean, url?: string): void {
171
251
  const next: RouterPhase =
172
252
  value && url ? { phase: 'navigating', targetUrl: url } : { phase: 'idle' };
@@ -183,7 +263,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
183
263
  routerPhase = next;
184
264
  // Notify external store listeners (non-React consumers).
185
265
  // React-facing pending state is handled by useOptimistic in
186
- // TransitionRoot via navigateTransition — not this function.
266
+ // NavigationRoot via navigateTransition — not this function.
187
267
  for (const listener of pendingListeners) {
188
268
  listener(value);
189
269
  }
@@ -326,7 +406,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
326
406
  */
327
407
  async function performNavigationFetch(
328
408
  url: string,
329
- options: { replace: boolean }
409
+ options: { replace: boolean; signal?: AbortSignal; skipHistory?: boolean }
330
410
  ): Promise<FetchResult & { navState: NavigationState }> {
331
411
  // Check prefetch cache first. PrefetchResult has optional segmentInfo/params
332
412
  // fields — normalize to null for FetchResult compatibility.
@@ -349,14 +429,21 @@ export function createRouter(deps: RouterDeps): RouterInstance {
349
429
  const currentUrl = rawCurrentUrl.startsWith('http')
350
430
  ? new URL(rawCurrentUrl).pathname
351
431
  : new URL(rawCurrentUrl, 'http://localhost').pathname;
352
- result = await fetchRscPayload(url, deps, stateTree, currentUrl);
432
+ result = await fetchRscPayload(url, deps, stateTree, currentUrl, options.signal);
353
433
  }
354
434
 
355
- // Update the browser history — replace mode overwrites the current entry
356
- if (options.replace) {
357
- deps.replaceState({ timber: true, scrollY: 0 }, '', url);
358
- } else {
359
- deps.pushState({ timber: true, scrollY: 0 }, '', url);
435
+ // Update the browser history — skip when the Navigation API has already
436
+ // updated the URL via event.intercept() (external navigations).
437
+ if (!options.skipHistory) {
438
+ // Set the router-navigating flag so the Navigation API's navigate
439
+ // listener doesn't double-intercept this pushState/replaceState.
440
+ deps.setRouterNavigating?.(true);
441
+ if (options.replace) {
442
+ deps.replaceState({ timber: true, scrollY: 0 }, '', url);
443
+ } else {
444
+ deps.pushState({ timber: true, scrollY: 0 }, '', url);
445
+ }
446
+ deps.setRouterNavigating?.(false);
360
447
  }
361
448
 
362
449
  // NOTE: History push is deferred — the merged payload (after segment
@@ -376,20 +463,48 @@ export function createRouter(deps: RouterDeps): RouterInstance {
376
463
  async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
377
464
  const scroll = options.scroll !== false;
378
465
  const replace = options.replace === true;
466
+ const externalSignal = options._signal as AbortSignal | undefined;
467
+ const skipHistory = options._skipHistory === true;
468
+
469
+ // Create an abort controller for this navigation. Links to the external
470
+ // signal (Navigation API's event.signal) when provided.
471
+ const navAbort = createNavAbort(externalSignal);
379
472
 
380
473
  // Capture the departing page's scroll position for scroll={false} preservation.
381
474
  const currentScrollY = deps.getScrollY();
382
475
 
383
- // Save the departing page's scroll position in history.state before
384
- // pushing a new entry. This ensures back/forward navigation can restore
385
- // the correct scroll position from the browser's per-entry state.
386
- deps.replaceState({ timber: true, scrollY: currentScrollY }, '', deps.getCurrentUrl());
476
+ // Save the departing page's scroll position use Navigation API entry
477
+ // state when available, otherwise fall back to history.state.
478
+ if (deps.saveNavigationEntryScroll) {
479
+ deps.saveNavigationEntryScroll(currentScrollY);
480
+ } else {
481
+ deps.replaceState({ timber: true, scrollY: currentScrollY }, '', deps.getCurrentUrl());
482
+ }
483
+
484
+ // When Navigation API is active, initiate the navigation via
485
+ // navigation.navigate() BEFORE the fetch. Unlike history.pushState()
486
+ // which commits the URL synchronously (so Chrome sees it as "done"),
487
+ // navigation.navigate() fires the navigate event before committing.
488
+ // Our handler intercepts with a deferred promise, and Chrome shows
489
+ // its native loading indicator until completeRouterNavigation()
490
+ // resolves it in the finally block (same time as TopLoader clears).
491
+ let effectiveSkipHistory = skipHistory;
492
+ if (!skipHistory && deps.navigationNavigate) {
493
+ deps.setRouterNavigating?.(true);
494
+ deps.navigationNavigate(url, replace);
495
+ deps.setRouterNavigating?.(false);
496
+ effectiveSkipHistory = true;
497
+ }
387
498
 
388
499
  setPending(true, url);
389
500
 
390
501
  try {
391
502
  const headElements = await renderViaTransition(url, () =>
392
- performNavigationFetch(url, { replace })
503
+ performNavigationFetch(url, {
504
+ replace,
505
+ signal: navAbort.signal,
506
+ skipHistory: effectiveSkipHistory,
507
+ })
393
508
  );
394
509
 
395
510
  // Update document.title and <meta> tags with the new page's metadata
@@ -405,7 +520,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
405
520
  } catch (error) {
406
521
  // Version skew — server has been redeployed. Trigger full page reload
407
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.
408
525
  if (error instanceof VersionSkewError) {
526
+ setHardNavigating(true);
409
527
  // Import triggerStaleReload dynamically to avoid circular deps
410
528
  // and keep the reload logic centralized with its loop guard.
411
529
  const { triggerStaleReload } = await import('./stale-reload.js');
@@ -414,15 +532,24 @@ export function createRouter(deps: RouterDeps): RouterInstance {
414
532
  return new Promise(() => {}) as never;
415
533
  }
416
534
  // Server-side redirect during RSC fetch → soft router navigation.
535
+ // The redirect navigate will push/replace its own URL.
417
536
  if (error instanceof RedirectError) {
418
537
  setPending(false);
538
+ deps.completeRouterNavigation?.();
419
539
  await navigate(error.redirectUrl, { replace: true });
420
540
  return;
421
541
  }
422
542
  // Server 5xx error — hard-navigate so the server renders the
423
543
  // error page as HTML. See design/10-error-handling.md
424
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.
425
551
  if (error instanceof ServerErrorResponse) {
552
+ setHardNavigating(true);
426
553
  window.location.href = error.url;
427
554
  return new Promise(() => {}) as never;
428
555
  }
@@ -430,19 +557,38 @@ export function createRouter(deps: RouterDeps): RouterInstance {
430
557
  if (isAbortError(error)) return;
431
558
  throw error;
432
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
+ }
433
569
  setPending(false);
570
+ // Resolve the Navigation API deferred — clears the browser's native
571
+ // loading state (tab spinner) at the same time as the TopLoader.
572
+ deps.completeRouterNavigation?.();
434
573
  }
435
574
  }
436
575
 
437
576
  async function refresh(): Promise<void> {
438
577
  const currentUrl = deps.getCurrentUrl();
578
+ const navAbort = createNavAbort();
439
579
 
440
580
  setPending(true, currentUrl);
441
581
 
442
582
  try {
443
583
  const headElements = await renderViaTransition(currentUrl, async () => {
444
584
  // No state tree sent — server renders the complete RSC payload
445
- const result = await fetchRscPayload(currentUrl, deps);
585
+ const result = await fetchRscPayload(
586
+ currentUrl,
587
+ deps,
588
+ undefined,
589
+ undefined,
590
+ navAbort.signal
591
+ );
446
592
  // History push handled by renderViaTransition (stores merged payload)
447
593
  updateSegmentCache(result.segmentInfo);
448
594
  const navState = updateNavigationState(result.params, currentUrl);
@@ -450,12 +596,25 @@ export function createRouter(deps: RouterDeps): RouterInstance {
450
596
  });
451
597
 
452
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;
453
604
  } finally {
605
+ if (currentNavAbort === navAbort) {
606
+ currentNavAbort = null;
607
+ }
454
608
  setPending(false);
609
+ deps.completeRouterNavigation?.();
455
610
  }
456
611
  }
457
612
 
458
- async function handlePopState(url: string, scrollY: number = 0): Promise<void> {
613
+ async function handlePopState(
614
+ url: string,
615
+ scrollY: number = 0,
616
+ externalSignal?: AbortSignal
617
+ ): Promise<void> {
459
618
  // Scroll position is read from history.state by the caller (browser-entry.ts)
460
619
  // and passed in. This is more reliable than tracking scroll per-URL in memory
461
620
  // because the browser maintains per-entry state even with duplicate URLs.
@@ -472,13 +631,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
472
631
  // This happens when navigating back to the initial SSR'd page
473
632
  // (its payload is null since it was rendered via SSR, not RSC fetch)
474
633
  // or when the entry doesn't exist at all.
634
+ const navAbort = createNavAbort(externalSignal);
475
635
  setPending(true, url);
476
636
  try {
477
637
  const headElements = await renderViaTransition(url, async () => {
478
638
  const stateTree = segmentCache.serializeStateTree(
479
639
  segmentElementCache.getMergeablePaths()
480
640
  );
481
- const result = await fetchRscPayload(url, deps, stateTree);
641
+ const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
482
642
  updateSegmentCache(result.segmentInfo);
483
643
  const navState = updateNavigationState(result.params, url);
484
644
  // History push handled by renderViaTransition (stores merged payload)
@@ -487,7 +647,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
487
647
 
488
648
  applyHead(headElements);
489
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;
490
655
  } finally {
656
+ if (currentNavAbort === navAbort) {
657
+ currentNavAbort = null;
658
+ }
491
659
  setPending(false);
492
660
  }
493
661
  }
@@ -249,7 +249,8 @@ export async function fetchRscPayload(
249
249
  url: string,
250
250
  deps: RouterDeps,
251
251
  stateTree?: { segments: string[] },
252
- currentUrl?: string
252
+ currentUrl?: string,
253
+ signal?: AbortSignal
253
254
  ): Promise<FetchResult> {
254
255
  const rscUrl = appendRscParam(url);
255
256
  const headers = buildRscHeaders(stateTree, currentUrl);
@@ -260,7 +261,7 @@ export async function fetchRscPayload(
260
261
  //
261
262
  // Intercept the response to read X-Timber-Head before createFromFetch
262
263
  // consumes the body. Reading headers does NOT consume the body stream.
263
- const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual' });
264
+ const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
264
265
  let headElements: HeadElement[] | null = null;
265
266
  let segmentInfo: SegmentInfo[] | null = null;
266
267
  let params: Record<string, string | string[]> | null = null;
@@ -303,7 +304,7 @@ export async function fetchRscPayload(
303
304
  return { payload, headElements, segmentInfo, params, skippedSegments };
304
305
  }
305
306
  // Test/fallback path: return raw text
306
- const response = await deps.fetch(rscUrl, { headers, redirect: 'manual' });
307
+ const response = await deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
307
308
  // Check for redirect in test path too
308
309
  if (response.status >= 300 && response.status < 400) {
309
310
  const location = response.headers.get('Location');
@@ -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
@@ -28,7 +28,7 @@
28
28
  'use client';
29
29
 
30
30
  import { useState, createElement } from 'react';
31
- import { usePendingNavigationUrl } from './navigation-context.js';
31
+ import { usePendingNavigationUrl, hasNativeNavigationTransition } from './navigation-context.js';
32
32
 
33
33
  // ─── Types ───────────────────────────────────────────────────────
34
34
 
@@ -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:
@@ -112,7 +112,15 @@ function ensureKeyframes(): void {
112
112
  */
113
113
  export function TopLoader({ config }: { config?: TopLoaderConfig }): React.ReactElement | null {
114
114
  const pendingUrl = usePendingNavigationUrl();
115
- const isPending = pendingUrl !== null;
115
+ // Navigation is pending when either:
116
+ // 1. Our React-based pending URL is set (standard path), OR
117
+ // 2. The Navigation API has an active transition (external navigations
118
+ // intercepted by the navigate event that haven't completed yet).
119
+ // In practice these are almost always in sync — the Navigation API
120
+ // transition is active while our pendingUrl is set. This check ensures
121
+ // the top-loader also shows for external navigations caught by the
122
+ // Navigation API before our React state updates.
123
+ const isPending = pendingUrl !== null || hasNativeNavigationTransition();
116
124
 
117
125
  const color = config?.color ?? DEFAULT_COLOR;
118
126
  const height = config?.height ?? DEFAULT_HEIGHT;
@@ -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.