@timber-js/app 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/_chunks/{ssr-data-B2yikEEB.js → ssr-data-DLnbYpj1.js} +2 -4
  2. package/dist/_chunks/{ssr-data-B2yikEEB.js.map → ssr-data-DLnbYpj1.js.map} +1 -1
  3. package/dist/_chunks/{use-cookie-D5aS4slY.js → use-cookie-dDbpCTx-.js} +2 -2
  4. package/dist/_chunks/{use-cookie-D5aS4slY.js.map → use-cookie-dDbpCTx-.js.map} +1 -1
  5. package/dist/adapters/nitro.d.ts.map +1 -1
  6. package/dist/adapters/nitro.js +4 -3
  7. package/dist/adapters/nitro.js.map +1 -1
  8. package/dist/cli.js +1 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/client/browser-dev.d.ts +29 -0
  11. package/dist/client/browser-dev.d.ts.map +1 -0
  12. package/dist/client/browser-links.d.ts +32 -0
  13. package/dist/client/browser-links.d.ts.map +1 -0
  14. package/dist/client/error-boundary.js +1 -1
  15. package/dist/client/index.d.ts +2 -0
  16. package/dist/client/index.d.ts.map +1 -1
  17. package/dist/client/index.js +150 -122
  18. package/dist/client/index.js.map +1 -1
  19. package/dist/client/navigation-context.d.ts +52 -0
  20. package/dist/client/navigation-context.d.ts.map +1 -0
  21. package/dist/client/router.d.ts.map +1 -1
  22. package/dist/client/transition-root.d.ts +54 -0
  23. package/dist/client/transition-root.d.ts.map +1 -0
  24. package/dist/client/use-params.d.ts +35 -25
  25. package/dist/client/use-params.d.ts.map +1 -1
  26. package/dist/client/use-pathname.d.ts +11 -4
  27. package/dist/client/use-pathname.d.ts.map +1 -1
  28. package/dist/client/use-router.d.ts +14 -0
  29. package/dist/client/use-router.d.ts.map +1 -1
  30. package/dist/cookies/index.js +2 -2
  31. package/dist/server/index.js +264 -218
  32. package/dist/server/index.js.map +1 -1
  33. package/dist/server/metadata-platform.d.ts +34 -0
  34. package/dist/server/metadata-platform.d.ts.map +1 -0
  35. package/dist/server/metadata-render.d.ts.map +1 -1
  36. package/dist/server/metadata-social.d.ts +24 -0
  37. package/dist/server/metadata-social.d.ts.map +1 -0
  38. package/dist/server/pipeline-interception.d.ts +32 -0
  39. package/dist/server/pipeline-interception.d.ts.map +1 -0
  40. package/dist/server/pipeline-metadata.d.ts +31 -0
  41. package/dist/server/pipeline-metadata.d.ts.map +1 -0
  42. package/dist/server/pipeline.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/src/adapters/nitro.ts +9 -7
  45. package/src/cli.ts +9 -2
  46. package/src/client/browser-dev.ts +142 -0
  47. package/src/client/browser-entry.ts +73 -223
  48. package/src/client/browser-links.ts +90 -0
  49. package/src/client/index.ts +4 -0
  50. package/src/client/navigation-context.ts +118 -0
  51. package/src/client/router.ts +37 -33
  52. package/src/client/transition-root.tsx +86 -0
  53. package/src/client/use-params.ts +50 -54
  54. package/src/client/use-pathname.ts +31 -24
  55. package/src/client/use-router.ts +17 -15
  56. package/src/server/metadata-platform.ts +229 -0
  57. package/src/server/metadata-render.ts +9 -363
  58. package/src/server/metadata-social.ts +184 -0
  59. package/src/server/pipeline-interception.ts +76 -0
  60. package/src/server/pipeline-metadata.ts +90 -0
  61. package/src/server/pipeline.ts +2 -148
@@ -49,7 +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';
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';
53
56
 
54
57
  // ─── Server Action Dispatch ──────────────────────────────────────
55
58
 
@@ -178,7 +181,7 @@ function bootstrap(runtimeConfig: typeof config): void {
178
181
 
179
182
  const timberChunks = (self as unknown as Record<string, FlightSegment[]>).__timber_f;
180
183
 
181
- let reactRoot: Root | null = null;
184
+ let _reactRoot: Root | null = null;
182
185
  let initialElement: unknown = null;
183
186
  // Declared here so it's accessible after the if/else hydration block.
184
187
  // Assigned inside initRouter() which is called in both branches.
@@ -271,15 +274,44 @@ function bootstrap(runtimeConfig: typeof config): void {
271
274
  // hydrateRoot() synchronously executes component render functions.
272
275
  // Components that call useRouter() during render need the global
273
276
  // router to be available, otherwise they get a stale no-op reference.
274
- // The renderRoot callback reads `reactRoot` lazily (via closure), so
275
- // 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).
276
279
  initRouter();
277
280
 
281
+ // ── Initialize navigation state BEFORE hydration ───────────────────
282
+ // Read server-embedded params and set navigation state so that
283
+ // useParams() and usePathname() return correct values during hydration.
284
+ // This must happen before hydrateRoot so the NavigationProvider
285
+ // wrapping the element has the right values on the initial render.
286
+ const earlyParams = (self as unknown as Record<string, unknown>).__timber_params;
287
+ if (earlyParams && typeof earlyParams === 'object') {
288
+ setCurrentParams(earlyParams as Record<string, string | string[]>);
289
+ setNavigationState({
290
+ params: earlyParams as Record<string, string | string[]>,
291
+ pathname: window.location.pathname,
292
+ });
293
+ delete (self as unknown as Record<string, unknown>).__timber_params;
294
+ } else {
295
+ setNavigationState({
296
+ params: {},
297
+ pathname: window.location.pathname,
298
+ });
299
+ }
300
+
278
301
  // Hydrate on document — the root layout renders the full <html> tree,
279
302
  // so React owns the entire document from the root.
280
- // Wrap with TimberNuqsAdapter so useQueryStates works out of the box.
281
- const wrapped = createElement(TimberNuqsAdapter, null, element as React.ReactNode);
282
- reactRoot = hydrateRoot(document, wrapped, {
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.
310
+ const navState = getNavigationState();
311
+ const withNav = createElement(NavigationProvider, { value: navState }, element as React.ReactNode);
312
+ const wrapped = createElement(TimberNuqsAdapter, null, withNav);
313
+ const rootElement = createElement(TransitionRoot, { initial: wrapped });
314
+ _reactRoot = hydrateRoot(document, rootElement, {
283
315
  // Suppress recoverable hydration errors from deny/error signals
284
316
  // inside Suspense boundaries. The server already handled these
285
317
  // (wrapStreamWithErrorHandling closes the stream cleanly after
@@ -303,14 +335,14 @@ function bootstrap(runtimeConfig: typeof config): void {
303
335
  // The initial SSR HTML remains as-is; the first client navigation will
304
336
  // replace it with a React-managed tree.
305
337
  initRouter();
306
- reactRoot = createRoot(document);
338
+ _reactRoot = createRoot(document);
307
339
  }
308
340
 
309
341
  // ── Router initialization (hoisted above hydrateRoot) ────────────────
310
342
  // Extracted into a function so both the hydration and createRoot paths
311
343
  // can call it. Must run before hydrateRoot so useRouter() works during
312
- // the initial render. The renderRoot dep reads `reactRoot` via closure,
313
- // 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.
314
346
  function initRouter(): void {
315
347
  const deps: RouterDeps = {
316
348
  fetch: (url, init) => window.fetch(url, init),
@@ -335,14 +367,28 @@ function bootstrap(runtimeConfig: typeof config): void {
335
367
  return createFromFetch(fetchPromise);
336
368
  },
337
369
 
338
- // Render decoded RSC tree into the hydrated React root.
339
- // Wrap with TimberNuqsAdapter to maintain nuqs context across navigations.
340
- // Reads `reactRoot` from the outer closure — assigned after hydrateRoot().
370
+ // Render decoded RSC tree via TransitionRoot's state-based mechanism.
371
+ // Wraps with NavigationProvider (for atomic useParams/usePathname updates)
372
+ // and TimberNuqsAdapter (for nuqs context).
373
+ //
374
+ // The router calls setNavigationState() before renderRoot(), so
375
+ // getNavigationState() returns the new params/pathname. By wrapping
376
+ // the element in NavigationProvider here, the context value and the
377
+ // RSC tree are passed to startTransition(() => setState()) in the same
378
+ // call — making the update atomic. Preserved layout components that call
379
+ // useParams() or usePathname() re-render in the same pass as the
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.
341
387
  renderRoot: (element: unknown) => {
342
- if (reactRoot) {
343
- const wrapped = createElement(TimberNuqsAdapter, null, element as React.ReactNode);
344
- reactRoot.render(wrapped);
345
- }
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);
346
392
  },
347
393
 
348
394
  // Schedule a callback after the next paint so scroll operations
@@ -384,11 +430,16 @@ function bootstrap(runtimeConfig: typeof config): void {
384
430
  delete (self as unknown as Record<string, unknown>).__timber_segments;
385
431
  }
386
432
 
387
- // Populate useParams() from server-embedded route params.
388
- // Without this, useParams() returns {} until the first client navigation.
389
- const timberParams = (self as unknown as Record<string, unknown>).__timber_params;
390
- if (timberParams && typeof timberParams === 'object') {
391
- setCurrentParams(timberParams as Record<string, string | string[]>);
433
+ // Note: __timber_params is read before hydrateRoot (see above) so that
434
+ // NavigationProvider has correct values during hydration. If the hydration
435
+ // path was skipped (no RSC payload), populate the fallback here.
436
+ const lateTimberParams = (self as unknown as Record<string, unknown>).__timber_params;
437
+ if (lateTimberParams && typeof lateTimberParams === 'object') {
438
+ setCurrentParams(lateTimberParams as Record<string, string | string[]>);
439
+ setNavigationState({
440
+ params: lateTimberParams as Record<string, string | string[]>,
441
+ pathname: window.location.pathname,
442
+ });
392
443
  delete (self as unknown as Record<string, unknown>).__timber_params;
393
444
  }
394
445
 
@@ -481,208 +532,7 @@ function bootstrap(runtimeConfig: typeof config): void {
481
532
  }
482
533
  }
483
534
 
484
- // ─── Server Log Replay (Dev Only) ─────────────────────────────────
485
-
486
- /** Payload shape from plugins/dev-logs.ts */
487
- interface ServerLogPayload {
488
- level: 'log' | 'warn' | 'error' | 'debug' | 'info';
489
- args: unknown[];
490
- location: string | null;
491
- timestamp: number;
492
- }
493
-
494
- /**
495
- * Deserialize a serialized arg back into a console-friendly value.
496
- *
497
- * Handles Error objects (serialized as { __type: 'Error', ... }),
498
- * Maps, Sets, and passes everything else through.
499
- */
500
- function deserializeArg(arg: unknown): unknown {
501
- if (arg === '[undefined]') return undefined;
502
- if (arg === null || typeof arg !== 'object') return arg;
503
-
504
- const obj = arg as Record<string, unknown>;
505
-
506
- if (obj.__type === 'Error') {
507
- const err = new Error(obj.message as string);
508
- err.name = obj.name as string;
509
- if (obj.stack) err.stack = obj.stack as string;
510
- return err;
511
- }
512
-
513
- if (obj.__type === 'Map') {
514
- return new Map(
515
- Object.entries(obj.entries as Record<string, unknown>).map(([k, v]) => [k, deserializeArg(v)])
516
- );
517
- }
518
-
519
- if (obj.__type === 'Set') {
520
- return new Set((obj.values as unknown[]).map(deserializeArg));
521
- }
522
-
523
- if (Array.isArray(arg)) {
524
- return arg.map(deserializeArg);
525
- }
526
-
527
- // Plain object — recurse
528
- const result: Record<string, unknown> = {};
529
- for (const [key, value] of Object.entries(obj)) {
530
- result[key] = deserializeArg(value);
531
- }
532
- return result;
533
- }
534
-
535
- /**
536
- * Set up the HMR listener that replays server console output in the browser.
537
- *
538
- * Each message arrives with a log level and serialized args. We prepend
539
- * a styled "[SERVER]" badge and call the matching console method.
540
- */
541
- function setupServerLogReplay(hot: {
542
- on(event: string, cb: (...args: unknown[]) => void): void;
543
- }): void {
544
- /** CSS styles for the [SERVER] badge in browser console. */
545
- const BADGE_STYLES: Record<string, string> = {
546
- log: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
547
- info: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
548
- warn: 'background: #f5a623; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
549
- error:
550
- 'background: #e00; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
551
- debug:
552
- 'background: #666; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
553
- };
554
-
555
- hot.on('timber:server-log', (data: unknown) => {
556
- const payload = data as ServerLogPayload;
557
- const level = payload.level;
558
- const fn = console[level] ?? console.log;
559
- const args = payload.args.map(deserializeArg);
560
-
561
- const badge = `%cSERVER`;
562
- const style = BADGE_STYLES[level] ?? BADGE_STYLES.log;
563
- const locationSuffix = payload.location ? ` (${payload.location})` : '';
564
-
565
- fn.call(console, badge, style, ...args, locationSuffix ? `\n → ${payload.location}` : '');
566
- });
567
- }
568
-
569
- // ─── Client Error Forwarding (Dev Only) ──────────────────────────
570
535
 
571
- /**
572
- * Set up global error handlers that forward uncaught client-side
573
- * errors to the dev server via Vite's HMR channel.
574
- *
575
- * The server receives 'timber:client-error' events, and echoes them
576
- * back as Vite '{ type: "error" }' payloads to trigger the overlay.
577
- */
578
- function setupClientErrorForwarding(hot: { send(event: string, data: unknown): void }): void {
579
- window.addEventListener('error', (event: ErrorEvent) => {
580
- // Skip errors without useful information
581
- if (!event.error && !event.message) return;
582
- // Skip errors during page unload — these are abort-related, not application errors
583
- if (isPageUnloading()) return;
584
-
585
- const error = event.error;
586
- hot.send('timber:client-error', {
587
- message: error?.message ?? event.message,
588
- stack: error?.stack ?? '',
589
- componentStack: error?.componentStack ?? null,
590
- });
591
- });
592
-
593
- window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
594
- const reason = event.reason;
595
- if (!reason) return;
596
- // Skip rejections during page unload — aborted fetches/streams cause these
597
- if (isPageUnloading()) return;
598
-
599
- const message = reason instanceof Error ? reason.message : String(reason);
600
- const stack = reason instanceof Error ? (reason.stack ?? '') : '';
601
-
602
- hot.send('timber:client-error', {
603
- message,
604
- stack,
605
- componentStack: null,
606
- });
607
- });
608
- }
609
-
610
- // ─── Link Click Interception ─────────────────────────────────────
611
-
612
- /**
613
- * Handle click events on timber links. Intercepts clicks on <a> elements
614
- * marked with data-timber-link and triggers SPA navigation instead of
615
- * a full page load.
616
- *
617
- * Passes through to default browser behavior when:
618
- * - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
619
- * - The click is not the primary button
620
- * - The link has a target attribute (e.g., target="_blank")
621
- * - The link has a download attribute
622
- */
623
- function handleLinkClick(event: MouseEvent, router: RouterInstance): void {
624
- // Only intercept primary clicks without modifier keys
625
- if (event.button !== 0) return;
626
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
627
- if (event.defaultPrevented) return;
628
-
629
- // Find the closest <a> ancestor with data-timber-link
630
- const anchor = (event.target as Element).closest?.(
631
- 'a[data-timber-link]'
632
- ) as HTMLAnchorElement | null;
633
- if (!anchor) return;
634
-
635
- // Don't intercept links that should open externally
636
- if (anchor.target && anchor.target !== '_self') return;
637
- if (anchor.hasAttribute('download')) return;
638
-
639
- const href = anchor.getAttribute('href');
640
- if (!href) return;
641
-
642
- // Prevent default navigation
643
- event.preventDefault();
644
-
645
- // Call onNavigate if registered on this anchor (via LinkNavigateInterceptor).
646
- // If the handler calls preventDefault(), skip the default SPA navigation —
647
- // the caller is responsible for navigating (e.g. via router.push()).
648
- const onNavigate = anchor[ON_NAVIGATE_KEY];
649
- if (onNavigate) {
650
- let prevented = false;
651
- onNavigate({
652
- preventDefault: () => {
653
- prevented = true;
654
- },
655
- });
656
- if (prevented) return;
657
- }
658
-
659
- // Check scroll preference from data attribute
660
- const scroll = anchor.getAttribute('data-timber-scroll') !== 'false';
661
-
662
- // Trigger SPA navigation
663
- void router.navigate(href, { scroll });
664
- }
665
-
666
- // ─── Prefetch on Hover ───────────────────────────────────────────
667
-
668
- /**
669
- * Handle mouseenter events on prefetch-enabled links. When the user
670
- * hovers over <a data-timber-prefetch>, the RSC payload is fetched
671
- * and cached for near-instant navigation.
672
- *
673
- * See design/19-client-navigation.md §"Prefetch Cache"
674
- */
675
- function handleLinkHover(event: MouseEvent, router: RouterInstance): void {
676
- const anchor = (event.target as Element).closest?.(
677
- 'a[data-timber-prefetch]'
678
- ) as HTMLAnchorElement | null;
679
- if (!anchor) return;
680
-
681
- const href = anchor.getAttribute('href');
682
- if (!href) return;
683
-
684
- router.prefetch(href);
685
- }
686
536
 
687
537
  bootstrap(config);
688
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
+ }
@@ -44,6 +44,10 @@ export type { UseActionStateFn, UseActionStateReturn, FormErrorsResult } from '.
44
44
  // Params
45
45
  export { useParams, setCurrentParams } from './use-params';
46
46
 
47
+ // Navigation context (framework-internal, used by browser-entry for atomic updates)
48
+ export { NavigationProvider, getNavigationState, setNavigationState } from './navigation-context';
49
+ export type { NavigationState } from './navigation-context';
50
+
47
51
  // Query states (URL-synced search params)
48
52
  export { useQueryStates, bindUseQueryStates } from './use-query-states';
49
53
 
@@ -0,0 +1,118 @@
1
+ /**
2
+ * NavigationContext — React context for navigation state.
3
+ *
4
+ * Holds the current route params and pathname, updated atomically
5
+ * with the RSC tree on each navigation. This replaces the previous
6
+ * useSyncExternalStore approach for useParams() and usePathname(),
7
+ * which suffered from a timing gap: the new tree could commit before
8
+ * the external store re-renders fired, causing a frame where both
9
+ * old and new active states were visible simultaneously.
10
+ *
11
+ * By wrapping the RSC payload element in NavigationProvider inside
12
+ * renderRoot(), the context value and the element tree are passed to
13
+ * reactRoot.render() in the same call — atomic by construction.
14
+ * All consumers (useParams, usePathname) see the new values in the
15
+ * same render pass as the new tree.
16
+ *
17
+ * During SSR, no NavigationProvider is mounted. Hooks fall back to
18
+ * the ALS-backed getSsrData() for per-request isolation.
19
+ *
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"
27
+ */
28
+
29
+ import React, { createElement, type ReactNode } from 'react';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Context type
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export interface NavigationState {
36
+ params: Record<string, string | string[]>;
37
+ pathname: string;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Lazy context initialization
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
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.
48
+ */
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
+ }
59
+
60
+ /**
61
+ * Read the navigation context. Returns null during SSR (no provider)
62
+ * or in the RSC environment (no context available).
63
+ * Internal — used by useParams() and usePathname().
64
+ */
65
+ export function useNavigationContext(): NavigationState | null {
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);
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Provider component
75
+ // ---------------------------------------------------------------------------
76
+
77
+ export interface NavigationProviderProps {
78
+ value: NavigationState;
79
+ children?: ReactNode;
80
+ }
81
+
82
+ /**
83
+ * Wraps children with NavigationContext.Provider.
84
+ *
85
+ * Used in browser-entry.ts renderRoot to wrap the RSC payload element
86
+ * so that navigation state updates atomically with the tree render.
87
+ */
88
+ export function NavigationProvider({ value, children }: NavigationProviderProps): React.ReactElement {
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);
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Module-level state for renderRoot to read
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Module-level navigation state. Updated by the router before calling
103
+ * renderRoot(). The renderRoot callback reads this to create the
104
+ * NavigationProvider with the correct values.
105
+ *
106
+ * This is NOT used by hooks directly — hooks read from React context.
107
+ * This exists only as a communication channel between the router
108
+ * (which knows the new nav state) and renderRoot (which wraps the element).
109
+ */
110
+ let _currentNavState: NavigationState = { params: {}, pathname: '/' };
111
+
112
+ export function setNavigationState(state: NavigationState): void {
113
+ _currentNavState = state;
114
+ }
115
+
116
+ export function getNavigationState(): NavigationState {
117
+ return _currentNavState;
118
+ }