@timber-js/app 0.2.0-alpha.121 → 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 (137) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{actions-Dy8vOjFt.js → actions-ZlcKx0H9.js} +5 -5
  3. package/dist/_chunks/actions-ZlcKx0H9.js.map +1 -0
  4. package/dist/_chunks/{build-output-helper-B1n0sUqj.js → build-output-helper-DYOWsPjZ.js} +47 -6
  5. package/dist/_chunks/build-output-helper-DYOWsPjZ.js.map +1 -0
  6. package/dist/_chunks/cli-schema-sync-4HcQIziG.js +2 -0
  7. package/dist/_chunks/{cli-schema-sync-ByZPGGmS.js → cli-schema-sync-VcCf1TFY.js} +17 -3
  8. package/dist/_chunks/{cli-schema-sync-ByZPGGmS.js.map → cli-schema-sync-VcCf1TFY.js.map} +1 -1
  9. package/dist/_chunks/{define-CPVQaWbx.js → define-C0V_eKiW.js} +6 -18
  10. package/dist/_chunks/define-C0V_eKiW.js.map +1 -0
  11. package/dist/_chunks/{handler-store-CcwNX-gk.js → handler-store-bIAstrxQ.js} +1 -1
  12. package/dist/_chunks/{handler-store-CcwNX-gk.js.map → handler-store-bIAstrxQ.js.map} +1 -1
  13. package/dist/_chunks/{merge-search-params-az6ZiBeU.js → merge-search-params-Cm1j99HC.js} +1 -1
  14. package/dist/_chunks/{merge-search-params-az6ZiBeU.js.map → merge-search-params-Cm1j99HC.js.map} +1 -1
  15. package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-DESG8uk5.js} +32 -19
  16. package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -0
  17. package/dist/_chunks/{navigation-root-DoRTeBm5.js → navigation-root-Ds_hDtRW.js} +2 -2
  18. package/dist/_chunks/{navigation-root-DoRTeBm5.js.map → navigation-root-Ds_hDtRW.js.map} +1 -1
  19. package/dist/_chunks/{resolve-schema-Bf8eC6q6.js → resolve-schema-DU2nbVOi.js} +5 -1
  20. package/dist/_chunks/resolve-schema-DU2nbVOi.js.map +1 -0
  21. package/dist/_chunks/{router-ref-CpvjBqdu.js → router-ref-B2-2kIIC.js} +2 -2
  22. package/dist/_chunks/{router-ref-CpvjBqdu.js.map → router-ref-B2-2kIIC.js.map} +1 -1
  23. package/dist/_chunks/{segment-classify-CnfLLvir.js → segment-classify-BjfuctV2.js} +1 -1
  24. package/dist/_chunks/{segment-classify-CnfLLvir.js.map → segment-classify-BjfuctV2.js.map} +1 -1
  25. package/dist/_chunks/{segment-context-EsfQpS--.js → segment-context-Bg0pFbLT.js} +1 -1
  26. package/dist/_chunks/{segment-context-EsfQpS--.js.map → segment-context-Bg0pFbLT.js.map} +1 -1
  27. package/dist/_chunks/{ssr-data-m9-cNEIw.js → ssr-data-DVX8S7vj.js} +1 -1
  28. package/dist/_chunks/{ssr-data-m9-cNEIw.js.map → ssr-data-DVX8S7vj.js.map} +1 -1
  29. package/dist/_chunks/{use-segment-params-CtIAjqK0.js → use-segment-params-Du7u25cK.js} +3 -8
  30. package/dist/_chunks/{use-segment-params-CtIAjqK0.js.map → use-segment-params-Du7u25cK.js.map} +1 -1
  31. package/dist/_chunks/{validation-BMU5MPzP.js → validation-AfEFM5ze.js} +1 -1
  32. package/dist/_chunks/{validation-BMU5MPzP.js.map → validation-AfEFM5ze.js.map} +1 -1
  33. package/dist/_chunks/{walkers-DNp9tHyV.js → walkers-tkw7sDlj.js} +12 -9
  34. package/dist/_chunks/walkers-tkw7sDlj.js.map +1 -0
  35. package/dist/adapters/build-output-helper.d.ts.map +1 -1
  36. package/dist/adapters/cloudflare.js +1 -1
  37. package/dist/adapters/nitro.js +1 -1
  38. package/dist/cache/index.js +1 -1
  39. package/dist/cli-schema-sync.d.ts.map +1 -1
  40. package/dist/cli.js +1 -1
  41. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  42. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  43. package/dist/client/error-boundary.js +2 -2
  44. package/dist/client/index.d.ts +1 -0
  45. package/dist/client/index.d.ts.map +1 -1
  46. package/dist/client/index.js +48 -15
  47. package/dist/client/index.js.map +1 -1
  48. package/dist/client/internal.js +67 -32
  49. package/dist/client/internal.js.map +1 -1
  50. package/dist/client/link.d.ts +9 -5
  51. package/dist/client/link.d.ts.map +1 -1
  52. package/dist/client/router.d.ts.map +1 -1
  53. package/dist/client/rsc-fetch.d.ts +8 -0
  54. package/dist/client/rsc-fetch.d.ts.map +1 -1
  55. package/dist/client/shallow-url.d.ts +10 -0
  56. package/dist/client/shallow-url.d.ts.map +1 -0
  57. package/dist/cookies/index.js +2 -2
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +102 -10
  60. package/dist/index.js.map +1 -1
  61. package/dist/params/index.js +1 -1
  62. package/dist/params/resolve-schema.d.ts.map +1 -1
  63. package/dist/plugins/build-manifest.d.ts.map +1 -1
  64. package/dist/plugins/cloudflare-wasm.d.ts +18 -0
  65. package/dist/plugins/cloudflare-wasm.d.ts.map +1 -0
  66. package/dist/plugins/dev-server.d.ts.map +1 -1
  67. package/dist/routing/codegen-shared.d.ts +5 -4
  68. package/dist/routing/codegen-shared.d.ts.map +1 -1
  69. package/dist/routing/index.js +3 -3
  70. package/dist/segment-params/define.d.ts +0 -6
  71. package/dist/segment-params/define.d.ts.map +1 -1
  72. package/dist/segment-params/index.js +1 -1
  73. package/dist/server/actions.d.ts +3 -8
  74. package/dist/server/actions.d.ts.map +1 -1
  75. package/dist/server/build-manifest.d.ts +6 -0
  76. package/dist/server/build-manifest.d.ts.map +1 -1
  77. package/dist/server/index.js +1 -1
  78. package/dist/server/internal.d.ts +1 -1
  79. package/dist/server/internal.d.ts.map +1 -1
  80. package/dist/server/internal.js +15 -6
  81. package/dist/server/internal.js.map +1 -1
  82. package/dist/server/metadata-routes.d.ts +23 -7
  83. package/dist/server/metadata-routes.d.ts.map +1 -1
  84. package/dist/server/pipeline-phases.d.ts.map +1 -1
  85. package/dist/server/request-context.d.ts.map +1 -1
  86. package/dist/server/route-element-builder.d.ts +1 -1
  87. package/dist/server/route-element-builder.d.ts.map +1 -1
  88. package/dist/server/route-matcher.d.ts +4 -2
  89. package/dist/server/route-matcher.d.ts.map +1 -1
  90. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  91. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  92. package/dist/server/ssr-entry.d.ts.map +1 -1
  93. package/dist/server/tree-match.d.ts +12 -4
  94. package/dist/server/tree-match.d.ts.map +1 -1
  95. package/dist/server/types.d.ts +2 -2
  96. package/package.json +6 -7
  97. package/src/adapters/build-output-helper.ts +67 -5
  98. package/src/cli-schema-sync.ts +25 -4
  99. package/src/cli.ts +0 -0
  100. package/src/client/browser-entry/action-dispatch.ts +9 -10
  101. package/src/client/browser-entry/index.ts +1 -1
  102. package/src/client/browser-entry/post-hydration.ts +12 -6
  103. package/src/client/index.ts +1 -5
  104. package/src/client/link.tsx +34 -21
  105. package/src/client/router.ts +74 -25
  106. package/src/client/rsc-fetch.ts +20 -5
  107. package/src/client/shallow-url.ts +22 -0
  108. package/src/index.ts +2 -0
  109. package/src/params/resolve-schema.ts +15 -0
  110. package/src/plugins/build-manifest.ts +57 -5
  111. package/src/plugins/cloudflare-wasm.ts +103 -0
  112. package/src/plugins/dev-server.ts +4 -2
  113. package/src/plugins/routing.ts +1 -1
  114. package/src/routing/codegen-shared.ts +8 -5
  115. package/src/routing/codegen.ts +5 -0
  116. package/src/routing/link-codegen.ts +1 -1
  117. package/src/segment-params/define.ts +2 -29
  118. package/src/server/actions.ts +4 -14
  119. package/src/server/build-manifest.ts +7 -0
  120. package/src/server/internal.ts +7 -1
  121. package/src/server/metadata-routes.ts +44 -19
  122. package/src/server/pipeline-phases.ts +8 -0
  123. package/src/server/request-context.ts +0 -4
  124. package/src/server/route-element-builder.ts +57 -11
  125. package/src/server/route-matcher.ts +81 -49
  126. package/src/server/rsc-entry/index.ts +7 -1
  127. package/src/server/rsc-entry/render-route.ts +7 -1
  128. package/src/server/ssr-entry.ts +6 -0
  129. package/src/server/tree-match.ts +23 -11
  130. package/src/server/types.ts +2 -2
  131. package/dist/_chunks/actions-Dy8vOjFt.js.map +0 -1
  132. package/dist/_chunks/build-output-helper-B1n0sUqj.js.map +0 -1
  133. package/dist/_chunks/cli-schema-sync-DHQLjFu3.js +0 -2
  134. package/dist/_chunks/define-CPVQaWbx.js.map +0 -1
  135. package/dist/_chunks/metadata-routes-Cjmvi3rQ.js.map +0 -1
  136. package/dist/_chunks/resolve-schema-Bf8eC6q6.js.map +0 -1
  137. package/dist/_chunks/walkers-DNp9tHyV.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
  /**
@@ -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
+ }
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ import { timberReactProd } from './plugins/react-prod';
29
29
  import { timberChunks } from './plugins/chunks';
30
30
  import { clientChunkGroup } from './plugins/client-chunks';
31
31
  import { timberServerBundle } from './plugins/server-bundle';
32
+ import { timberCloudflareWasm } from './plugins/cloudflare-wasm';
32
33
  import { timberAdapterBuild } from './plugins/adapter-build';
33
34
  import { timberBuildReport } from './plugins/build-report';
34
35
  import { createNoopTimer } from './utils/startup-timer';
@@ -531,6 +532,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
531
532
  timberMdx(ctx),
532
533
  timberContent(ctx),
533
534
  timberServerBundle(), // Bundle all deps in server environments for prod
535
+ timberCloudflareWasm(ctx), // Externalize WASM for Cloudflare Workers
534
536
  timberChunks(),
535
537
  timberBuildReport(ctx), // Post-build: route table with bundle sizes
536
538
  timberAdapterBuild(ctx), // Post-build: invoke adapter.buildOutput()
@@ -70,6 +70,10 @@ function isCatchAllKey(key: string): boolean {
70
70
  return CATCH_ALL_RE.test(key) || OPTIONAL_CATCH_ALL_RE.test(key);
71
71
  }
72
72
 
73
+ function isOptionalCatchAllKey(key: string): boolean {
74
+ return OPTIONAL_CATCH_ALL_RE.test(key);
75
+ }
76
+
73
77
  /**
74
78
  * Resolve the global schema's segmentParams into a map of bracket-keyed
75
79
  * param name → resolved Codec.
@@ -94,6 +98,17 @@ export function resolveSchemaCodecs(
94
98
  `(e.g. codec.catchAll(codec.string)). Got a scalar codec.`
95
99
  );
96
100
  }
101
+ if (
102
+ isOptionalCatchAllKey(bracketName) &&
103
+ isCodec(value) &&
104
+ '__catchAll' in value &&
105
+ !('__optional' in value)
106
+ ) {
107
+ throw new Error(
108
+ `[timber] Schema error: '${bracketName}' is an optional catch-all segment and requires codec.optionalCatchAll() ` +
109
+ `(e.g. codec.optionalCatchAll(codec.string)). Got a non-optional catch-all codec.`
110
+ );
111
+ }
97
112
  resolved[bracketName] = resolveCodec(bracketName, value);
98
113
  }
99
114
 
@@ -16,10 +16,13 @@
16
16
  * Design docs: 18-build-system.md §"Build Manifest", 02-rendering-pipeline.md §"Early Hints"
17
17
  */
18
18
 
19
- import { randomUUID } from 'node:crypto';
19
+ import { randomUUID, createHash } from 'node:crypto';
20
+ import { readFileSync } from 'node:fs';
20
21
  import type { Plugin, ResolvedConfig } from 'vite';
21
22
  import type { PluginContext } from '../plugin-context.js';
22
23
  import type { BuildManifest } from '../server/build-manifest.js';
24
+ import type { SegmentNode, RouteFile } from '../routing/types.js';
25
+ import { isDynamicMetadataExtension } from '../server/metadata-routes.js';
23
26
 
24
27
  // Rollup types used by generateBundle hook — imported from vite which re-exports them.
25
28
  // We define minimal interfaces here to avoid a direct 'rollup' dependency.
@@ -84,7 +87,7 @@ export function parseViteManifest(
84
87
  modulepreload[inputPath] = collectTransitiveDeps(inputPath, viteManifest, base);
85
88
  }
86
89
 
87
- return { css, js, modulepreload, fonts: {} };
90
+ return { css, js, modulepreload, fonts: {}, metadataRouteHashes: {} };
88
91
  }
89
92
 
90
93
  /**
@@ -197,7 +200,7 @@ export function buildManifestFromBundle(
197
200
  css['_global'] = allCss;
198
201
  }
199
202
 
200
- return { css, js, modulepreload, fonts: {} };
203
+ return { css, js, modulepreload, fonts: {}, metadataRouteHashes: {} };
201
204
  }
202
205
 
203
206
  /**
@@ -228,6 +231,50 @@ function collectTransitiveBundleDeps(
228
231
  return result;
229
232
  }
230
233
 
234
+ /**
235
+ * Walk the route tree and compute short content hashes for metadata route files
236
+ * (opengraph-image, icon, apple-icon) used for cache-busting
237
+ * the auto-linked URLs in <head>.
238
+ */
239
+ function computeMetadataRouteHashes(node: SegmentNode<RouteFile>): Record<string, string> {
240
+ const hashes: Record<string, string> = {};
241
+
242
+ if (node.metadataRoutes) {
243
+ for (const [baseName, file] of Object.entries(node.metadataRoutes)) {
244
+ if (!isDynamicMetadataExtension(baseName, file.extension)) {
245
+ // For static files, hash the file content
246
+ try {
247
+ const content = readFileSync(file.filePath);
248
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
249
+ hashes[file.filePath] = hash;
250
+ } catch {
251
+ // File unreadable — skip hash, DEV_NONCE fallback applies
252
+ }
253
+ } else {
254
+ // For dynamic files (.ts/.tsx), hash the source file as a reasonable
255
+ // approximation — the output depends on runtime data too, but the
256
+ // source change is the best build-time signal we have.
257
+ try {
258
+ const content = readFileSync(file.filePath);
259
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
260
+ hashes[file.filePath] = hash;
261
+ } catch {
262
+ // File unreadable — skip
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ for (const child of node.children) {
269
+ Object.assign(hashes, computeMetadataRouteHashes(child));
270
+ }
271
+ for (const slot of Object.values(node.slots)) {
272
+ Object.assign(hashes, computeMetadataRouteHashes(slot));
273
+ }
274
+
275
+ return hashes;
276
+ }
277
+
231
278
  /**
232
279
  * Create the timber-build-manifest Vite plugin.
233
280
  *
@@ -278,7 +325,7 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
278
325
  '// Auto-generated build manifest — do not edit.',
279
326
  '// Dev mode: empty manifest (Vite HMR handles CSS/JS).',
280
327
  '',
281
- 'const manifest = { css: {}, js: {}, modulepreload: {}, fonts: {} };',
328
+ 'const manifest = { css: {}, js: {}, modulepreload: {}, fonts: {}, metadataRouteHashes: {} };',
282
329
  '',
283
330
  'export default manifest;',
284
331
  ].join('\n');
@@ -292,7 +339,7 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
292
339
  '// Auto-generated build manifest — do not edit.',
293
340
  '// Production: reads manifest from globalThis (set by _timber-manifest-init.js).',
294
341
  '',
295
- 'const manifest = globalThis.__TIMBER_BUILD_MANIFEST__ ?? { css: {}, js: {}, modulepreload: {}, fonts: {} };',
342
+ 'const manifest = globalThis.__TIMBER_BUILD_MANIFEST__ ?? { css: {}, js: {}, modulepreload: {}, fonts: {}, metadataRouteHashes: {} };',
296
343
  '',
297
344
  'export default manifest;',
298
345
  ].join('\n');
@@ -309,6 +356,11 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
309
356
  if (envName === 'client') {
310
357
  ctx.buildManifest = buildManifestFromBundle(bundle, resolvedBase, ctx.root);
311
358
 
359
+ // Compute cache-busting hashes for metadata route files
360
+ if (ctx.routeTree) {
361
+ ctx.buildManifest.metadataRouteHashes = computeMetadataRouteHashes(ctx.routeTree.root);
362
+ }
363
+
312
364
  // When client JavaScript is disabled, strip JS chunks from the bundle
313
365
  // so Rollup never writes them to disk. CSS assets are preserved —
314
366
  // they're still needed for server-rendered HTML.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * timber-cloudflare-wasm — Handle WASM modules for Cloudflare Workers builds.
3
+ *
4
+ * Cloudflare Workers can natively import `.wasm` files as compiled
5
+ * WebAssembly.Module. But Rolldown can't load `.wasm` files at build time.
6
+ *
7
+ * This plugin bridges the gap:
8
+ * 1. Adds the `workerd` resolve condition so libraries like @takumi-rs/wasm
9
+ * resolve to their Cloudflare-native entry (bare `.wasm` import)
10
+ * 2. Externalizes `.wasm` imports so Rolldown skips them
11
+ * 3. Copies the WASM files to the build output so wrangler can find them
12
+ *
13
+ * Only active for Cloudflare adapter production builds.
14
+ */
15
+
16
+ import { basename, resolve, dirname } from 'node:path';
17
+ import { copyFile, mkdir } from 'node:fs/promises';
18
+ import type { Plugin, ResolvedConfig } from 'vite';
19
+ import type { PluginContext } from '../plugin-context.js';
20
+
21
+ function isCloudflareAdapter(ctx: PluginContext): boolean {
22
+ const adapter = ctx.config.adapter as { name?: string } | undefined;
23
+ return adapter?.name === 'cloudflare';
24
+ }
25
+
26
+ export function timberCloudflareWasm(ctx: PluginContext): Plugin[] {
27
+ // Track resolved WASM file paths so we can copy them to the output
28
+ const wasmFiles = new Map<string, string>();
29
+
30
+ const plugin: Plugin = {
31
+ name: 'timber-cloudflare-wasm',
32
+
33
+ configResolved(resolved: ResolvedConfig) {
34
+ if (resolved.command !== 'build') return;
35
+ if (!isCloudflareAdapter(ctx)) return;
36
+
37
+ // Prepend `workerd` to resolve conditions for server environments
38
+ // so @takumi-rs/wasm/auto resolves to the Cloudflare-native entry
39
+ // (bare WASM import) instead of the Vite entry (?url + fetch).
40
+ // Must happen in configResolved because the RSC plugin sets its own
41
+ // conditions during config — merging via config() would lose them.
42
+ for (const envName of ['rsc', 'ssr']) {
43
+ const env = resolved.environments[envName];
44
+ if (!env) continue;
45
+ const conditions = env.resolve.conditions;
46
+ if (!conditions.includes('workerd')) {
47
+ conditions.unshift('workerd');
48
+ }
49
+ }
50
+ },
51
+
52
+ resolveId: {
53
+ order: 'pre',
54
+ async handler(source, importer) {
55
+ if (!source.endsWith('.wasm')) return;
56
+
57
+ let absolutePath: string;
58
+ if (source.startsWith('.') && importer) {
59
+ absolutePath = resolve(dirname(importer), source);
60
+ } else {
61
+ return;
62
+ }
63
+
64
+ const filename = basename(absolutePath);
65
+ wasmFiles.set(filename, absolutePath);
66
+
67
+ return { id: './' + filename, external: true };
68
+ },
69
+ },
70
+
71
+ async writeBundle(options, bundle) {
72
+ if (wasmFiles.size === 0) return;
73
+
74
+ const outDir = options.dir;
75
+ if (!outDir) return;
76
+
77
+ // Find which output chunks reference WASM files and copy the WASM
78
+ // to the same directory so the relative imports resolve correctly.
79
+ const targetDirs = new Set<string>();
80
+ for (const chunk of Object.values(bundle)) {
81
+ if (chunk.type !== 'chunk') continue;
82
+ for (const filename of wasmFiles.keys()) {
83
+ if (chunk.code.includes(filename)) {
84
+ targetDirs.add(dirname(resolve(outDir, chunk.fileName)));
85
+ }
86
+ }
87
+ }
88
+
89
+ if (targetDirs.size === 0) return;
90
+
91
+ const copies: Promise<void>[] = [];
92
+ for (const dir of targetDirs) {
93
+ await mkdir(dir, { recursive: true });
94
+ for (const [filename, absolutePath] of wasmFiles) {
95
+ copies.push(copyFile(absolutePath, resolve(dir, filename)));
96
+ }
97
+ }
98
+ await Promise.all(copies);
99
+ },
100
+ };
101
+
102
+ return [plugin];
103
+ }
@@ -31,6 +31,7 @@ import {
31
31
  } from '../dev-tools/error-page.js';
32
32
  import { addVirtualModuleContext } from '../config-validation.js';
33
33
  import { compressResponse } from '../server/compress.js';
34
+ import { isMetadataRouteServePath } from '../server/metadata-routes.js';
34
35
 
35
36
  // ─── Constants ────────────────────────────────────────────────────────────
36
37
 
@@ -165,8 +166,9 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
165
166
  return;
166
167
  }
167
168
 
168
- // Pass through static asset requests
169
- if (isAssetRequest(url)) {
169
+ // Pass through static asset requests — but not metadata routes like
170
+ // /favicon.ico that match asset extensions but are handled by timber.
171
+ if (isAssetRequest(url) && !isMetadataRouteServePath(url)) {
170
172
  next();
171
173
  return;
172
174
  }
@@ -68,7 +68,7 @@ const SEGMENT_PREFIX = '\0$segment:';
68
68
  * File convention names we track for changes that require manifest regeneration.
69
69
  */
70
70
  const ROUTE_FILE_PATTERNS =
71
- /\/(page|layout|middleware|access|route|error|global-error|default|denied|params|schema|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|twitter-image|apple-icon)\./;
71
+ /\/(page|layout|middleware|access|route|error|global-error|default|denied|params|schema|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|apple-icon)\./;
72
72
 
73
73
  /**
74
74
  * Create the timber-routing Vite plugin.
@@ -82,13 +82,16 @@ export function buildCodecChainType(
82
82
 
83
83
  /**
84
84
  * Build a TypeScript type expression for a single param using the global
85
- * schema's InferCodecType. Falls back to `fallback` when schema doesn't
86
- * declare this param.
85
+ * schema's InferCodecType. Falls back to the param's base type when
86
+ * schema doesn't declare this param (schemas are opt-in per key).
87
87
  *
88
- * Generates: `InferCodecType<typeof schema.segmentParams['[paramName]']>`
88
+ * Generates a conditional that checks key existence:
89
+ * `[bracketName] extends keyof typeof schema.segmentParams ? InferCodecType<...> : fallback`
89
90
  */
90
- export function buildSchemaParamType(p: ParamEntry): string {
91
- return `InferCodecType<typeof schema.segmentParams[${JSON.stringify(p.bracketName)}]>`;
91
+ export function buildSchemaParamType(p: ParamEntry, fallback?: string): string {
92
+ const key = JSON.stringify(p.bracketName);
93
+ const fb = fallback ?? p.type;
94
+ return `${key} extends keyof typeof schema['segmentParams'] ? InferCodecType<typeof schema.segmentParams[${key}]> : ${fb}`;
92
95
  }
93
96
 
94
97
  /**
@@ -350,6 +350,11 @@ function formatDeclarationFile(
350
350
  " [K in keyof typeof schema['segmentParams']]: typeof schema['segmentParams'][K] extends SegmentParamCodec ? true : ['Error: value for', K, 'is not a valid Codec or Standard Schema'];"
351
351
  );
352
352
  lines.push('};');
353
+ // Generic constraint enforces that all values are `true` — if any value is
354
+ // an error tuple, TS reports a real diagnostic instead of silently computing it.
355
+ lines.push(
356
+ 'type _EnforceSchemaValues<_T extends Record<string, true> = _AssertSchemaValues> = true;'
357
+ );
353
358
  lines.push('');
354
359
  }
355
360
 
@@ -83,7 +83,7 @@ export function formatLinkParamsType(
83
83
  const fields = params.map((p) => {
84
84
  const fallback = p.type === 'string' ? 'string | number' : p.type;
85
85
  const codecType = hasSchema
86
- ? buildSchemaParamType(p)
86
+ ? buildSchemaParamType(p, fallback)
87
87
  : buildCodecChainType(p, importBase, fallback);
88
88
  return `${p.name}: ${codecType}`;
89
89
  });