@timber-js/app 0.1.24 → 0.1.26

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 (48) hide show
  1. package/dist/adapters/nitro.d.ts +9 -0
  2. package/dist/adapters/nitro.d.ts.map +1 -1
  3. package/dist/adapters/nitro.js +175 -7
  4. package/dist/adapters/nitro.js.map +1 -1
  5. package/dist/cli.js +2 -2
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client/browser-dev.d.ts +29 -0
  8. package/dist/client/browser-dev.d.ts.map +1 -0
  9. package/dist/client/browser-links.d.ts +32 -0
  10. package/dist/client/browser-links.d.ts.map +1 -0
  11. package/dist/client/index.d.ts +1 -1
  12. package/dist/client/index.d.ts.map +1 -1
  13. package/dist/client/index.js +46 -20
  14. package/dist/client/index.js.map +1 -1
  15. package/dist/client/navigation-context.d.ts +10 -8
  16. package/dist/client/navigation-context.d.ts.map +1 -1
  17. package/dist/client/transition-root.d.ts +54 -0
  18. package/dist/client/transition-root.d.ts.map +1 -0
  19. package/dist/client/use-router.d.ts +14 -0
  20. package/dist/client/use-router.d.ts.map +1 -1
  21. package/dist/server/index.js +264 -218
  22. package/dist/server/index.js.map +1 -1
  23. package/dist/server/metadata-platform.d.ts +34 -0
  24. package/dist/server/metadata-platform.d.ts.map +1 -0
  25. package/dist/server/metadata-render.d.ts.map +1 -1
  26. package/dist/server/metadata-social.d.ts +24 -0
  27. package/dist/server/metadata-social.d.ts.map +1 -0
  28. package/dist/server/pipeline-interception.d.ts +32 -0
  29. package/dist/server/pipeline-interception.d.ts.map +1 -0
  30. package/dist/server/pipeline-metadata.d.ts +31 -0
  31. package/dist/server/pipeline-metadata.d.ts.map +1 -0
  32. package/dist/server/pipeline.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/adapters/nitro.ts +187 -10
  35. package/src/cli.ts +10 -3
  36. package/src/client/browser-dev.ts +142 -0
  37. package/src/client/browser-entry.ts +32 -222
  38. package/src/client/browser-links.ts +90 -0
  39. package/src/client/index.ts +1 -1
  40. package/src/client/navigation-context.ts +39 -9
  41. package/src/client/transition-root.tsx +86 -0
  42. package/src/client/use-router.ts +17 -15
  43. package/src/server/metadata-platform.ts +229 -0
  44. package/src/server/metadata-render.ts +9 -363
  45. package/src/server/metadata-social.ts +184 -0
  46. package/src/server/pipeline-interception.ts +76 -0
  47. package/src/server/pipeline-metadata.ts +90 -0
  48. package/src/server/pipeline.ts +2 -148
@@ -49,8 +49,10 @@ import type { RouterDeps, RouterInstance } from '@timber-js/app/client';
49
49
  import { applyHeadElements } from './head.js';
50
50
  import { TimberNuqsAdapter } from './nuqs-adapter.js';
51
51
  import { isPageUnloading } from './unload-guard.js';
52
- import { ON_NAVIGATE_KEY } from './link-navigate-interceptor.js';
53
52
  import { NavigationProvider, getNavigationState, setNavigationState } from './navigation-context.js';
53
+ import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.js';
54
+ import { handleLinkClick, handleLinkHover } from './browser-links.js';
55
+ import { TransitionRoot, transitionRender } from './transition-root.js';
54
56
 
55
57
  // ─── Server Action Dispatch ──────────────────────────────────────
56
58
 
@@ -179,7 +181,7 @@ function bootstrap(runtimeConfig: typeof config): void {
179
181
 
180
182
  const timberChunks = (self as unknown as Record<string, FlightSegment[]>).__timber_f;
181
183
 
182
- let reactRoot: Root | null = null;
184
+ let _reactRoot: Root | null = null;
183
185
  let initialElement: unknown = null;
184
186
  // Declared here so it's accessible after the if/else hydration block.
185
187
  // Assigned inside initRouter() which is called in both branches.
@@ -272,8 +274,8 @@ function bootstrap(runtimeConfig: typeof config): void {
272
274
  // hydrateRoot() synchronously executes component render functions.
273
275
  // Components that call useRouter() during render need the global
274
276
  // router to be available, otherwise they get a stale no-op reference.
275
- // The renderRoot callback reads `reactRoot` lazily (via closure), so
276
- // it's safe to create the router before reactRoot is assigned.
277
+ // The router must be initialized before hydration so useRouter() works.
278
+ // renderRoot uses transitionRender (no direct reactRoot dependency).
277
279
  initRouter();
278
280
 
279
281
  // ── Initialize navigation state BEFORE hydration ───────────────────
@@ -298,12 +300,18 @@ function bootstrap(runtimeConfig: typeof config): void {
298
300
 
299
301
  // Hydrate on document — the root layout renders the full <html> tree,
300
302
  // so React owns the entire document from the root.
301
- // Wrap with NavigationProvider (for atomic useParams/usePathname) and
302
- // TimberNuqsAdapter (for nuqs context).
303
+ // Wrap with NavigationProvider (for atomic useParams/usePathname),
304
+ // TimberNuqsAdapter (for nuqs context), and TransitionRoot (for
305
+ // transition-based rendering during client navigation).
306
+ //
307
+ // TransitionRoot holds the element in React state and updates via
308
+ // startTransition, so React keeps old UI visible while new Suspense
309
+ // boundaries resolve during navigation. See design/05-streaming.md.
303
310
  const navState = getNavigationState();
304
311
  const withNav = createElement(NavigationProvider, { value: navState }, element as React.ReactNode);
305
312
  const wrapped = createElement(TimberNuqsAdapter, null, withNav);
306
- reactRoot = hydrateRoot(document, wrapped, {
313
+ const rootElement = createElement(TransitionRoot, { initial: wrapped });
314
+ _reactRoot = hydrateRoot(document, rootElement, {
307
315
  // Suppress recoverable hydration errors from deny/error signals
308
316
  // inside Suspense boundaries. The server already handled these
309
317
  // (wrapStreamWithErrorHandling closes the stream cleanly after
@@ -327,14 +335,14 @@ function bootstrap(runtimeConfig: typeof config): void {
327
335
  // The initial SSR HTML remains as-is; the first client navigation will
328
336
  // replace it with a React-managed tree.
329
337
  initRouter();
330
- reactRoot = createRoot(document);
338
+ _reactRoot = createRoot(document);
331
339
  }
332
340
 
333
341
  // ── Router initialization (hoisted above hydrateRoot) ────────────────
334
342
  // Extracted into a function so both the hydration and createRoot paths
335
343
  // can call it. Must run before hydrateRoot so useRouter() works during
336
- // the initial render. The renderRoot dep reads `reactRoot` via closure,
337
- // so it's fine that reactRoot is assigned after this runs.
344
+ // the initial render. renderRoot uses transitionRender which is set
345
+ // by the TransitionRoot component during hydration.
338
346
  function initRouter(): void {
339
347
  const deps: RouterDeps = {
340
348
  fetch: (url, init) => window.fetch(url, init),
@@ -359,25 +367,28 @@ function bootstrap(runtimeConfig: typeof config): void {
359
367
  return createFromFetch(fetchPromise);
360
368
  },
361
369
 
362
- // Render decoded RSC tree into the hydrated React root.
370
+ // Render decoded RSC tree via TransitionRoot's state-based mechanism.
363
371
  // Wraps with NavigationProvider (for atomic useParams/usePathname updates)
364
- // and TimberNuqsAdapter (for nuqs context). Reads `reactRoot` and
365
- // navigation state from closures — both set before this callback fires.
372
+ // and TimberNuqsAdapter (for nuqs context).
366
373
  //
367
374
  // The router calls setNavigationState() before renderRoot(), so
368
375
  // getNavigationState() returns the new params/pathname. By wrapping
369
376
  // the element in NavigationProvider here, the context value and the
370
- // RSC tree are passed to reactRoot.render() in the same call —
371
- // making the update atomic. Preserved layout components that call
377
+ // RSC tree are passed to startTransition(() => setState()) in the same
378
+ // call — making the update atomic. Preserved layout components that call
372
379
  // useParams() or usePathname() re-render in the same pass as the
373
380
  // new tree, preventing the dual-active-state flash.
381
+ //
382
+ // Using transitionRender instead of reactRoot.render() enables
383
+ // client-side Suspense deferral: React keeps the old committed tree
384
+ // visible while new Suspense boundaries in the navigation resolve.
385
+ // This is the client-side equivalent of deferSuspenseFor on the server.
386
+ // See design/05-streaming.md.
374
387
  renderRoot: (element: unknown) => {
375
- if (reactRoot) {
376
- const navState = getNavigationState();
377
- const withNav = createElement(NavigationProvider, { value: navState }, element as React.ReactNode);
378
- const wrapped = createElement(TimberNuqsAdapter, null, withNav);
379
- reactRoot.render(wrapped);
380
- }
388
+ const navState = getNavigationState();
389
+ const withNav = createElement(NavigationProvider, { value: navState }, element as React.ReactNode);
390
+ const wrapped = createElement(TimberNuqsAdapter, null, withNav);
391
+ transitionRender(wrapped);
381
392
  },
382
393
 
383
394
  // Schedule a callback after the next paint so scroll operations
@@ -521,208 +532,7 @@ function bootstrap(runtimeConfig: typeof config): void {
521
532
  }
522
533
  }
523
534
 
524
- // ─── Server Log Replay (Dev Only) ─────────────────────────────────
525
-
526
- /** Payload shape from plugins/dev-logs.ts */
527
- interface ServerLogPayload {
528
- level: 'log' | 'warn' | 'error' | 'debug' | 'info';
529
- args: unknown[];
530
- location: string | null;
531
- timestamp: number;
532
- }
533
-
534
- /**
535
- * Deserialize a serialized arg back into a console-friendly value.
536
- *
537
- * Handles Error objects (serialized as { __type: 'Error', ... }),
538
- * Maps, Sets, and passes everything else through.
539
- */
540
- function deserializeArg(arg: unknown): unknown {
541
- if (arg === '[undefined]') return undefined;
542
- if (arg === null || typeof arg !== 'object') return arg;
543
-
544
- const obj = arg as Record<string, unknown>;
545
-
546
- if (obj.__type === 'Error') {
547
- const err = new Error(obj.message as string);
548
- err.name = obj.name as string;
549
- if (obj.stack) err.stack = obj.stack as string;
550
- return err;
551
- }
552
-
553
- if (obj.__type === 'Map') {
554
- return new Map(
555
- Object.entries(obj.entries as Record<string, unknown>).map(([k, v]) => [k, deserializeArg(v)])
556
- );
557
- }
558
-
559
- if (obj.__type === 'Set') {
560
- return new Set((obj.values as unknown[]).map(deserializeArg));
561
- }
562
-
563
- if (Array.isArray(arg)) {
564
- return arg.map(deserializeArg);
565
- }
566
-
567
- // Plain object — recurse
568
- const result: Record<string, unknown> = {};
569
- for (const [key, value] of Object.entries(obj)) {
570
- result[key] = deserializeArg(value);
571
- }
572
- return result;
573
- }
574
-
575
- /**
576
- * Set up the HMR listener that replays server console output in the browser.
577
- *
578
- * Each message arrives with a log level and serialized args. We prepend
579
- * a styled "[SERVER]" badge and call the matching console method.
580
- */
581
- function setupServerLogReplay(hot: {
582
- on(event: string, cb: (...args: unknown[]) => void): void;
583
- }): void {
584
- /** CSS styles for the [SERVER] badge in browser console. */
585
- const BADGE_STYLES: Record<string, string> = {
586
- log: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
587
- info: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
588
- warn: 'background: #f5a623; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
589
- error:
590
- 'background: #e00; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
591
- debug:
592
- 'background: #666; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
593
- };
594
-
595
- hot.on('timber:server-log', (data: unknown) => {
596
- const payload = data as ServerLogPayload;
597
- const level = payload.level;
598
- const fn = console[level] ?? console.log;
599
- const args = payload.args.map(deserializeArg);
600
-
601
- const badge = `%cSERVER`;
602
- const style = BADGE_STYLES[level] ?? BADGE_STYLES.log;
603
- const locationSuffix = payload.location ? ` (${payload.location})` : '';
604
-
605
- fn.call(console, badge, style, ...args, locationSuffix ? `\n → ${payload.location}` : '');
606
- });
607
- }
608
-
609
- // ─── Client Error Forwarding (Dev Only) ──────────────────────────
610
-
611
- /**
612
- * Set up global error handlers that forward uncaught client-side
613
- * errors to the dev server via Vite's HMR channel.
614
- *
615
- * The server receives 'timber:client-error' events, and echoes them
616
- * back as Vite '{ type: "error" }' payloads to trigger the overlay.
617
- */
618
- function setupClientErrorForwarding(hot: { send(event: string, data: unknown): void }): void {
619
- window.addEventListener('error', (event: ErrorEvent) => {
620
- // Skip errors without useful information
621
- if (!event.error && !event.message) return;
622
- // Skip errors during page unload — these are abort-related, not application errors
623
- if (isPageUnloading()) return;
624
-
625
- const error = event.error;
626
- hot.send('timber:client-error', {
627
- message: error?.message ?? event.message,
628
- stack: error?.stack ?? '',
629
- componentStack: error?.componentStack ?? null,
630
- });
631
- });
632
-
633
- window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
634
- const reason = event.reason;
635
- if (!reason) return;
636
- // Skip rejections during page unload — aborted fetches/streams cause these
637
- if (isPageUnloading()) return;
638
-
639
- const message = reason instanceof Error ? reason.message : String(reason);
640
- const stack = reason instanceof Error ? (reason.stack ?? '') : '';
641
-
642
- hot.send('timber:client-error', {
643
- message,
644
- stack,
645
- componentStack: null,
646
- });
647
- });
648
- }
649
-
650
- // ─── Link Click Interception ─────────────────────────────────────
651
-
652
- /**
653
- * Handle click events on timber links. Intercepts clicks on <a> elements
654
- * marked with data-timber-link and triggers SPA navigation instead of
655
- * a full page load.
656
- *
657
- * Passes through to default browser behavior when:
658
- * - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
659
- * - The click is not the primary button
660
- * - The link has a target attribute (e.g., target="_blank")
661
- * - The link has a download attribute
662
- */
663
- function handleLinkClick(event: MouseEvent, router: RouterInstance): void {
664
- // Only intercept primary clicks without modifier keys
665
- if (event.button !== 0) return;
666
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
667
- if (event.defaultPrevented) return;
668
-
669
- // Find the closest <a> ancestor with data-timber-link
670
- const anchor = (event.target as Element).closest?.(
671
- 'a[data-timber-link]'
672
- ) as HTMLAnchorElement | null;
673
- if (!anchor) return;
674
-
675
- // Don't intercept links that should open externally
676
- if (anchor.target && anchor.target !== '_self') return;
677
- if (anchor.hasAttribute('download')) return;
678
-
679
- const href = anchor.getAttribute('href');
680
- if (!href) return;
681
-
682
- // Prevent default navigation
683
- event.preventDefault();
684
-
685
- // Call onNavigate if registered on this anchor (via LinkNavigateInterceptor).
686
- // If the handler calls preventDefault(), skip the default SPA navigation —
687
- // the caller is responsible for navigating (e.g. via router.push()).
688
- const onNavigate = anchor[ON_NAVIGATE_KEY];
689
- if (onNavigate) {
690
- let prevented = false;
691
- onNavigate({
692
- preventDefault: () => {
693
- prevented = true;
694
- },
695
- });
696
- if (prevented) return;
697
- }
698
-
699
- // Check scroll preference from data attribute
700
- const scroll = anchor.getAttribute('data-timber-scroll') !== 'false';
701
-
702
- // Trigger SPA navigation
703
- void router.navigate(href, { scroll });
704
- }
705
-
706
- // ─── Prefetch on Hover ───────────────────────────────────────────
707
535
 
708
- /**
709
- * Handle mouseenter events on prefetch-enabled links. When the user
710
- * hovers over <a data-timber-prefetch>, the RSC payload is fetched
711
- * and cached for near-instant navigation.
712
- *
713
- * See design/19-client-navigation.md §"Prefetch Cache"
714
- */
715
- function handleLinkHover(event: MouseEvent, router: RouterInstance): void {
716
- const anchor = (event.target as Element).closest?.(
717
- 'a[data-timber-prefetch]'
718
- ) as HTMLAnchorElement | null;
719
- if (!anchor) return;
720
-
721
- const href = anchor.getAttribute('href');
722
- if (!href) return;
723
-
724
- router.prefetch(href);
725
- }
726
536
 
727
537
  bootstrap(config);
728
538
 
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Link click interception and hover prefetch for SPA navigation.
3
+ *
4
+ * Handles click events on <a data-timber-link> and mouseenter events
5
+ * on <a data-timber-prefetch> for client-side navigation.
6
+ *
7
+ * Extracted from browser-entry.ts to keep files under 500 lines.
8
+ *
9
+ * See design/19-client-navigation.md
10
+ */
11
+
12
+ import type { RouterInstance } from '@timber-js/app/client';
13
+ import { ON_NAVIGATE_KEY } from './link-navigate-interceptor.js';
14
+
15
+ // ─── Link Click Interception ─────────────────────────────────────
16
+
17
+ /**
18
+ * Handle click events on timber links. Intercepts clicks on <a> elements
19
+ * marked with data-timber-link and triggers SPA navigation instead of
20
+ * a full page load.
21
+ *
22
+ * Passes through to default browser behavior when:
23
+ * - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
24
+ * - The click is not the primary button
25
+ * - The link has a target attribute (e.g., target="_blank")
26
+ * - The link has a download attribute
27
+ */
28
+ export function handleLinkClick(event: MouseEvent, router: RouterInstance): void {
29
+ // Only intercept primary clicks without modifier keys
30
+ if (event.button !== 0) return;
31
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
32
+ if (event.defaultPrevented) return;
33
+
34
+ // Find the closest <a> ancestor with data-timber-link
35
+ const anchor = (event.target as Element).closest?.(
36
+ 'a[data-timber-link]'
37
+ ) as HTMLAnchorElement | null;
38
+ if (!anchor) return;
39
+
40
+ // Don't intercept links that should open externally
41
+ if (anchor.target && anchor.target !== '_self') return;
42
+ if (anchor.hasAttribute('download')) return;
43
+
44
+ const href = anchor.getAttribute('href');
45
+ if (!href) return;
46
+
47
+ // Prevent default navigation
48
+ event.preventDefault();
49
+
50
+ // Call onNavigate if registered on this anchor (via LinkNavigateInterceptor).
51
+ // If the handler calls preventDefault(), skip the default SPA navigation —
52
+ // the caller is responsible for navigating (e.g. via router.push()).
53
+ const onNavigate = anchor[ON_NAVIGATE_KEY];
54
+ if (onNavigate) {
55
+ let prevented = false;
56
+ onNavigate({
57
+ preventDefault: () => {
58
+ prevented = true;
59
+ },
60
+ });
61
+ if (prevented) return;
62
+ }
63
+
64
+ // Check scroll preference from data attribute
65
+ const scroll = anchor.getAttribute('data-timber-scroll') !== 'false';
66
+
67
+ // Trigger SPA navigation
68
+ void router.navigate(href, { scroll });
69
+ }
70
+
71
+ // ─── Prefetch on Hover ───────────────────────────────────────────
72
+
73
+ /**
74
+ * Handle mouseenter events on prefetch-enabled links. When the user
75
+ * hovers over <a data-timber-prefetch>, the RSC payload is fetched
76
+ * and cached for near-instant navigation.
77
+ *
78
+ * See design/19-client-navigation.md §"Prefetch Cache"
79
+ */
80
+ export function handleLinkHover(event: MouseEvent, router: RouterInstance): void {
81
+ const anchor = (event.target as Element).closest?.(
82
+ 'a[data-timber-prefetch]'
83
+ ) as HTMLAnchorElement | null;
84
+ if (!anchor) return;
85
+
86
+ const href = anchor.getAttribute('href');
87
+ if (!href) return;
88
+
89
+ router.prefetch(href);
90
+ }
@@ -45,7 +45,7 @@ export type { UseActionStateFn, UseActionStateReturn, FormErrorsResult } from '.
45
45
  export { useParams, setCurrentParams } from './use-params';
46
46
 
47
47
  // Navigation context (framework-internal, used by browser-entry for atomic updates)
48
- export { NavigationProvider, NavigationContext, getNavigationState, setNavigationState } from './navigation-context';
48
+ export { NavigationProvider, getNavigationState, setNavigationState } from './navigation-context';
49
49
  export type { NavigationState } from './navigation-context';
50
50
 
51
51
  // Query states (URL-synced search params)
@@ -17,13 +17,19 @@
17
17
  * During SSR, no NavigationProvider is mounted. Hooks fall back to
18
18
  * the ALS-backed getSsrData() for per-request isolation.
19
19
  *
20
- * See design/19-client-navigation.md §"Navigation Flow"
20
+ * IMPORTANT: createContext and useContext are NOT available in the RSC
21
+ * environment (React Server Components use a stripped-down React).
22
+ * The context is lazily initialized on first access, and all functions
23
+ * that depend on these APIs are safe to call from any environment —
24
+ * they return null or no-op when the APIs aren't available.
25
+ *
26
+ * See design/19-client-navigation.md §"NavigationContext"
21
27
  */
22
28
 
23
- import { createContext, useContext, createElement, type ReactNode } from 'react';
29
+ import React, { createElement, type ReactNode } from 'react';
24
30
 
25
31
  // ---------------------------------------------------------------------------
26
- // Context type and creation
32
+ // Context type
27
33
  // ---------------------------------------------------------------------------
28
34
 
29
35
  export interface NavigationState {
@@ -31,18 +37,37 @@ export interface NavigationState {
31
37
  pathname: string;
32
38
  }
33
39
 
40
+ // ---------------------------------------------------------------------------
41
+ // Lazy context initialization
42
+ // ---------------------------------------------------------------------------
43
+
34
44
  /**
35
- * The context value is null when no provider is mounted (SSR).
36
- * On the client, NavigationProvider always wraps the tree.
45
+ * The context is created lazily to avoid calling createContext at module
46
+ * level. In the RSC environment, React.createContext doesn't exist
47
+ * calling it at import time would crash the server.
37
48
  */
38
- export const NavigationContext = createContext<NavigationState | null>(null);
49
+ let _context: React.Context<NavigationState | null> | undefined;
50
+
51
+ function getOrCreateContext(): React.Context<NavigationState | null> | undefined {
52
+ if (_context !== undefined) return _context;
53
+ // createContext may not exist in the RSC environment
54
+ if (typeof React.createContext === 'function') {
55
+ _context = React.createContext<NavigationState | null>(null);
56
+ }
57
+ return _context;
58
+ }
39
59
 
40
60
  /**
41
- * Read the navigation context. Returns null during SSR (no provider).
61
+ * Read the navigation context. Returns null during SSR (no provider)
62
+ * or in the RSC environment (no context available).
42
63
  * Internal — used by useParams() and usePathname().
43
64
  */
44
65
  export function useNavigationContext(): NavigationState | null {
45
- return useContext(NavigationContext);
66
+ const ctx = getOrCreateContext();
67
+ if (!ctx) return null;
68
+ // useContext may not exist in the RSC environment — caller wraps in try/catch
69
+ if (typeof React.useContext !== 'function') return null;
70
+ return React.useContext(ctx);
46
71
  }
47
72
 
48
73
  // ---------------------------------------------------------------------------
@@ -61,7 +86,12 @@ export interface NavigationProviderProps {
61
86
  * so that navigation state updates atomically with the tree render.
62
87
  */
63
88
  export function NavigationProvider({ value, children }: NavigationProviderProps): React.ReactElement {
64
- return createElement(NavigationContext.Provider, { value }, children);
89
+ const ctx = getOrCreateContext();
90
+ if (!ctx) {
91
+ // RSC environment — no context available. Return children as-is.
92
+ return children as React.ReactElement;
93
+ }
94
+ return createElement(ctx.Provider, { value }, children);
65
95
  }
66
96
 
67
97
  // ---------------------------------------------------------------------------
@@ -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
+ }
@@ -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();