@timber-js/app 0.2.0-alpha.122 → 0.2.0-alpha.124

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 (81) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{actions-Dy8vOjFt.js → actions-418ygYKC.js} +5 -5
  3. package/dist/_chunks/{actions-Dy8vOjFt.js.map → actions-418ygYKC.js.map} +1 -1
  4. package/dist/_chunks/{cli-schema-sync-VcCf1TFY.js → cli-schema-sync-DFHBj6yZ.js} +59 -12
  5. package/dist/_chunks/{cli-schema-sync-VcCf1TFY.js.map → cli-schema-sync-DFHBj6yZ.js.map} +1 -1
  6. package/dist/_chunks/cli-schema-sync-vdobDWwX.js +2 -0
  7. package/dist/_chunks/{define-CPVQaWbx.js → define-BJMwm0c4.js} +6 -18
  8. package/dist/_chunks/define-BJMwm0c4.js.map +1 -0
  9. package/dist/_chunks/{handler-store-CcwNX-gk.js → handler-store-bIAstrxQ.js} +1 -1
  10. package/dist/_chunks/{handler-store-CcwNX-gk.js.map → handler-store-bIAstrxQ.js.map} +1 -1
  11. package/dist/_chunks/{merge-search-params-az6ZiBeU.js → merge-search-params-Cm1j99HC.js} +1 -1
  12. package/dist/_chunks/{merge-search-params-az6ZiBeU.js.map → merge-search-params-Cm1j99HC.js.map} +1 -1
  13. package/dist/_chunks/{navigation-root-DoRTeBm5.js → navigation-root-DaERBEGV.js} +2 -2
  14. package/dist/_chunks/{navigation-root-DoRTeBm5.js.map → navigation-root-DaERBEGV.js.map} +1 -1
  15. package/dist/_chunks/{resolve-schema-cURaYvaD.js → resolve-schema-DU2nbVOi.js} +1 -1
  16. package/dist/_chunks/{resolve-schema-cURaYvaD.js.map → resolve-schema-DU2nbVOi.js.map} +1 -1
  17. package/dist/_chunks/{router-ref-CpvjBqdu.js → router-ref-B2-2kIIC.js} +2 -2
  18. package/dist/_chunks/{router-ref-CpvjBqdu.js.map → router-ref-B2-2kIIC.js.map} +1 -1
  19. package/dist/_chunks/{segment-context-EsfQpS--.js → segment-context-Bg0pFbLT.js} +1 -1
  20. package/dist/_chunks/{segment-context-EsfQpS--.js.map → segment-context-Bg0pFbLT.js.map} +1 -1
  21. package/dist/_chunks/{ssr-data-m9-cNEIw.js → ssr-data-DVX8S7vj.js} +1 -1
  22. package/dist/_chunks/{ssr-data-m9-cNEIw.js.map → ssr-data-DVX8S7vj.js.map} +1 -1
  23. package/dist/_chunks/{use-segment-params-CtIAjqK0.js → use-segment-params-ByGqwUuO.js} +5 -9
  24. package/dist/_chunks/use-segment-params-ByGqwUuO.js.map +1 -0
  25. package/dist/_chunks/{validation-BMU5MPzP.js → validation-AfEFM5ze.js} +1 -1
  26. package/dist/_chunks/{validation-BMU5MPzP.js.map → validation-AfEFM5ze.js.map} +1 -1
  27. package/dist/_chunks/{walkers-tkw7sDlj.js → walkers-DMSGZeCk.js} +2 -2
  28. package/dist/_chunks/{walkers-tkw7sDlj.js.map → walkers-DMSGZeCk.js.map} +1 -1
  29. package/dist/cache/index.js +1 -1
  30. package/dist/cli-schema-sync.d.ts +6 -4
  31. package/dist/cli-schema-sync.d.ts.map +1 -1
  32. package/dist/cli.d.ts +1 -1
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +12 -6
  35. package/dist/cli.js.map +1 -1
  36. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  37. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  38. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  39. package/dist/client/error-boundary.js +2 -2
  40. package/dist/client/index.d.ts +1 -0
  41. package/dist/client/index.d.ts.map +1 -1
  42. package/dist/client/index.js +47 -14
  43. package/dist/client/index.js.map +1 -1
  44. package/dist/client/internal.js +87 -64
  45. package/dist/client/internal.js.map +1 -1
  46. package/dist/client/link.d.ts.map +1 -1
  47. package/dist/client/navigation-context.d.ts +1 -0
  48. package/dist/client/navigation-context.d.ts.map +1 -1
  49. package/dist/client/router.d.ts.map +1 -1
  50. package/dist/client/rsc-fetch.d.ts +8 -0
  51. package/dist/client/rsc-fetch.d.ts.map +1 -1
  52. package/dist/client/shallow-url.d.ts +10 -0
  53. package/dist/client/shallow-url.d.ts.map +1 -0
  54. package/dist/client/use-search-params.d.ts +7 -5
  55. package/dist/client/use-search-params.d.ts.map +1 -1
  56. package/dist/cookies/index.js +2 -2
  57. package/dist/index.js +3 -3
  58. package/dist/params/index.js +1 -1
  59. package/dist/routing/index.js +2 -2
  60. package/dist/segment-params/define.d.ts +0 -6
  61. package/dist/segment-params/define.d.ts.map +1 -1
  62. package/dist/segment-params/index.js +1 -1
  63. package/dist/server/index.js +1 -1
  64. package/dist/server/internal.js +3 -3
  65. package/package.json +6 -7
  66. package/src/cli-schema-sync.ts +80 -13
  67. package/src/cli.ts +18 -7
  68. package/src/client/browser-entry/action-dispatch.ts +9 -10
  69. package/src/client/browser-entry/hydrate.ts +2 -0
  70. package/src/client/browser-entry/post-hydration.ts +13 -6
  71. package/src/client/index.ts +1 -5
  72. package/src/client/link.tsx +25 -12
  73. package/src/client/navigation-context.ts +2 -1
  74. package/src/client/router.ts +78 -27
  75. package/src/client/rsc-fetch.ts +20 -5
  76. package/src/client/shallow-url.ts +22 -0
  77. package/src/client/use-search-params.ts +25 -44
  78. package/src/segment-params/define.ts +2 -29
  79. package/dist/_chunks/cli-schema-sync-4HcQIziG.js +0 -2
  80. package/dist/_chunks/define-CPVQaWbx.js.map +0 -1
  81. package/dist/_chunks/use-segment-params-CtIAjqK0.js.map +0 -1
@@ -215,6 +215,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
215
215
  const segmentElementCache = new SegmentElementCache();
216
216
 
217
217
  let routerPhase: RouterPhase = { phase: 'idle' };
218
+ let navigationId = 0;
218
219
  const pendingListeners = new Set<(pending: boolean) => void>();
219
220
 
220
221
  // AbortController for the current in-flight navigation.
@@ -287,24 +288,46 @@ export function createRouter(deps: RouterDeps): RouterInstance {
287
288
 
288
289
  /**
289
290
  * Merge a partial RSC payload with cached segment elements if segments
290
- * were skipped, then cache segments from the (merged) payload.
291
- * Returns the merged payload ready for rendering.
291
+ * were skipped. Returns the merged payload ready for rendering.
292
+ *
293
+ * Segment element caching is deferred to after paint by the caller
294
+ * so it doesn't block the render path.
292
295
  */
293
- function mergeAndCachePayload(
296
+ async function mergePayload(
294
297
  payload: unknown,
295
298
  skippedSegments: string[] | null | undefined
296
- ): unknown {
297
- let merged = payload;
298
-
299
- // If segments were skipped, merge the partial payload with cached segments
299
+ ): Promise<unknown> {
300
300
  if (skippedSegments && skippedSegments.length > 0) {
301
- merged = mergeSegmentTree(payload, skippedSegments, segmentElementCache);
301
+ // Segment merging needs a resolved React element tree for cloneElement.
302
+ // Await the payload only when merging is actually needed.
303
+ const resolved =
304
+ payload != null && typeof payload === 'object' && 'then' in payload
305
+ ? await (payload as PromiseLike<unknown>)
306
+ : payload;
307
+ return mergeSegmentTree(resolved, skippedSegments, segmentElementCache);
302
308
  }
309
+ // No merging needed — return the payload as-is (may be an unresolved
310
+ // Flight thenable). React handles progressive rendering via Suspense.
311
+ return payload;
312
+ }
303
313
 
304
- // Cache segment elements from the (merged) payload for future merges
305
- cacheSegmentElements(merged, segmentElementCache);
306
-
307
- return merged;
314
+ /**
315
+ * Schedule segment element caching after paint. Resolves thenable
316
+ * payloads first since tree walking requires resolved React elements.
317
+ * Guards against stale writes: if a newer navigation started before
318
+ * this callback fires, the cache write is skipped.
319
+ */
320
+ function deferSegmentCaching(payload: unknown): void {
321
+ const capturedNavId = navigationId;
322
+ afterPaint(async () => {
323
+ if (capturedNavId !== navigationId) return;
324
+ const resolved =
325
+ payload != null && typeof payload === 'object' && 'then' in payload
326
+ ? await (payload as PromiseLike<unknown>)
327
+ : payload;
328
+ if (capturedNavId !== navigationId) return;
329
+ cacheSegmentElements(resolved, segmentElementCache);
330
+ });
308
331
  }
309
332
 
310
333
  /**
@@ -323,8 +346,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
323
346
  // Module-level fallback for tests (no NavigationProvider) and SSR
324
347
  setCurrentParams(resolvedParams);
325
348
  // globalThis bridge — kept for backward compat
326
- const pathname = url.startsWith('http') ? new URL(url).pathname : url.split('?')[0] || '/';
327
- const navState: NavigationState = { params: resolvedParams, pathname };
349
+ const parsed = new URL(url, 'http://localhost');
350
+ const pathname = parsed.pathname || '/';
351
+ const search = parsed.search;
352
+ const navState: NavigationState = { params: resolvedParams, pathname, search };
328
353
  setNavigationState(navState);
329
354
  return navState;
330
355
  }
@@ -335,17 +360,27 @@ export function createRouter(deps: RouterDeps): RouterInstance {
335
360
  * FetchResult plus the NavigationState (so it can be passed explicitly
336
361
  * to wrapPayload/renderRoot without temporal coupling).
337
362
  */
363
+ interface TransitionResult {
364
+ headElements: HeadElement[] | null;
365
+ decodePromise: Promise<void> | null;
366
+ }
367
+
338
368
  async function renderViaTransition(
339
369
  url: string,
340
370
  perform: () => Promise<FetchResult & { navState: NavigationState }>
341
- ): Promise<HeadElement[] | null> {
371
+ ): Promise<TransitionResult> {
342
372
  if (deps.navigateTransition) {
343
373
  let headElements: HeadElement[] | null = null;
374
+ let decodePromise: Promise<void> | null = null;
344
375
  await deps.navigateTransition(url, async (wrapPayload) => {
345
376
  const result = await perform();
346
377
  headElements = result.headElements;
347
- // Merge partial payload with cached segments before wrapping
348
- const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
378
+ decodePromise = result.decodePromise;
379
+ // Merge partial payload with cached segments before wrapping.
380
+ // When no segments were skipped, mergePayload returns the payload
381
+ // as-is (possibly still an unresolved Flight thenable — React
382
+ // handles progressive rendering via Suspense).
383
+ const merged = await mergePayload(result.payload, result.skippedSegments);
349
384
  // Store the MERGED payload in history — not the partial pre-merge tree.
350
385
  // This ensures handlePopState replays the complete tree on back/forward.
351
386
  historyStack.push(url, {
@@ -353,24 +388,28 @@ export function createRouter(deps: RouterDeps): RouterInstance {
353
388
  headElements: result.headElements,
354
389
  params: result.params,
355
390
  });
391
+ // Defer segment element caching to after paint — it only benefits
392
+ // future navigations and doesn't need to block the current render.
393
+ deferSegmentCaching(merged);
356
394
  // Pass navState explicitly — wrapPayload wraps element in
357
395
  // NavigationProvider with this state, no getNavigationState() needed.
358
396
  return wrapPayload(merged, result.navState);
359
397
  });
360
- return headElements;
398
+ return { headElements, decodePromise };
361
399
  }
362
400
  // Fallback: no transition (tests, no React tree)
363
401
  const result = await perform();
364
402
  // Merge partial payload with cached segments before rendering
365
- const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
403
+ const merged = await mergePayload(result.payload, result.skippedSegments);
366
404
  // Store merged payload in history
367
405
  historyStack.push(url, {
368
406
  payload: merged,
369
407
  headElements: result.headElements,
370
408
  params: result.params,
371
409
  });
410
+ deferSegmentCaching(merged);
372
411
  renderPayload(merged, result.navState);
373
- return result.headElements;
412
+ return { headElements: result.headElements, decodePromise: result.decodePromise };
374
413
  }
375
414
 
376
415
  /** Apply head elements (title, meta tags) to the DOM if available. */
@@ -414,6 +453,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
414
453
  let result: FetchResult | undefined = prefetched
415
454
  ? {
416
455
  payload: prefetched.payload,
456
+ decodePromise: null,
417
457
  headElements: prefetched.headElements,
418
458
  segmentInfo: prefetched.segmentInfo ?? null,
419
459
  params: prefetched.params ?? null,
@@ -466,6 +506,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
466
506
  const externalSignal = options._signal as AbortSignal | undefined;
467
507
  const skipHistory = options._skipHistory === true;
468
508
 
509
+ navigationId++;
510
+
469
511
  // Create an abort controller for this navigation. Links to the external
470
512
  // signal (Navigation API's event.signal) when provided.
471
513
  const navAbort = createNavAbort(externalSignal);
@@ -499,7 +541,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
499
541
  setPending(true, url);
500
542
 
501
543
  try {
502
- const headElements = await renderViaTransition(url, () =>
544
+ const { headElements, decodePromise } = await renderViaTransition(url, () =>
503
545
  performNavigationFetch(url, {
504
546
  replace,
505
547
  signal: navAbort.signal,
@@ -507,6 +549,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
507
549
  })
508
550
  );
509
551
 
552
+ // Await async RSC decode errors (truncated streams, Flight parse failures)
553
+ // so they surface through this try/catch rather than becoming unhandled.
554
+ if (decodePromise) await decodePromise;
555
+
510
556
  // Update document.title and <meta> tags with the new page's metadata
511
557
  applyHead(headElements);
512
558
 
@@ -575,12 +621,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
575
621
 
576
622
  async function refresh(): Promise<void> {
577
623
  const currentUrl = deps.getCurrentUrl();
624
+ navigationId++;
578
625
  const navAbort = createNavAbort();
579
626
 
580
627
  setPending(true, currentUrl);
581
628
 
582
629
  try {
583
- const headElements = await renderViaTransition(currentUrl, async () => {
630
+ const { headElements, decodePromise } = await renderViaTransition(currentUrl, async () => {
584
631
  // No state tree sent — server renders the complete RSC payload
585
632
  const result = await fetchRscPayload(
586
633
  currentUrl,
@@ -595,6 +642,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
595
642
  return { ...result, navState };
596
643
  });
597
644
 
645
+ if (decodePromise) await decodePromise;
598
646
  applyHead(headElements);
599
647
  } catch (error) {
600
648
  // Stale transition (superseded by a newer navigation) or aborted
@@ -615,6 +663,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
615
663
  scrollY: number = 0,
616
664
  externalSignal?: AbortSignal
617
665
  ): Promise<void> {
666
+ navigationId++;
618
667
  // Scroll position is read from history.state by the caller (browser-entry.ts)
619
668
  // and passed in. This is more reliable than tracking scroll per-URL in memory
620
669
  // because the browser maintains per-entry state even with duplicate URLs.
@@ -634,7 +683,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
634
683
  const navAbort = createNavAbort(externalSignal);
635
684
  setPending(true, url);
636
685
  try {
637
- const headElements = await renderViaTransition(url, async () => {
686
+ const { headElements, decodePromise } = await renderViaTransition(url, async () => {
638
687
  const stateTree = segmentCache.serializeStateTree(
639
688
  segmentElementCache.getMergeablePaths()
640
689
  );
@@ -645,6 +694,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
645
694
  return { ...result, navState };
646
695
  });
647
696
 
697
+ if (decodePromise) await decodePromise;
648
698
  applyHead(headElements);
649
699
  restoreScrollAfterPaint(scrollY);
650
700
  } catch (error) {
@@ -697,18 +747,19 @@ export function createRouter(deps: RouterDeps): RouterInstance {
697
747
  // Render the piggybacked element tree from a server action response.
698
748
  // Updates the current history entry with the fresh payload and applies
699
749
  // head elements — same as refresh() but without a server fetch.
700
- // Cache segment elements for future partial merges.
750
+ // Revalidation payloads are already resolved (no Flight thenable),
751
+ // so no segment merging is needed — just cache and render.
701
752
  const currentUrl = deps.getCurrentUrl();
702
- const merged = mergeAndCachePayload(element, null);
703
753
  historyStack.push(currentUrl, {
704
- payload: merged,
754
+ payload: element,
705
755
  headElements,
706
756
  });
757
+ deferSegmentCaching(element);
707
758
  // Revalidation doesn't change params/pathname — preserve current state.
708
759
  // DO NOT call updateNavigationState(null, ...) here: that normalizes
709
760
  // params to {}, clearing dynamic route params on every action response.
710
761
  const navState = getNavigationState();
711
- renderPayload(merged, navState);
762
+ renderPayload(element, navState);
712
763
  applyHead(headElements);
713
764
  },
714
765
  initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
@@ -20,6 +20,14 @@ import type { RouterDeps } from './router';
20
20
  /** Result of fetching an RSC payload — includes head elements and segment metadata. */
21
21
  export interface FetchResult {
22
22
  payload: unknown;
23
+ /**
24
+ * Promise that settles when the RSC decode completes or fails.
25
+ * The payload thenable is NOT awaited before returning (for streaming),
26
+ * so callers must monitor this to catch async decode errors
27
+ * (truncated streams, Flight parse failures) that would otherwise
28
+ * become unhandled rejections.
29
+ */
30
+ decodePromise: Promise<void> | null;
23
31
  headElements: HeadElement[] | null;
24
32
  /** Segment metadata from X-Timber-Segments header for populating the segment cache. */
25
33
  segmentInfo: SegmentInfo[] | null;
@@ -313,11 +321,17 @@ export async function fetchRscPayload(
313
321
  });
314
322
  // Await headers so headElements/segmentInfo/params are populated.
315
323
  await wrappedPromise;
316
- // Await the decoded payload — createFromFetch returns a thenable
317
- // that resolves to the React element tree once the Flight stream
318
- // has enough data to produce the shell.
319
- const payload = await deps.decodeRsc(wrappedPromise);
320
- return { payload, headElements, segmentInfo, params, skippedSegments };
324
+ // Start decoding but do NOT await — return the in-progress thenable.
325
+ // React can render a Flight thenable directly: it suspends on unresolved
326
+ // parts and progressively renders as chunks arrive, spreading work across
327
+ // frames instead of blocking the main thread in one burst.
328
+ const payload = deps.decodeRsc(wrappedPromise);
329
+ // Track decode completion so callers can catch async errors (truncated
330
+ // streams, Flight parse failures) that fire after fetchRscPayload returns.
331
+ const decodePromise = Promise.resolve(payload).then(() => undefined);
332
+ // Prevent unhandled rejection — the router monitors decodePromise separately.
333
+ decodePromise.catch(() => {});
334
+ return { payload, decodePromise, headElements, segmentInfo, params, skippedSegments };
321
335
  }
322
336
  // Test/fallback path: return raw text
323
337
  const response = await deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
@@ -330,6 +344,7 @@ export async function fetchRscPayload(
330
344
  }
331
345
  return {
332
346
  payload: await response.text(),
347
+ decodePromise: null,
333
348
  headElements: extractHeadElements(response),
334
349
  segmentInfo: extractSegmentInfo(response),
335
350
  params: extractParams(response),
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shallow URL replacement — update the browser URL bar without triggering
3
+ * RSC navigation, TopLoader, or any server round-trip.
4
+ *
5
+ * Uses the Navigation API's `info: { shallow: true }` when available (Chrome),
6
+ * which the navigate event handler intercepts with a no-op handler. Falls back
7
+ * to raw `history.replaceState` (Safari/Firefox — no navigate event fired).
8
+ */
9
+
10
+ import { getNavigationApi } from './navigation-api.js';
11
+
12
+ export function replaceUrl(url: string): void {
13
+ const nav = getNavigationApi();
14
+ if (nav) {
15
+ nav.navigate(url, {
16
+ history: 'replace',
17
+ info: { shallow: true },
18
+ });
19
+ } else {
20
+ history.replaceState(history.state, '', url);
21
+ }
22
+ }
@@ -4,9 +4,12 @@
4
4
  * Returns a read-only URLSearchParams instance reflecting the current
5
5
  * URL's query string. Updates when client-side navigation changes the URL.
6
6
  *
7
- * This is a thin wrapper over window.location.search, provided for
8
- * Next.js API compatibility (libraries like nuqs import useSearchParams
9
- * from next/navigation).
7
+ * On the client, reads from NavigationContext which is updated atomically
8
+ * with the RSC tree render. This replaces the previous useSyncExternalStore
9
+ * approach which read window.location.search directly — causing React to
10
+ * detect external store tearing during transitions and fall back to
11
+ * synchronous rendering (renderRootSync instead of renderRootConcurrent),
12
+ * blocking the main thread and freezing animations.
10
13
  *
11
14
  * Unlike Next.js's ReadonlyURLSearchParams, this returns a standard
12
15
  * URLSearchParams. Mutation methods (set, delete, append) work on the
@@ -15,42 +18,14 @@
15
18
  * During SSR, reads the request search params from the SSR ALS context
16
19
  * (populated by ssr-entry.ts) instead of window.location.
17
20
  *
18
- * All mutable state is delegated to client/state.ts for singleton guarantees.
19
- * See design/18-build-system.md §"Singleton State Registry"
21
+ * Compatible with Next.js's `useSearchParams()` from `next/navigation`.
20
22
  */
21
23
 
22
- import { useSyncExternalStore } from 'react';
23
24
  import { getSsrData } from './ssr-data.js';
25
+ import { useNavigationContext } from './navigation-context.js';
24
26
  import { cachedSearch, cachedSearchParams, _setCachedSearch } from './state.js';
25
27
 
26
- function getSearch(): string {
27
- if (typeof window !== 'undefined') return window.location.search;
28
- const data = getSsrData();
29
- if (!data) return '';
30
- const sp = new URLSearchParams(data.searchParams);
31
- const str = sp.toString();
32
- return str ? `?${str}` : '';
33
- }
34
-
35
- function getServerSearch(): string {
36
- const data = getSsrData();
37
- if (!data) return '';
38
- const sp = new URLSearchParams(data.searchParams);
39
- const str = sp.toString();
40
- return str ? `?${str}` : '';
41
- }
42
-
43
- function subscribe(callback: () => void): () => void {
44
- window.addEventListener('popstate', callback);
45
- return () => window.removeEventListener('popstate', callback);
46
- }
47
-
48
- // Cache the last search string and its parsed URLSearchParams to avoid
49
- // creating a new object on every render when the URL hasn't changed.
50
- // State lives in client/state.ts for singleton guarantees.
51
-
52
- function getSearchParams(): URLSearchParams {
53
- const search = getSearch();
28
+ function getSearchParams(search: string): URLSearchParams {
54
29
  if (search !== cachedSearch) {
55
30
  const params = new URLSearchParams(search);
56
31
  _setCachedSearch(search, params);
@@ -59,20 +34,26 @@ function getSearchParams(): URLSearchParams {
59
34
  return cachedSearchParams;
60
35
  }
61
36
 
62
- function getServerSearchParams(): URLSearchParams {
63
- const data = getSsrData();
64
- return data ? new URLSearchParams(data.searchParams) : new URLSearchParams();
65
- }
66
-
67
37
  /**
68
38
  * Read the current URL search params.
69
39
  *
70
40
  * Compatible with Next.js's `useSearchParams()` from `next/navigation`.
71
41
  */
72
42
  export function useSearchParams(): URLSearchParams {
73
- // useSyncExternalStore needs a primitive snapshot for comparison.
74
- // We use the raw search string as the snapshot, then return the
75
- // parsed URLSearchParams.
76
- useSyncExternalStore(subscribe, getSearch, getServerSearch);
77
- return typeof window !== 'undefined' ? getSearchParams() : getServerSearchParams();
43
+ try {
44
+ const navContext = useNavigationContext();
45
+ if (navContext !== null) {
46
+ return getSearchParams(navContext.search);
47
+ }
48
+ } catch {
49
+ // No React dispatcher available (called outside a component).
50
+ }
51
+
52
+ // SSR path: read from ALS-backed SSR data context.
53
+ const ssrData = getSsrData();
54
+ if (ssrData) return new URLSearchParams(ssrData.searchParams);
55
+
56
+ // Final fallback: window.location (tests, edge cases).
57
+ if (typeof window !== 'undefined') return getSearchParams(window.location.search);
58
+ return new URLSearchParams();
78
59
  }
@@ -20,6 +20,7 @@ import {
20
20
  isStandardSchema,
21
21
  isCodec,
22
22
  } from '../schema-bridge.js';
23
+ import { useSegmentParams as clientUseSegmentParams } from '../client/use-segment-params.js';
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Server-only ALS reference for .get()
@@ -28,28 +29,6 @@ import {
28
29
  // Same pattern as search-params: eagerly registered at server startup
29
30
  // to avoid dynamic imports that lose ALS context. See TIM-523.
30
31
  let _getSegmentParamsFn: (() => Record<string, string | string[]>) | undefined;
31
- let _useSegmentParamsHook: (() => Record<string, string | string[]>) | undefined;
32
-
33
- /**
34
- * Register the client useSegmentParams hook.
35
- * Called by client entry at module load time to avoid require() at call time.
36
- * @internal
37
- */
38
- export function _registerUseSegmentParams(hook: () => Record<string, string | string[]>): void {
39
- _useSegmentParamsHook = hook;
40
- }
41
-
42
- // Self-register on the client when the barrel hasn't been imported yet.
43
- // Handles the edge case where a client component imports only from params.ts
44
- // without importing anything from @timber-js/app/client (which runs the
45
- // registration). The dynamic import resolves before React hydration in Vite.
46
- if (typeof window !== 'undefined' && !_useSegmentParamsHook) {
47
- import('../client/use-segment-params.js').then((mod) => {
48
- if (!_useSegmentParamsHook) {
49
- _useSegmentParamsHook = mod.useSegmentParams;
50
- }
51
- });
52
- }
53
32
 
54
33
  /**
55
34
  * Register the getSegmentParams function. Called once at module load time
@@ -311,13 +290,7 @@ export function defineSegmentParams<C extends Record<string, ParamField>>(
311
290
  // Client hook that reads typed params from the navigation context.
312
291
  // Delegates to the existing useSegmentParams hook from use-segment-params.ts.
313
292
  function useSegmentParams(): T {
314
- if (!_useSegmentParamsHook) {
315
- throw new Error(
316
- '[timber] segmentParams.useSegmentParams() requires @timber-js/app/client to be loaded. ' +
317
- 'This hook can only be used in client components.'
318
- );
319
- }
320
- const raw = _useSegmentParamsHook();
293
+ const raw = clientUseSegmentParams();
321
294
  // Params are already coerced by the server pipeline before being sent
322
295
  // to the client — return as-is. Re-parsing would break non-idempotent
323
296
  // codecs (e.g., z.coerce.number() on an already-parsed number). See TIM-574.
@@ -1,2 +0,0 @@
1
- import { i as runSchemaSync } from "./cli-schema-sync-VcCf1TFY.js";
2
- export { runSchemaSync };
@@ -1 +0,0 @@
1
- {"version":3,"file":"define-CPVQaWbx.js","names":[],"sources":["../../src/segment-params/define.ts"],"sourcesContent":["/**\n * defineSegmentParams — factory for typed route param coercion.\n *\n * Creates a ParamsDefinition that coerces raw string params from the\n * URL into typed values. Used by exporting from params.ts (segment-level)\n * convention file.\n *\n * Reuses the shared Codec<T> protocol with Standard Schema auto-detection,\n * same pattern as defineSearchParams. Runtime constraints are stricter:\n * - serialize must return string (not null — path segments can't be omitted)\n * - parse throwing → 404 (invalid param value)\n *\n * Design doc: design/07a-route-params-triage.md\n */\n\nimport type { Codec } from '../codec.js';\nimport {\n type StandardSchemaV1,\n validateSync,\n isStandardSchema,\n isCodec,\n} from '../schema-bridge.js';\n\n// ---------------------------------------------------------------------------\n// Server-only ALS reference for .get()\n// ---------------------------------------------------------------------------\n\n// Same pattern as search-params: eagerly registered at server startup\n// to avoid dynamic imports that lose ALS context. See TIM-523.\nlet _getSegmentParamsFn: (() => Record<string, string | string[]>) | undefined;\nlet _useSegmentParamsHook: (() => Record<string, string | string[]>) | undefined;\n\n/**\n * Register the client useSegmentParams hook.\n * Called by client entry at module load time to avoid require() at call time.\n * @internal\n */\nexport function _registerUseSegmentParams(hook: () => Record<string, string | string[]>): void {\n _useSegmentParamsHook = hook;\n}\n\n// Self-register on the client when the barrel hasn't been imported yet.\n// Handles the edge case where a client component imports only from params.ts\n// without importing anything from @timber-js/app/client (which runs the\n// registration). The dynamic import resolves before React hydration in Vite.\nif (typeof window !== 'undefined' && !_useSegmentParamsHook) {\n import('../client/use-segment-params.js').then((mod) => {\n if (!_useSegmentParamsHook) {\n _useSegmentParamsHook = mod.useSegmentParams;\n }\n });\n}\n\n/**\n * Register the getSegmentParams function. Called once at module load time\n * from request-context.ts to avoid dynamic import at call time.\n * @internal\n */\nexport function _setGetSegmentParamsFn(fn: () => Record<string, string | string[]>): void {\n _getSegmentParamsFn = fn;\n}\n\nfunction getSegmentParamsFromAls(): Record<string, string | string[]> {\n if (!_getSegmentParamsFn) {\n throw new Error(\n '[timber] segmentParams.get() is only available on the server. ' +\n 'Use segmentParams.useSegmentParams() on the client.'\n );\n }\n return _getSegmentParamsFn();\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Infer the output type from a Codec or StandardSchemaV1. */\nexport type InferParamField<V> =\n V extends Codec<infer T> ? T : V extends StandardSchemaV1<infer T> ? T : never;\n\n/** Acceptable field value for defineParams: a Codec or a Standard Schema. */\nexport type ParamField<T = unknown> = Codec<T> | StandardSchemaV1<T>;\n\nexport type { StandardSchemaV1 };\n\n/**\n * A typed route params definition.\n *\n * Returned by defineParams(). Provides parse (string → typed) and\n * serialize (typed → string) for each declared param.\n */\nexport interface ParamsDefinition<T extends Record<string, unknown>> {\n /** Parse raw string params into typed values. Throws on invalid values. */\n parse(raw: Record<string, string | string[]>): T;\n\n /** Serialize typed values back to strings for URL construction. */\n serialize(values: T): Record<string, string>;\n\n /**\n * Get typed segment params from the current request context (ALS).\n *\n * Server-only, sync.\n *\n * ```ts\n * // app/products/[id]/params.ts\n * export const segmentParams = defineSegmentParams({ id: z.coerce.number() })\n *\n * // app/products/[id]/page.tsx\n * import { segmentParams } from './params'\n * export default function Page() {\n * const { id } = segmentParams.get() // id: number\n * }\n * ```\n */\n get(): T;\n\n /**\n * Client hook for accessing typed segment params reactively.\n *\n * ```tsx\n * 'use client'\n * import { segmentParams } from './params'\n * export function ProductHeader() {\n * const { id } = segmentParams.useSegmentParams()\n * }\n * ```\n */\n useSegmentParams(): T;\n\n /** Read-only codec map. */\n codecs: { [K in keyof T]: Codec<T[K]> };\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Wrap a Standard Schema into a Codec for route params.\n *\n * Unlike fromSchema for search params:\n * - Parse throws on failure (no fallback to default)\n * - Serialize returns string (not null)\n */\nfunction fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {\n return {\n parse(value: string | string[] | undefined): T {\n // Route params are always strings (single segment) or string[] (catch-all)\n const input = Array.isArray(value) ? value : value;\n\n const result = validateSync(schema, input);\n if (!result.issues) {\n return result.value;\n }\n\n // For route params, parse failure means the param is invalid → throw\n const messages = result.issues.map((i) => i.message).join(', ');\n throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);\n },\n\n serialize(value: T): string | null {\n if (value === null || value === undefined) {\n return null;\n }\n // Catch-all segments produce arrays — join with '/' for path reconstruction\n if (Array.isArray(value)) {\n return value.join('/');\n }\n return String(value);\n },\n };\n}\n\n/**\n * Resolve a field value to a Codec. Auto-detects Standard Schema objects.\n */\nfunction resolveField(fieldName: string, value: ParamField): Codec<unknown> {\n if (isCodec(value)) {\n return value;\n }\n\n if (isStandardSchema(value)) {\n return fromParamSchema(fieldName, value);\n }\n\n throw new Error(\n `[timber] defineSegmentParams: field '${fieldName}' is not a valid codec or Standard Schema. ` +\n `Expected an object with { parse, serialize } methods, or a Standard Schema object ` +\n `(Zod, Valibot, ArkType).`\n );\n}\n\n/**\n * Validate that no codec's serialize returns null.\n * Route params are structural — they must produce a valid path segment.\n */\nfunction validateSerialize(codecMap: Record<string, Codec<unknown>>): void {\n for (const [key, codec] of Object.entries(codecMap)) {\n // Test serialize with a sample parsed value to check for null\n // We can't exhaustively test, but we can check that serialize(parse(\"test\"))\n // doesn't return null for a basic input.\n try {\n const testValue = codec.parse('test');\n const serialized = codec.serialize(testValue);\n if (serialized === null) {\n throw new Error(\n `[timber] defineSegmentParams: field '${key}' codec.serialize() returned null.\\n` +\n ` Route params are path segments — they cannot be omitted.\\n` +\n ` Ensure serialize() always returns a string.`\n );\n }\n } catch (e) {\n // parse('test') may throw for strict codecs (e.g., number-only).\n // That's fine — it means the codec validates. We only care about\n // serialize returning null, which we can't test without a valid value.\n if (e instanceof Error && e.message.includes('returned null')) {\n throw e;\n }\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Create a ParamsDefinition from a map of codecs and/or Standard Schema objects.\n *\n * ```ts\n * // app/products/[id]/layout.tsx\n * import { defineSegmentParams } from '@timber-js/app/segment-params'\n * import { z } from 'zod/v4'\n *\n * export const segmentParams = defineSegmentParams({\n * id: z.coerce.number().int().positive(),\n * })\n * ```\n */\nexport function defineSegmentParams<C extends Record<string, ParamField>>(\n codecs: C\n): ParamsDefinition<{ [K in keyof C]: InferParamField<C[K]> }> {\n type T = { [K in keyof C]: InferParamField<C[K]> };\n\n const resolvedCodecs: Record<string, Codec<unknown>> = {};\n\n for (const [key, value] of Object.entries(codecs)) {\n resolvedCodecs[key] = resolveField(key, value as ParamField);\n }\n\n // Validate that serialize doesn't return null\n validateSerialize(resolvedCodecs);\n\n // ---- parse ----\n function parse(raw: Record<string, string | string[]>): T {\n const result: Record<string, unknown> = {};\n\n for (const [key, codec] of Object.entries(resolvedCodecs)) {\n const rawValue = raw[key];\n // Skip params not present in the URL at this route depth.\n // This supports TIM-834 inheritance: a parent params.ts can declare\n // codecs for descendant segments. At shallower depths those params\n // don't exist yet — skip them instead of passing undefined to the codec.\n if (rawValue === undefined) continue;\n result[key] = codec.parse(rawValue);\n }\n\n return result as T;\n }\n\n // ---- serialize ----\n function serialize(values: T): Record<string, string> {\n const result: Record<string, string> = {};\n\n for (const [key, codec] of Object.entries(resolvedCodecs)) {\n const serialized = codec.serialize(values[key as keyof T] as unknown);\n if (serialized === null) {\n throw new Error(\n `[timber] params.serialize: field '${key}' serialized to null. ` +\n `Route params must produce a valid path segment.`\n );\n }\n result[key] = serialized;\n }\n\n return result;\n }\n\n // ---- get ----\n // ALS-backed: reads segment params from the current request context.\n // Server-only, sync.\n //\n // The pipeline already coerces params via coerceSegmentParams() which\n // calls parse() and stores typed values in ALS via setSegmentParams().\n // We return those directly instead of re-parsing, because codecs may\n // not be idempotent (e.g., a codec that only accepts raw strings would\n // throw if given an already-parsed value). See TIM-574.\n function get(): T {\n if (typeof window !== 'undefined') {\n throw new Error(\n '[timber] segmentParams.get() is server-only. ' +\n 'Use segmentParams.useSegmentParams() on the client.'\n );\n }\n const params = getSegmentParamsFromAls();\n // params are already coerced by the pipeline — return as-is.\n return params as unknown as T;\n }\n\n // ---- useSegmentParams ----\n // Client hook that reads typed params from the navigation context.\n // Delegates to the existing useSegmentParams hook from use-segment-params.ts.\n function useSegmentParams(): T {\n if (!_useSegmentParamsHook) {\n throw new Error(\n '[timber] segmentParams.useSegmentParams() requires @timber-js/app/client to be loaded. ' +\n 'This hook can only be used in client components.'\n );\n }\n const raw = _useSegmentParamsHook();\n // Params are already coerced by the server pipeline before being sent\n // to the client — return as-is. Re-parsing would break non-idempotent\n // codecs (e.g., z.coerce.number() on an already-parsed number). See TIM-574.\n return raw as unknown as T;\n }\n\n const definition: ParamsDefinition<T> = {\n parse,\n serialize,\n get,\n useSegmentParams,\n codecs: resolvedCodecs as { [K in keyof T]: Codec<T[K]> },\n };\n\n return definition;\n}\n"],"mappings":";;AA6BA,IAAI;AACJ,IAAI;;;;;;AAOJ,SAAgB,0BAA0B,MAAqD;AAC7F,yBAAwB;;AAO1B,IAAI,OAAO,WAAW,eAAe,CAAC,sBACpC,QAAO,oCAAA,MAAA,MAAA,EAAA,EAAA,CAAmC,MAAM,QAAQ;AACtD,KAAI,CAAC,sBACH,yBAAwB,IAAI;EAE9B;;;;;;AAQJ,SAAgB,uBAAuB,IAAmD;AACxF,uBAAsB;;AAGxB,SAAS,0BAA6D;AACpE,KAAI,CAAC,oBACH,OAAM,IAAI,MACR,oHAED;AAEH,QAAO,qBAAqB;;;;;;;;;AA2E9B,SAAS,gBAAmB,WAAmB,QAAuC;AACpF,QAAO;EACL,MAAM,OAAyC;GAI7C,MAAM,SAAS,aAAa,QAFd,MAAM,QAAQ,MAAM,GAAG,QAAQ,MAEH;AAC1C,OAAI,CAAC,OAAO,OACV,QAAO,OAAO;GAIhB,MAAM,WAAW,OAAO,OAAO,KAAK,MAAM,EAAE,QAAQ,CAAC,KAAK,KAAK;AAC/D,SAAM,IAAI,MAAM,mBAAmB,UAAU,qBAAqB,WAAW;;EAG/E,UAAU,OAAyB;AACjC,OAAI,UAAU,QAAQ,UAAU,KAAA,EAC9B,QAAO;AAGT,OAAI,MAAM,QAAQ,MAAM,CACtB,QAAO,MAAM,KAAK,IAAI;AAExB,UAAO,OAAO,MAAM;;EAEvB;;;;;AAMH,SAAS,aAAa,WAAmB,OAAmC;AAC1E,KAAI,QAAQ,MAAM,CAChB,QAAO;AAGT,KAAI,iBAAiB,MAAM,CACzB,QAAO,gBAAgB,WAAW,MAAM;AAG1C,OAAM,IAAI,MACR,wCAAwC,UAAU,uJAGnD;;;;;;AAOH,SAAS,kBAAkB,UAAgD;AACzE,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,SAAS,CAIjD,KAAI;EACF,MAAM,YAAY,MAAM,MAAM,OAAO;AAErC,MADmB,MAAM,UAAU,UAAU,KAC1B,KACjB,OAAM,IAAI,MACR,wCAAwC,IAAI,+IAG7C;UAEI,GAAG;AAIV,MAAI,aAAa,SAAS,EAAE,QAAQ,SAAS,gBAAgB,CAC3D,OAAM;;;;;;;;;;;;;;;;AAuBd,SAAgB,oBACd,QAC6D;CAG7D,MAAM,iBAAiD,EAAE;AAEzD,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,CAC/C,gBAAe,OAAO,aAAa,KAAK,MAAoB;AAI9D,mBAAkB,eAAe;CAGjC,SAAS,MAAM,KAA2C;EACxD,MAAM,SAAkC,EAAE;AAE1C,OAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,eAAe,EAAE;GACzD,MAAM,WAAW,IAAI;AAKrB,OAAI,aAAa,KAAA,EAAW;AAC5B,UAAO,OAAO,MAAM,MAAM,SAAS;;AAGrC,SAAO;;CAIT,SAAS,UAAU,QAAmC;EACpD,MAAM,SAAiC,EAAE;AAEzC,OAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,eAAe,EAAE;GACzD,MAAM,aAAa,MAAM,UAAU,OAAO,KAA2B;AACrE,OAAI,eAAe,KACjB,OAAM,IAAI,MACR,qCAAqC,IAAI,uEAE1C;AAEH,UAAO,OAAO;;AAGhB,SAAO;;CAYT,SAAS,MAAS;AAChB,MAAI,OAAO,WAAW,YACpB,OAAM,IAAI,MACR,mGAED;AAIH,SAFe,yBAAyB;;CAQ1C,SAAS,mBAAsB;AAC7B,MAAI,CAAC,sBACH,OAAM,IAAI,MACR,0IAED;AAMH,SAJY,uBAAuB;;AAerC,QARwC;EACtC;EACA;EACA;EACA;EACA,QAAQ;EACT"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"use-segment-params-CtIAjqK0.js","names":[],"sources":["../../src/client/navigation-context.ts","../../src/client/use-segment-params.ts"],"sourcesContent":["'use client';\n\n/**\n * NavigationContext — React context for navigation state.\n *\n * Holds the current route params and pathname, updated atomically\n * with the RSC tree on each navigation. This replaces the previous\n * useSyncExternalStore approach for useSegmentParams() and usePathname(),\n * which suffered from a timing gap: the new tree could commit before\n * the external store re-renders fired, causing a frame where both\n * old and new active states were visible simultaneously.\n *\n * By wrapping the RSC payload element in NavigationProvider inside\n * renderRoot(), the context value and the element tree are passed to\n * reactRoot.render() in the same call — atomic by construction.\n * All consumers (useParams, usePathname) see the new values in the\n * same render pass as the new tree.\n *\n * During SSR, no NavigationProvider is mounted. Hooks fall back to\n * the ALS-backed getSsrData() for per-request isolation.\n *\n * IMPORTANT: createContext and useContext are NOT available in the RSC\n * environment (React Server Components use a stripped-down React).\n * The context is lazily initialized on first access, and all functions\n * that depend on these APIs are safe to call from any environment —\n * they return null or no-op when the APIs aren't available.\n *\n * SINGLETON GUARANTEE: All shared mutable state uses globalThis via\n * Symbol.for keys. The RSC client bundler can duplicate this module\n * across chunks (browser-entry graph + client-reference graph). With\n * ESM output, each chunk gets its own module scope — module-level\n * variables would create separate singleton instances per chunk.\n * globalThis guarantees a single instance regardless of duplication.\n *\n * This workaround will be removed when Rolldown ships `format: 'app'`\n * (module registry format that deduplicates like webpack/Turbopack).\n * See design/27-chunking-strategy.md.\n *\n * See design/19-client-navigation.md §\"NavigationContext\"\n */\n\nimport React, { createElement, type ReactNode } from 'react';\n\n// ---------------------------------------------------------------------------\n// Context type\n// ---------------------------------------------------------------------------\n\nexport interface NavigationState {\n params: Record<string, string | string[]>;\n pathname: string;\n}\n\n// ---------------------------------------------------------------------------\n// Lazy context initialization\n// ---------------------------------------------------------------------------\n\n/**\n * The context is created lazily to avoid calling createContext at module\n * level. In the RSC environment, React.createContext doesn't exist —\n * calling it at import time would crash the server.\n *\n * Context instances are stored on globalThis (NOT in module-level\n * variables) because the ESM bundler can duplicate this module across\n * chunks. Module-level variables would create separate instances per\n * chunk — the provider in NavigationRoot (index chunk) would use\n * context A while the consumer in usePendingNavigation (shared chunk)\n * reads from context B. globalThis guarantees a single instance.\n *\n * See design/27-chunking-strategy.md §\"Singleton Safety\"\n *\n * NOTE: Despite similar naming, `usePendingNavigationUrl()` here is an\n * internal helper — the public hook is `usePendingNavigation()` in\n * use-pending-navigation.ts.\n */\n\n// Symbol keys for globalThis storage — prevents collisions with user code\nconst NAV_CTX_KEY = Symbol.for('__timber_nav_ctx');\nconst PENDING_CTX_KEY = Symbol.for('__timber_pending_nav_ctx');\n\nfunction getOrCreateContext(): React.Context<NavigationState | null> | undefined {\n const existing = (globalThis as Record<symbol, unknown>)[NAV_CTX_KEY] as\n | React.Context<NavigationState | null>\n | undefined;\n if (existing !== undefined) return existing;\n // createContext may not exist in the RSC environment\n if (typeof React.createContext === 'function') {\n const ctx = React.createContext<NavigationState | null>(null);\n (globalThis as Record<symbol, unknown>)[NAV_CTX_KEY] = ctx;\n return ctx;\n }\n return undefined;\n}\n\n/**\n * Read the navigation context. Returns null during SSR (no provider)\n * or in the RSC environment (no context available).\n * Internal — used by useSegmentParams() and usePathname().\n */\nexport function useNavigationContext(): NavigationState | null {\n const ctx = getOrCreateContext();\n if (!ctx) return null;\n // useContext may not exist in the RSC environment — caller wraps in try/catch\n if (typeof React.useContext !== 'function') return null;\n return React.useContext(ctx);\n}\n\n// ---------------------------------------------------------------------------\n// Provider component\n// ---------------------------------------------------------------------------\n\nexport interface NavigationProviderProps {\n value: NavigationState;\n children?: ReactNode;\n}\n\n/**\n * Wraps children with NavigationContext.Provider.\n *\n * Used in browser-entry.ts renderRoot to wrap the RSC payload element\n * so that navigation state updates atomically with the tree render.\n */\nexport function NavigationProvider({\n value,\n children,\n}: NavigationProviderProps): React.ReactElement {\n const ctx = getOrCreateContext();\n if (!ctx) {\n // RSC environment — no context available. Return children as-is.\n return children as React.ReactElement;\n }\n return createElement(ctx.Provider, { value }, children);\n}\n\n// ---------------------------------------------------------------------------\n// Module-level state for renderRoot to read\n// ---------------------------------------------------------------------------\n\n/**\n * Navigation state communicated between the router and renderRoot.\n *\n * The router calls setNavigationState() before renderRoot(). The\n * renderRoot callback reads via getNavigationState() to create the\n * NavigationProvider with the correct params/pathname.\n *\n * This is NOT used by hooks directly — hooks read from React context.\n *\n * Stored on globalThis (like the context instances above) because the\n * router lives in one chunk while renderRoot lives in another. Module-\n * level variables would be separate per chunk.\n */\nconst NAV_STATE_KEY = Symbol.for('__timber_nav_state');\n\nfunction _getNavStateStore(): { current: NavigationState } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[NAV_STATE_KEY]) {\n g[NAV_STATE_KEY] = { current: { params: {}, pathname: '/' } };\n }\n return g[NAV_STATE_KEY] as { current: NavigationState };\n}\n\nexport function setNavigationState(state: NavigationState): void {\n _getNavStateStore().current = state;\n}\n\nexport function getNavigationState(): NavigationState {\n return _getNavStateStore().current;\n}\n\n// ---------------------------------------------------------------------------\n// Pending Navigation Context (same module for singleton guarantee)\n// ---------------------------------------------------------------------------\n\n/**\n * Separate context for the in-flight navigation URL. Provided by\n * NavigationRoot (urgent useState), consumed by usePendingNavigation\n * and TopLoader. Per-link pending state uses useOptimistic instead\n * (see link-pending-store.ts).\n *\n * Uses globalThis via Symbol.for for the same reason as NavigationContext\n * above — the bundler may duplicate this module across chunks, and module-\n * level variables would create separate context instances.\n */\n\nfunction getOrCreatePendingContext(): React.Context<string | null> | undefined {\n const existing = (globalThis as Record<symbol, unknown>)[PENDING_CTX_KEY] as\n | React.Context<string | null>\n | undefined;\n if (existing !== undefined) return existing;\n if (typeof React.createContext === 'function') {\n const ctx = React.createContext<string | null>(null);\n (globalThis as Record<symbol, unknown>)[PENDING_CTX_KEY] = ctx;\n return ctx;\n }\n return undefined;\n}\n\n/**\n * Read the pending navigation URL from context.\n * Returns null during SSR (no provider) or in the RSC environment.\n */\nexport function usePendingNavigationUrl(): string | null {\n const ctx = getOrCreatePendingContext();\n if (!ctx) return null;\n if (typeof React.useContext !== 'function') return null;\n return React.useContext(ctx);\n}\n\n/**\n * Provider for the pending navigation URL. Wraps children with\n * the pending context Provider.\n */\nexport function PendingNavigationProvider({\n value,\n children,\n}: {\n value: string | null;\n children?: ReactNode;\n}): React.ReactElement {\n const ctx = getOrCreatePendingContext();\n if (!ctx) {\n return children as React.ReactElement;\n }\n return createElement(ctx.Provider, { value }, children);\n}\n\n// ---------------------------------------------------------------------------\n// Navigation API transition state (optional progressive enhancement)\n// ---------------------------------------------------------------------------\n\n/**\n * Check if the browser's Navigation API has an active transition.\n *\n * When the Navigation API is available and a navigation has been intercepted\n * via event.intercept(), `navigation.transition` is non-null until the\n * handler resolves. This provides browser-native progress tracking that\n * can be used alongside the existing pendingUrl mechanism.\n *\n * Returns false when Navigation API is unavailable or no transition is active.\n */\nexport function hasNativeNavigationTransition(): boolean {\n if (typeof window === 'undefined') return false;\n const nav = (window as unknown as { navigation?: { transition?: unknown } }).navigation;\n return nav?.transition != null;\n}\n","/**\n * useParams() — client-side hook for accessing route params.\n *\n * Returns the dynamic route parameters for the current URL.\n * When called with a route pattern argument, TypeScript narrows\n * the return type to the exact params shape for that route.\n *\n * Two layers of type narrowing work together:\n * 1. The generic overload here uses the Routes interface directly —\n * `useParams<R>()` returns `Routes[R]['segmentParams']`.\n * 2. Build-time codegen generates per-route string-literal overloads\n * in the .d.ts file for IDE autocomplete (see routing/codegen.ts).\n *\n * When the Routes interface is empty (no codegen yet), the generic\n * overload has `keyof Routes = never`, so only the fallback matches.\n *\n * During SSR, params are read from the ALS-backed SSR data context\n * (populated by ssr-entry.ts) to ensure correct per-request isolation\n * across concurrent requests with streaming Suspense.\n *\n * Reactivity: On the client, useParams() reads from NavigationContext\n * which is updated atomically with the RSC tree render. This replaces\n * the previous useSyncExternalStore approach that suffered from a\n * timing gap between tree render and store notification — causing\n * preserved layout components to briefly show stale active state.\n *\n * All mutable state is delegated to client/state.ts for singleton guarantees.\n * See design/18-build-system.md §\"Singleton State Registry\"\n *\n * Design doc: design/09-typescript.md §\"Typed Routes\"\n */\n\nimport type { Routes } from '../index.js';\nimport { getSsrData } from './ssr-data.js';\nimport { currentParams, _setCurrentParams, paramsListeners } from './state.js';\nimport { useNavigationContext } from './navigation-context.js';\n\n// ---------------------------------------------------------------------------\n// Module-level subscribe/notify pattern — kept for backward compat and tests\n// ---------------------------------------------------------------------------\n\n/**\n * Subscribe to params changes.\n * Retained for backward compatibility with tests that verify the\n * subscribe/notify contract. On the client, useParams() reads from\n * NavigationContext instead.\n */\nexport function subscribe(callback: () => void): () => void {\n paramsListeners.add(callback);\n return () => paramsListeners.delete(callback);\n}\n\n/**\n * Get the current params snapshot (module-level fallback).\n * Used by tests and by the hook when called outside a React component.\n */\nexport function getSnapshot(): Record<string, string | string[]> {\n return currentParams;\n}\n\n// ---------------------------------------------------------------------------\n// Framework API — called by the segment router on each navigation\n// ---------------------------------------------------------------------------\n\n/**\n * Set the current route params in the module-level store.\n *\n * Called by the router on each navigation. This updates the fallback\n * snapshot used by tests and by the hook when called outside a React\n * component (no NavigationContext available).\n *\n * On the client, the primary reactivity path is NavigationContext —\n * the router calls setNavigationState() then renderRoot() which wraps\n * the element in NavigationProvider. setCurrentParams is still called\n * for the module-level fallback.\n *\n * During SSR, params are also available via getSsrData().params\n * (ALS-backed).\n */\nexport function setCurrentParams(params: Record<string, string | string[]>): void {\n _setCurrentParams(params);\n}\n\n/**\n * Notify all legacy subscribers that params have changed.\n *\n * Retained for backward compatibility with tests. On the client,\n * the NavigationContext + renderRoot pattern replaces this — params\n * update atomically with the tree render, so explicit notification\n * is no longer needed.\n */\nexport function notifyParamsListeners(): void {\n for (const listener of paramsListeners) {\n listener();\n }\n}\n\n// ---------------------------------------------------------------------------\n// Public hook\n// ---------------------------------------------------------------------------\n\n/**\n * Read the current route's dynamic params.\n *\n * The optional `_route` argument exists only for TypeScript narrowing —\n * it does not affect the runtime return value.\n *\n * On the client, reads from NavigationContext (provided by\n * NavigationProvider in renderRoot). This ensures params update\n * atomically with the RSC tree — no timing gap.\n *\n * During SSR, reads from the ALS-backed SSR data context to ensure\n * per-request isolation across concurrent requests with streaming Suspense.\n *\n * When called outside a React component (e.g., in test assertions),\n * falls back to the module-level snapshot.\n *\n * @overload Typed — when a known segment path is passed, returns the\n * exact params shape from the generated Routes interface.\n * @overload Fallback — returns the generic params record.\n */\nexport function useSegmentParams<R extends keyof Routes>(\n segmentPath: R\n): Routes[R] extends { segmentParams: infer P } ? P : Record<string, string | string[]>;\nexport function useSegmentParams(segmentPath?: string): Record<string, string | string[]>;\nexport function useSegmentParams(_segmentPath?: string): Record<string, string | string[]> {\n // Try reading from NavigationContext (client-side, inside React tree).\n // During SSR, no NavigationProvider is mounted, so this returns null.\n // When called outside a React component, useContext throws — caught below.\n try {\n const navContext = useNavigationContext();\n if (navContext !== null) {\n return navContext.params;\n }\n } catch {\n // No React dispatcher available (called outside a component).\n // Fall through to module-level snapshot below.\n }\n\n // SSR path: read from ALS-backed SSR data context.\n // Falls back to module-level currentParams for tests.\n return getSsrData()?.params ?? currentParams;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4EA,IAAM,cAAc,OAAO,IAAI,mBAAmB;AAClD,IAAM,kBAAkB,OAAO,IAAI,2BAA2B;AAE9D,SAAS,qBAAwE;CAC/E,MAAM,WAAY,WAAuC;AAGzD,KAAI,aAAa,KAAA,EAAW,QAAO;AAEnC,KAAI,OAAO,MAAM,kBAAkB,YAAY;EAC7C,MAAM,MAAM,MAAM,cAAsC,KAAK;AAC5D,aAAuC,eAAe;AACvD,SAAO;;;;;;;;AAUX,SAAgB,uBAA+C;CAC7D,MAAM,MAAM,oBAAoB;AAChC,KAAI,CAAC,IAAK,QAAO;AAEjB,KAAI,OAAO,MAAM,eAAe,WAAY,QAAO;AACnD,QAAO,MAAM,WAAW,IAAI;;;;;;;;AAkB9B,SAAgB,mBAAmB,EACjC,OACA,YAC8C;CAC9C,MAAM,MAAM,oBAAoB;AAChC,KAAI,CAAC,IAEH,QAAO;AAET,QAAO,cAAc,IAAI,UAAU,EAAE,OAAO,EAAE,SAAS;;;;;;;;;;;;;;;AAoBzD,IAAM,gBAAgB,OAAO,IAAI,qBAAqB;AAEtD,SAAS,oBAAkD;CACzD,MAAM,IAAI;AACV,KAAI,CAAC,EAAE,eACL,GAAE,iBAAiB,EAAE,SAAS;EAAE,QAAQ,EAAE;EAAE,UAAU;EAAK,EAAE;AAE/D,QAAO,EAAE;;AAGX,SAAgB,mBAAmB,OAA8B;AAC/D,oBAAmB,CAAC,UAAU;;AAGhC,SAAgB,qBAAsC;AACpD,QAAO,mBAAmB,CAAC;;;;;;;;;;;;AAkB7B,SAAS,4BAAsE;CAC7E,MAAM,WAAY,WAAuC;AAGzD,KAAI,aAAa,KAAA,EAAW,QAAO;AACnC,KAAI,OAAO,MAAM,kBAAkB,YAAY;EAC7C,MAAM,MAAM,MAAM,cAA6B,KAAK;AACnD,aAAuC,mBAAmB;AAC3D,SAAO;;;;;;;AASX,SAAgB,0BAAyC;CACvD,MAAM,MAAM,2BAA2B;AACvC,KAAI,CAAC,IAAK,QAAO;AACjB,KAAI,OAAO,MAAM,eAAe,WAAY,QAAO;AACnD,QAAO,MAAM,WAAW,IAAI;;;;;;;;;;;;;;;;;;;;;;;AC7H9B,SAAgB,iBAAiB,QAAiD;AAChF,mBAAkB,OAAO;;AA6C3B,SAAgB,iBAAiB,cAA0D;AAIzF,KAAI;EACF,MAAM,aAAa,sBAAsB;AACzC,MAAI,eAAe,KACjB,QAAO,WAAW;SAEd;AAOR,QAAO,YAAY,EAAE,UAAU"}