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

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 (58) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{actions-Dy8vOjFt.js → actions-ZlcKx0H9.js} +5 -5
  3. package/dist/_chunks/{actions-Dy8vOjFt.js.map → actions-ZlcKx0H9.js.map} +1 -1
  4. package/dist/_chunks/{define-CPVQaWbx.js → define-C0V_eKiW.js} +6 -18
  5. package/dist/_chunks/define-C0V_eKiW.js.map +1 -0
  6. package/dist/_chunks/{handler-store-CcwNX-gk.js → handler-store-bIAstrxQ.js} +1 -1
  7. package/dist/_chunks/{handler-store-CcwNX-gk.js.map → handler-store-bIAstrxQ.js.map} +1 -1
  8. package/dist/_chunks/{merge-search-params-az6ZiBeU.js → merge-search-params-Cm1j99HC.js} +1 -1
  9. package/dist/_chunks/{merge-search-params-az6ZiBeU.js.map → merge-search-params-Cm1j99HC.js.map} +1 -1
  10. package/dist/_chunks/{navigation-root-DoRTeBm5.js → navigation-root-Ds_hDtRW.js} +2 -2
  11. package/dist/_chunks/{navigation-root-DoRTeBm5.js.map → navigation-root-Ds_hDtRW.js.map} +1 -1
  12. package/dist/_chunks/{resolve-schema-cURaYvaD.js → resolve-schema-DU2nbVOi.js} +1 -1
  13. package/dist/_chunks/{resolve-schema-cURaYvaD.js.map → resolve-schema-DU2nbVOi.js.map} +1 -1
  14. package/dist/_chunks/{router-ref-CpvjBqdu.js → router-ref-B2-2kIIC.js} +2 -2
  15. package/dist/_chunks/{router-ref-CpvjBqdu.js.map → router-ref-B2-2kIIC.js.map} +1 -1
  16. package/dist/_chunks/{segment-context-EsfQpS--.js → segment-context-Bg0pFbLT.js} +1 -1
  17. package/dist/_chunks/{segment-context-EsfQpS--.js.map → segment-context-Bg0pFbLT.js.map} +1 -1
  18. package/dist/_chunks/{ssr-data-m9-cNEIw.js → ssr-data-DVX8S7vj.js} +1 -1
  19. package/dist/_chunks/{ssr-data-m9-cNEIw.js.map → ssr-data-DVX8S7vj.js.map} +1 -1
  20. package/dist/_chunks/{use-segment-params-CtIAjqK0.js → use-segment-params-Du7u25cK.js} +3 -8
  21. package/dist/_chunks/{use-segment-params-CtIAjqK0.js.map → use-segment-params-Du7u25cK.js.map} +1 -1
  22. package/dist/_chunks/{validation-BMU5MPzP.js → validation-AfEFM5ze.js} +1 -1
  23. package/dist/_chunks/{validation-BMU5MPzP.js.map → validation-AfEFM5ze.js.map} +1 -1
  24. package/dist/cache/index.js +1 -1
  25. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  26. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  27. package/dist/client/error-boundary.js +2 -2
  28. package/dist/client/index.d.ts +1 -0
  29. package/dist/client/index.d.ts.map +1 -1
  30. package/dist/client/index.js +47 -14
  31. package/dist/client/index.js.map +1 -1
  32. package/dist/client/internal.js +67 -32
  33. package/dist/client/internal.js.map +1 -1
  34. package/dist/client/link.d.ts.map +1 -1
  35. package/dist/client/router.d.ts.map +1 -1
  36. package/dist/client/rsc-fetch.d.ts +8 -0
  37. package/dist/client/rsc-fetch.d.ts.map +1 -1
  38. package/dist/client/shallow-url.d.ts +10 -0
  39. package/dist/client/shallow-url.d.ts.map +1 -0
  40. package/dist/cookies/index.js +2 -2
  41. package/dist/index.js +1 -1
  42. package/dist/params/index.js +1 -1
  43. package/dist/segment-params/define.d.ts +0 -6
  44. package/dist/segment-params/define.d.ts.map +1 -1
  45. package/dist/segment-params/index.js +1 -1
  46. package/dist/server/index.js +1 -1
  47. package/dist/server/internal.js +3 -3
  48. package/package.json +6 -7
  49. package/src/cli.ts +0 -0
  50. package/src/client/browser-entry/action-dispatch.ts +9 -10
  51. package/src/client/browser-entry/post-hydration.ts +12 -6
  52. package/src/client/index.ts +1 -5
  53. package/src/client/link.tsx +25 -12
  54. package/src/client/router.ts +74 -25
  55. package/src/client/rsc-fetch.ts +20 -5
  56. package/src/client/shallow-url.ts +22 -0
  57. package/src/segment-params/define.ts +2 -29
  58. package/dist/_chunks/define-CPVQaWbx.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.122",
3
+ "version": "0.2.0-alpha.123",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -114,11 +114,6 @@
114
114
  "publishConfig": {
115
115
  "access": "public"
116
116
  },
117
- "scripts": {
118
- "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
119
- "typecheck": "tsgo --noEmit",
120
- "prepublishOnly": "pnpm run build"
121
- },
122
117
  "dependencies": {
123
118
  "@opentelemetry/api": "^1.9.1",
124
119
  "@opentelemetry/context-async-hooks": "^2.6.1",
@@ -162,5 +157,9 @@
162
157
  },
163
158
  "engines": {
164
159
  "node": ">=22.12.0"
160
+ },
161
+ "scripts": {
162
+ "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
163
+ "typecheck": "tsgo --noEmit"
165
164
  }
166
- }
165
+ }
package/src/cli.ts CHANGED
File without changes
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { setServerCallback, encodeReply, createFromFetch } from '../../rsc-runtime/browser.js';
18
- import { getRouter } from '#client-internal';
18
+ import { getRouter, getRouterOrNull } from '#client-internal';
19
19
  import { setHardNavigating } from '../navigation-root.js';
20
20
  import { isStaleClientReference, triggerStaleReload } from '../stale-reload.js';
21
21
  import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../rsc-fetch.js';
@@ -96,15 +96,14 @@ export function setupServerActions(): void {
96
96
  // Piggybacked response: wrapper object { _action, _tree }
97
97
  // Apply the revalidated tree directly — no separate router.refresh() needed.
98
98
  const wrapper = decoded as { _action: unknown; _tree: unknown };
99
- try {
100
- const router = getRouter();
101
- const headElements = headElementsJson ? JSON.parse(headElementsJson) : null;
102
- router.applyRevalidation(wrapper._tree, headElements);
103
- } catch (e) {
104
- console.debug(
105
- '[timber] action revalidation: router not ready',
106
- e instanceof Error ? e.message : e
107
- );
99
+ const router = getRouterOrNull();
100
+ if (router) {
101
+ try {
102
+ const headElements = headElementsJson ? JSON.parse(headElementsJson) : null;
103
+ router.applyRevalidation(wrapper._tree, headElements);
104
+ } catch (e) {
105
+ console.error('[timber] applyRevalidation failed after server action', e);
106
+ }
108
107
  }
109
108
  return wrapper._action;
110
109
  }
@@ -58,12 +58,18 @@ export function setupPostHydration({
58
58
  delete (self as unknown as Record<string, unknown>).__timber_segments;
59
59
  }
60
60
 
61
- // NOTE: We do NOT cache segment elements from the initial RSC payload here.
62
- // The decoded element from createFromReadableStream is a thenable/lazy
63
- // element that React resolves during render — the segment walker can't
64
- // traverse it. The element cache is populated lazily after the first SPA
65
- // navigation (via mergeAndCachePayload in renderViaTransition), when
66
- // the decoded payload is a fully resolved React element tree.
61
+ // Cache segment elements from the initial RSC payload after hydration.
62
+ // The element from createFromReadableStream is a Flight thenable that
63
+ // React resolves during hydration. Once resolved (status='fulfilled'),
64
+ // its .value contains the React element tree we can walk.
65
+ // Use requestAnimationFrame to run after hydration commits.
66
+ if (initialElement != null) {
67
+ requestAnimationFrame(() => {
68
+ const el = initialElement as { status?: string; value?: unknown };
69
+ const resolved = el.status === 'fulfilled' && el.value != null ? el.value : initialElement;
70
+ router.cacheElementTree(resolved);
71
+ });
72
+ }
67
73
 
68
74
  // If the hydration path was skipped (no RSC payload), populate the
69
75
  // fallback params from server embed here.
@@ -66,6 +66,7 @@ export type { LinkStatus } from './use-link-status';
66
66
  export { useRouter } from './use-router';
67
67
  export type { AppRouterInstance } from './use-router';
68
68
  export { usePathname } from './use-pathname';
69
+ export { replaceUrl } from './shallow-url';
69
70
  // useSearchParams removed from public exports — lives in next/navigation shim only.
70
71
  export { useSelectedLayoutSegment, useSelectedLayoutSegments } from './use-selected-layout-segment';
71
72
 
@@ -82,8 +83,3 @@ export { useSegmentParams } from './use-segment-params';
82
83
  // Cookies — useCookie lives on defineCookie().useCookie().
83
84
  // Types still exported for advanced use.
84
85
  export type { ClientCookieOptions, CookieSetter } from './use-cookie';
85
-
86
- // Register the client useSegmentParams hook with defineSegmentParams.
87
- import { useSegmentParams as _useSegmentParams } from './use-segment-params.js';
88
- import { _registerUseSegmentParams } from '../segment-params/define.js';
89
- _registerUseSegmentParams(_useSegmentParams);
@@ -384,20 +384,28 @@ export function resolveHref(
384
384
  | SearchParamsDefinition<Record<string, unknown>>
385
385
  | undefined;
386
386
  values = searchParams;
387
- if (!definition) {
388
- throw new Error(
389
- `<Link> received a flat searchParams object for href "${href}", but no ` +
390
- `SearchParamsDefinition is registered for that route. ` +
391
- `Either the route does not export \`searchParams\` from its \`params.ts\`/\`page.tsx\`, ` +
392
- `or the search-params registry module was not loaded. ` +
393
- `For external or computed hrefs, pass the legacy \`{ definition, values }\` shape instead.`
394
- );
395
- }
396
387
  }
397
388
 
398
- const qs = definition.serialize(values);
399
- if (qs) {
400
- resolvedPath = `${resolvedPath}?${qs}`;
389
+ if (definition) {
390
+ const qs = definition.serialize(values);
391
+ if (qs) {
392
+ resolvedPath = `${resolvedPath}?${qs}`;
393
+ }
394
+ } else {
395
+ // No registered definition — serialize flat object as plain query params.
396
+ const usp = new URLSearchParams();
397
+ for (const [key, val] of Object.entries(values)) {
398
+ if (val === undefined || val === null) continue;
399
+ if (Array.isArray(val)) {
400
+ for (const item of val) usp.append(key, String(item));
401
+ } else {
402
+ usp.set(key, String(val));
403
+ }
404
+ }
405
+ const qs = usp.toString();
406
+ if (qs) {
407
+ resolvedPath = `${resolvedPath}?${qs}`;
408
+ }
401
409
  }
402
410
  }
403
411
 
@@ -591,6 +599,11 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
591
599
  return;
592
600
  }
593
601
 
602
+ // Hash-only hrefs — let the browser handle scroll-to-anchor natively.
603
+ // The Navigation API path already skips these via event.hashChange;
604
+ // this covers the fallback (Safari/Firefox) where we drive navigation manually.
605
+ if (resolvedHref.startsWith('#')) return;
606
+
594
607
  // History API fallback — prevent default and navigate via router
595
608
  event.preventDefault();
596
609
 
@@ -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
  /**
@@ -335,17 +358,27 @@ export function createRouter(deps: RouterDeps): RouterInstance {
335
358
  * FetchResult plus the NavigationState (so it can be passed explicitly
336
359
  * to wrapPayload/renderRoot without temporal coupling).
337
360
  */
361
+ interface TransitionResult {
362
+ headElements: HeadElement[] | null;
363
+ decodePromise: Promise<void> | null;
364
+ }
365
+
338
366
  async function renderViaTransition(
339
367
  url: string,
340
368
  perform: () => Promise<FetchResult & { navState: NavigationState }>
341
- ): Promise<HeadElement[] | null> {
369
+ ): Promise<TransitionResult> {
342
370
  if (deps.navigateTransition) {
343
371
  let headElements: HeadElement[] | null = null;
372
+ let decodePromise: Promise<void> | null = null;
344
373
  await deps.navigateTransition(url, async (wrapPayload) => {
345
374
  const result = await perform();
346
375
  headElements = result.headElements;
347
- // Merge partial payload with cached segments before wrapping
348
- const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
376
+ decodePromise = result.decodePromise;
377
+ // Merge partial payload with cached segments before wrapping.
378
+ // When no segments were skipped, mergePayload returns the payload
379
+ // as-is (possibly still an unresolved Flight thenable — React
380
+ // handles progressive rendering via Suspense).
381
+ const merged = await mergePayload(result.payload, result.skippedSegments);
349
382
  // Store the MERGED payload in history — not the partial pre-merge tree.
350
383
  // This ensures handlePopState replays the complete tree on back/forward.
351
384
  historyStack.push(url, {
@@ -353,24 +386,28 @@ export function createRouter(deps: RouterDeps): RouterInstance {
353
386
  headElements: result.headElements,
354
387
  params: result.params,
355
388
  });
389
+ // Defer segment element caching to after paint — it only benefits
390
+ // future navigations and doesn't need to block the current render.
391
+ deferSegmentCaching(merged);
356
392
  // Pass navState explicitly — wrapPayload wraps element in
357
393
  // NavigationProvider with this state, no getNavigationState() needed.
358
394
  return wrapPayload(merged, result.navState);
359
395
  });
360
- return headElements;
396
+ return { headElements, decodePromise };
361
397
  }
362
398
  // Fallback: no transition (tests, no React tree)
363
399
  const result = await perform();
364
400
  // Merge partial payload with cached segments before rendering
365
- const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
401
+ const merged = await mergePayload(result.payload, result.skippedSegments);
366
402
  // Store merged payload in history
367
403
  historyStack.push(url, {
368
404
  payload: merged,
369
405
  headElements: result.headElements,
370
406
  params: result.params,
371
407
  });
408
+ deferSegmentCaching(merged);
372
409
  renderPayload(merged, result.navState);
373
- return result.headElements;
410
+ return { headElements: result.headElements, decodePromise: result.decodePromise };
374
411
  }
375
412
 
376
413
  /** Apply head elements (title, meta tags) to the DOM if available. */
@@ -414,6 +451,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
414
451
  let result: FetchResult | undefined = prefetched
415
452
  ? {
416
453
  payload: prefetched.payload,
454
+ decodePromise: null,
417
455
  headElements: prefetched.headElements,
418
456
  segmentInfo: prefetched.segmentInfo ?? null,
419
457
  params: prefetched.params ?? null,
@@ -466,6 +504,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
466
504
  const externalSignal = options._signal as AbortSignal | undefined;
467
505
  const skipHistory = options._skipHistory === true;
468
506
 
507
+ navigationId++;
508
+
469
509
  // Create an abort controller for this navigation. Links to the external
470
510
  // signal (Navigation API's event.signal) when provided.
471
511
  const navAbort = createNavAbort(externalSignal);
@@ -499,7 +539,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
499
539
  setPending(true, url);
500
540
 
501
541
  try {
502
- const headElements = await renderViaTransition(url, () =>
542
+ const { headElements, decodePromise } = await renderViaTransition(url, () =>
503
543
  performNavigationFetch(url, {
504
544
  replace,
505
545
  signal: navAbort.signal,
@@ -507,6 +547,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
507
547
  })
508
548
  );
509
549
 
550
+ // Await async RSC decode errors (truncated streams, Flight parse failures)
551
+ // so they surface through this try/catch rather than becoming unhandled.
552
+ if (decodePromise) await decodePromise;
553
+
510
554
  // Update document.title and <meta> tags with the new page's metadata
511
555
  applyHead(headElements);
512
556
 
@@ -575,12 +619,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
575
619
 
576
620
  async function refresh(): Promise<void> {
577
621
  const currentUrl = deps.getCurrentUrl();
622
+ navigationId++;
578
623
  const navAbort = createNavAbort();
579
624
 
580
625
  setPending(true, currentUrl);
581
626
 
582
627
  try {
583
- const headElements = await renderViaTransition(currentUrl, async () => {
628
+ const { headElements, decodePromise } = await renderViaTransition(currentUrl, async () => {
584
629
  // No state tree sent — server renders the complete RSC payload
585
630
  const result = await fetchRscPayload(
586
631
  currentUrl,
@@ -595,6 +640,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
595
640
  return { ...result, navState };
596
641
  });
597
642
 
643
+ if (decodePromise) await decodePromise;
598
644
  applyHead(headElements);
599
645
  } catch (error) {
600
646
  // Stale transition (superseded by a newer navigation) or aborted
@@ -615,6 +661,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
615
661
  scrollY: number = 0,
616
662
  externalSignal?: AbortSignal
617
663
  ): Promise<void> {
664
+ navigationId++;
618
665
  // Scroll position is read from history.state by the caller (browser-entry.ts)
619
666
  // and passed in. This is more reliable than tracking scroll per-URL in memory
620
667
  // because the browser maintains per-entry state even with duplicate URLs.
@@ -634,7 +681,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
634
681
  const navAbort = createNavAbort(externalSignal);
635
682
  setPending(true, url);
636
683
  try {
637
- const headElements = await renderViaTransition(url, async () => {
684
+ const { headElements, decodePromise } = await renderViaTransition(url, async () => {
638
685
  const stateTree = segmentCache.serializeStateTree(
639
686
  segmentElementCache.getMergeablePaths()
640
687
  );
@@ -645,6 +692,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
645
692
  return { ...result, navState };
646
693
  });
647
694
 
695
+ if (decodePromise) await decodePromise;
648
696
  applyHead(headElements);
649
697
  restoreScrollAfterPaint(scrollY);
650
698
  } catch (error) {
@@ -697,18 +745,19 @@ export function createRouter(deps: RouterDeps): RouterInstance {
697
745
  // Render the piggybacked element tree from a server action response.
698
746
  // Updates the current history entry with the fresh payload and applies
699
747
  // head elements — same as refresh() but without a server fetch.
700
- // Cache segment elements for future partial merges.
748
+ // Revalidation payloads are already resolved (no Flight thenable),
749
+ // so no segment merging is needed — just cache and render.
701
750
  const currentUrl = deps.getCurrentUrl();
702
- const merged = mergeAndCachePayload(element, null);
703
751
  historyStack.push(currentUrl, {
704
- payload: merged,
752
+ payload: element,
705
753
  headElements,
706
754
  });
755
+ deferSegmentCaching(element);
707
756
  // Revalidation doesn't change params/pathname — preserve current state.
708
757
  // DO NOT call updateNavigationState(null, ...) here: that normalizes
709
758
  // params to {}, clearing dynamic route params on every action response.
710
759
  const navState = getNavigationState();
711
- renderPayload(merged, navState);
760
+ renderPayload(element, navState);
712
761
  applyHead(headElements);
713
762
  },
714
763
  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
+ }
@@ -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 +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"}