@timber-js/app 0.2.0-alpha.166 → 0.2.0-alpha.167

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 (69) hide show
  1. package/dist/_chunks/{actions-CDPfMp_I.js → actions-TSxpXLHJ.js} +2 -2
  2. package/dist/_chunks/{actions-CDPfMp_I.js.map → actions-TSxpXLHJ.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-DygSeKCB.js → cache-api-DzpQQOEx.js} +52 -48
  4. package/dist/_chunks/{cache-api-DygSeKCB.js.map → cache-api-DzpQQOEx.js.map} +1 -1
  5. package/dist/_chunks/{cli-schema-sync-EXGYPhI2.js → cli-schema-sync-wX-i90Og.js} +4 -2
  6. package/dist/_chunks/cli-schema-sync-wX-i90Og.js.map +1 -0
  7. package/dist/_chunks/cloudflare-AHoWYTYr.js +1188 -0
  8. package/dist/_chunks/cloudflare-AHoWYTYr.js.map +1 -0
  9. package/dist/_chunks/logger-t3uxAmbX.js.map +1 -1
  10. package/dist/_chunks/{walkers-DBVzXuWc.js → walkers-Cfwvl-UC.js} +2 -2
  11. package/dist/_chunks/{walkers-DBVzXuWc.js.map → walkers-Cfwvl-UC.js.map} +1 -1
  12. package/dist/adapters/cloudflare-dev.js +1 -1
  13. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  14. package/dist/adapters/cloudflare.d.ts +12 -1
  15. package/dist/adapters/cloudflare.d.ts.map +1 -1
  16. package/dist/adapters/cloudflare.js +2 -461
  17. package/dist/adapters/types.d.ts +2 -0
  18. package/dist/adapters/types.d.ts.map +1 -1
  19. package/dist/cache/cache-api.d.ts +33 -11
  20. package/dist/cache/cache-api.d.ts.map +1 -1
  21. package/dist/cache/index.js +1 -1
  22. package/dist/cli.js +1 -1
  23. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  24. package/dist/client/history.d.ts +10 -0
  25. package/dist/client/history.d.ts.map +1 -1
  26. package/dist/client/internal.js +138 -114
  27. package/dist/client/internal.js.map +1 -1
  28. package/dist/client/router.d.ts.map +1 -1
  29. package/dist/index.js +36 -27
  30. package/dist/index.js.map +1 -1
  31. package/dist/plugins/adapter-build.d.ts.map +1 -1
  32. package/dist/plugins/cache.d.ts +8 -8
  33. package/dist/plugins/cache.d.ts.map +1 -1
  34. package/dist/routing/index.js +2 -2
  35. package/dist/server/als-registry.d.ts +7 -0
  36. package/dist/server/als-registry.d.ts.map +1 -1
  37. package/dist/server/deny-boundary.d.ts +3 -1
  38. package/dist/server/deny-boundary.d.ts.map +1 -1
  39. package/dist/server/index.js +1 -1
  40. package/dist/server/internal.js +6 -3
  41. package/dist/server/internal.js.map +1 -1
  42. package/dist/server/route-element-builder.d.ts +9 -0
  43. package/dist/server/route-element-builder.d.ts.map +1 -1
  44. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  45. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  46. package/dist/server/rsc-entry/rsc-stream.d.ts +8 -0
  47. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  48. package/docs/api/32-api-cache.mdx +4 -4
  49. package/docs/learn/09-caching.mdx +5 -5
  50. package/docs/more/03-coming-from-nextjs.mdx +2 -2
  51. package/docs/more/50-ai-agent-instructions.mdx +5 -5
  52. package/package.json +3 -2
  53. package/src/adapters/cloudflare.ts +63 -25
  54. package/src/adapters/types.ts +2 -0
  55. package/src/cache/cache-api.ts +84 -84
  56. package/src/client/browser-entry/post-hydration.ts +16 -9
  57. package/src/client/history.ts +11 -0
  58. package/src/client/router.ts +212 -206
  59. package/src/plugins/adapter-build.ts +1 -0
  60. package/src/plugins/cache.ts +45 -30
  61. package/src/routing/scanner.ts +7 -0
  62. package/src/server/als-registry.ts +7 -0
  63. package/src/server/deny-boundary.ts +6 -3
  64. package/src/server/route-element-builder.ts +49 -8
  65. package/src/server/rsc-entry/render-route.ts +3 -1
  66. package/src/server/rsc-entry/rsc-payload.ts +21 -4
  67. package/src/server/rsc-entry/rsc-stream.ts +8 -0
  68. package/dist/_chunks/cli-schema-sync-EXGYPhI2.js.map +0 -1
  69. package/dist/adapters/cloudflare.js.map +0 -1
@@ -306,6 +306,89 @@ export function createRouter(deps: RouterDeps): RouterInstance {
306
306
  }
307
307
  }
308
308
 
309
+ /**
310
+ * Atomically update all navigation-owned state for a new page. Every
311
+ * code path that changes the "current page" must go through this
312
+ * function — making "forgot a field" impossible by construction.
313
+ *
314
+ * The three operations:
315
+ * 1. Segment cache — update from server-provided segment metadata
316
+ * 2. Navigation state — params + pathname for useSegmentParams/usePathname
317
+ * 3. History stack — store the payload for instant back/forward replay
318
+ */
319
+ function commitNavigation(
320
+ url: string,
321
+ opts: {
322
+ payload: unknown;
323
+ params?: Record<string, string | string[]> | null;
324
+ segmentInfo?: SegmentInfo[] | null;
325
+ /** When true, clear the segment cache if segmentInfo is empty
326
+ * (popstate replay for entries without layout metadata). */
327
+ clearSegmentCacheOnEmpty?: boolean;
328
+ }
329
+ ): NavigationState {
330
+ if (opts.segmentInfo && opts.segmentInfo.length > 0) {
331
+ updateSegmentCache(opts.segmentInfo);
332
+ } else if (opts.clearSegmentCacheOnEmpty) {
333
+ segmentCache.clear();
334
+ }
335
+
336
+ const navState = updateNavigationState(opts.params, url);
337
+
338
+ historyStack.push(url, {
339
+ payload: opts.payload,
340
+ params: navState.params,
341
+ segmentInfo: opts.segmentInfo,
342
+ });
343
+
344
+ return navState;
345
+ }
346
+
347
+ /**
348
+ * Wrap a navigation in the standard abort/pending/cleanup lifecycle.
349
+ * Consolidates the createNavAbort + setPending + staleness-guarded
350
+ * finally that was duplicated across navigate, refresh, and both
351
+ * handlePopState paths. AbortErrors are swallowed (not application
352
+ * errors); all other errors propagate to the caller.
353
+ */
354
+ async function runNavigation(
355
+ url: string,
356
+ fn: (navAbort: AbortController) => Promise<void>,
357
+ externalSignal?: AbortSignal
358
+ ): Promise<void> {
359
+ const navAbort = createNavAbort(externalSignal);
360
+ setPending(true, url);
361
+ try {
362
+ await fn(navAbort);
363
+ } catch (error) {
364
+ if (isAbortError(error)) return;
365
+ throw error;
366
+ } finally {
367
+ if (currentNavAbort === navAbort) {
368
+ currentNavAbort = null;
369
+ setPending(false);
370
+ deps.completeRouterNavigation?.();
371
+ }
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Resolve thenable payloads in the test/fallback path (no navigateTransition).
377
+ * In production, React handles thenables from createFromFetch directly via
378
+ * Suspense. In tests, renderRoot is a plain mock that expects resolved values.
379
+ */
380
+ async function resolveForFallback(payload: unknown): Promise<unknown> {
381
+ if (
382
+ !deps.navigateTransition &&
383
+ payload != null &&
384
+ typeof payload === 'object' &&
385
+ 'then' in payload
386
+ ) {
387
+ return await (payload as PromiseLike<unknown>);
388
+ }
389
+ return payload;
390
+ }
391
+
309
392
  function isPartialNavigation(skippedSegments: string[] | null | undefined): boolean {
310
393
  return skippedSegments != null && skippedSegments.length > 0;
311
394
  }
@@ -357,9 +440,11 @@ export function createRouter(deps: RouterDeps): RouterInstance {
357
440
 
358
441
  /**
359
442
  * Render a payload via navigateTransition (production) or renderRoot (tests).
360
- * The perform callback should fetch data, update state, and return the
361
- * FetchResult plus the NavigationState (so it can be passed explicitly
362
- * to wrapPayload/renderRoot without temporal coupling).
443
+ * The perform callback should fetch data, call commitNavigation, and return
444
+ * the FetchResult plus the NavigationState.
445
+ *
446
+ * State management (segmentCache, navState, historyStack) is handled by
447
+ * commitNavigation inside perform — this function only handles rendering.
363
448
  */
364
449
  async function renderViaTransition(
365
450
  url: string,
@@ -372,11 +457,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
372
457
  if (isPartialNavigation(result.skippedSegments)) {
373
458
  const segmentUpdates = buildSegmentUpdates(result);
374
459
 
375
- historyStack.push(url, {
376
- payload: null,
377
- params: result.params,
378
- });
379
-
380
460
  // Re-wrap the CURRENT element with new context values.
381
461
  // SegmentOutlets read updates from SegmentUpdateContext;
382
462
  // NavigationProvider gets new params/pathname.
@@ -389,10 +469,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
389
469
  }
390
470
 
391
471
  // Full navigation — empty updates, render the new tree.
392
- historyStack.push(url, {
393
- payload: result.payload,
394
- params: result.params,
395
- });
396
472
  const element = wrapPayload(result.payload, result.navState);
397
473
  return { element, decodePromise: result.decodePromise };
398
474
  });
@@ -400,24 +476,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
400
476
  }
401
477
  // Fallback: no transition (tests, no React tree)
402
478
  const result = await perform();
403
- if (isPartialNavigation(result.skippedSegments)) {
404
- historyStack.push(url, {
405
- payload: null,
406
- params: result.params,
407
- });
408
- } else {
409
- // Resolve thenables before storing/rendering in the fallback path
410
- // (tests without navigateTransition). In production, React handles
411
- // thenables via Suspense; in tests, renderRoot expects resolved values.
412
- const payload =
413
- result.payload != null && typeof result.payload === 'object' && 'then' in result.payload
414
- ? await (result.payload as PromiseLike<unknown>)
415
- : result.payload;
416
- historyStack.push(url, {
417
- payload,
418
- params: result.params,
419
- });
420
- renderPayload(payload, result.navState);
479
+ if (!isPartialNavigation(result.skippedSegments)) {
480
+ renderPayload(result.payload, result.navState);
421
481
  }
422
482
  }
423
483
 
@@ -505,18 +565,21 @@ export function createRouter(deps: RouterDeps): RouterInstance {
505
565
  deps.setRouterNavigating?.(false);
506
566
  }
507
567
 
508
- // NOTE: History push is deferred — the merged payload (after segment
509
- // merging in renderViaTransition) is stored by the caller, not here.
510
- // Storing result.payload here would record the partial (pre-merge)
511
- // RSC tree, causing handlePopState to replay an incomplete tree.
512
-
513
- // Update the segment cache with the new route's segment tree.
514
- updateSegmentCache(result.segmentInfo);
515
-
516
- // Update navigation state and capture it for explicit passing.
517
- const navState = updateNavigationState(result.params, url);
568
+ // Resolve thenable payloads in the test path so popstate replay
569
+ // and renderPayload receive plain values (see resolveForFallback).
570
+ const payload = await resolveForFallback(result.payload);
571
+
572
+ // Atomically update all navigation state via commitNavigation.
573
+ // Partial navigations store null payload — the partial RSC tree
574
+ // can't be replayed standalone; popstate will fetch fresh.
575
+ const isPartial = isPartialNavigation(result.skippedSegments);
576
+ const navState = commitNavigation(url, {
577
+ payload: isPartial ? null : payload,
578
+ params: result.params,
579
+ segmentInfo: result.segmentInfo,
580
+ });
518
581
 
519
- return { ...result, navState };
582
+ return { ...result, payload, navState };
520
583
  }
521
584
 
522
585
  async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
@@ -534,10 +597,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
534
597
  const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
535
598
  const fetchUrl = hashIndex === -1 ? url : url.slice(0, hashIndex);
536
599
 
537
- // Create an abort controller for this navigation. Links to the external
538
- // signal (Navigation API's event.signal) when provided.
539
- const navAbort = createNavAbort(externalSignal);
540
-
541
600
  // Capture the departing page's scroll position for scroll={false} preservation.
542
601
  const currentScrollY = deps.getScrollY();
543
602
 
@@ -549,114 +608,69 @@ export function createRouter(deps: RouterDeps): RouterInstance {
549
608
  deps.replaceState({ timber: true, scrollY: currentScrollY }, '', deps.getCurrentUrl());
550
609
  }
551
610
 
552
- // When Navigation API is active, initiate the navigation via
553
- // navigation.navigate() BEFORE the fetch. Unlike history.pushState()
554
- // which commits the URL synchronously (so Chrome sees it as "done"),
555
- // navigation.navigate() fires the navigate event before committing.
556
- // Our handler intercepts with a deferred promise, and Chrome shows
557
- // its native loading indicator until completeRouterNavigation()
558
- // resolves it in the finally block (same time as TopLoader clears).
559
611
  let effectiveSkipHistory = skipHistory;
560
- if (!skipHistory && deps.navigationNavigate) {
561
- deps.setRouterNavigating?.(true);
562
- deps.navigationNavigate(url, replace);
563
- deps.setRouterNavigating?.(false);
564
- effectiveSkipHistory = true;
565
- }
566
612
 
567
- setPending(true, url);
568
-
569
- try {
570
- await renderViaTransition(fetchUrl, () =>
571
- performNavigationFetch(fetchUrl, {
572
- replace,
573
- commitUrl: url,
574
- signal: navAbort.signal,
575
- skipHistory: effectiveSkipHistory,
576
- })
577
- );
613
+ await runNavigation(
614
+ url,
615
+ async (navAbort) => {
616
+ // When Navigation API is active, initiate the navigation via
617
+ // navigation.navigate() BEFORE the fetch. Must happen after
618
+ // createNavAbort supersedes the previous navigation (done by
619
+ // runNavigation) so the old deferred is resolved first.
620
+ if (!effectiveSkipHistory && deps.navigationNavigate) {
621
+ deps.setRouterNavigating?.(true);
622
+ deps.navigationNavigate(url, replace);
623
+ deps.setRouterNavigating?.(false);
624
+ effectiveSkipHistory = true;
625
+ }
578
626
 
579
- // Notify nuqs adapter (and any other listeners) that navigation completed.
580
- window.dispatchEvent(new Event('timber:navigation-end'));
627
+ try {
628
+ await renderViaTransition(fetchUrl, () =>
629
+ performNavigationFetch(fetchUrl, {
630
+ replace,
631
+ commitUrl: url,
632
+ signal: navAbort.signal,
633
+ skipHistory: effectiveSkipHistory,
634
+ })
635
+ );
581
636
 
582
- // Scroll-to-top on forward navigation, scroll to the #fragment target
583
- // when the URL has one, or restore captured position for scroll={false}.
584
- // React's render() on the document root can reset scroll during DOM
585
- // reconciliation, so all scroll must be actively managed.
586
- if (scroll && hash) {
587
- scrollToHashAfterPaint(hash);
588
- } else {
589
- restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
590
- }
591
- } catch (error) {
592
- // Version skew — server has been redeployed. Full page reload
593
- // so the browser fetches the new bundle. See TIM-446.
594
- if (error instanceof VersionSkewError) {
595
- setHardNavigating(true);
596
- window.location.reload();
597
- return new Promise(() => {}) as never;
598
- }
599
- // Server-side redirect during RSC fetch → soft router navigation.
600
- // The redirect navigate will push/replace its own URL.
601
- if (error instanceof RedirectError) {
602
- // Drop the redirect if a newer navigation superseded this one
603
- // while the redirect response was in flight — the newer
604
- // navigation wins, same as the stale-transition guard.
605
- if (currentNavAbort !== navAbort) return;
606
- // The recursive navigate() supersedes this one via createNavAbort,
607
- // which also resolves this navigation's Navigation API deferred.
608
- await navigate(error.redirectUrl, { replace: true });
609
- return;
610
- }
611
- // Server 5xx error — hard-navigate so the server renders the
612
- // error page as HTML. See design/10-error-handling.md
613
- // §"Error Page Rendering for Client Navigation".
614
- //
615
- // Set hard-navigating flag BEFORE setting window.location.href:
616
- // 1. Prevents Navigation API from intercepting → infinite loop
617
- // 2. Causes NavigationRoot to throw unresolvedThenable → prevents
618
- // React from rendering children during page teardown (avoids
619
- // "Rendered more hooks" crashes). See TIM-626.
620
- if (error instanceof ServerErrorResponse) {
621
- setHardNavigating(true);
622
- window.location.href = error.url;
623
- return new Promise(() => {}) as never;
624
- }
625
- // Abort errors are not application errors — swallow silently.
626
- if (isAbortError(error)) return;
627
- throw error;
628
- } finally {
629
- // Staleness guard (TIM-1034): only clean up when this navigation
630
- // still owns the abort controller. When a newer navigation
631
- // superseded this one, createNavAbort() already replaced
632
- // currentNavAbort — running setPending(false) /
633
- // completeRouterNavigation() here would clear the NEWER
634
- // navigation's pending state and resolve its Navigation API
635
- // deferred. The superseding navigation resolved OUR deferred in
636
- // createNavAbort().
637
- if (currentNavAbort === navAbort) {
638
- // Clear the abort controller so we don't abort a completed
639
- // navigation when the next one starts. In dev mode, the RSC body
640
- // stream stays open after data arrives (React's Flight client
641
- // waits for debug rows). Aborting a "completed" navigation kills
642
- // the open stream reader → "BodyStreamBuffer was aborted".
643
- currentNavAbort = null;
644
- setPending(false);
645
- // Resolve the Navigation API deferred — clears the browser's
646
- // native loading state (tab spinner) at the same time as the
647
- // TopLoader.
648
- deps.completeRouterNavigation?.();
649
- }
650
- }
637
+ // Notify nuqs adapter (and any other listeners) that navigation completed.
638
+ window.dispatchEvent(new Event('timber:navigation-end'));
639
+
640
+ // Scroll-to-top on forward navigation, scroll to the #fragment target
641
+ // when the URL has one, or restore captured position for scroll={false}.
642
+ if (scroll && hash) {
643
+ scrollToHashAfterPaint(hash);
644
+ } else {
645
+ restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
646
+ }
647
+ } catch (error) {
648
+ if (error instanceof VersionSkewError) {
649
+ setHardNavigating(true);
650
+ window.location.reload();
651
+ await new Promise(() => {});
652
+ }
653
+ if (error instanceof RedirectError) {
654
+ if (currentNavAbort !== navAbort) return;
655
+ await navigate(error.redirectUrl, { replace: true });
656
+ return;
657
+ }
658
+ if (error instanceof ServerErrorResponse) {
659
+ setHardNavigating(true);
660
+ window.location.href = error.url;
661
+ await new Promise(() => {});
662
+ }
663
+ throw error;
664
+ }
665
+ },
666
+ externalSignal
667
+ );
651
668
  }
652
669
 
653
670
  async function refresh(): Promise<void> {
654
671
  const currentUrl = deps.getCurrentUrl();
655
- const navAbort = createNavAbort();
656
-
657
- setPending(true, currentUrl);
658
672
 
659
- try {
673
+ await runNavigation(currentUrl, async (navAbort) => {
660
674
  await renderViaTransition(currentUrl, async () => {
661
675
  // No state tree sent — server renders the complete RSC payload
662
676
  const result = await fetchRscPayload(
@@ -666,22 +680,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
666
680
  undefined,
667
681
  navAbort.signal
668
682
  );
669
- // History push handled by renderViaTransition (stores merged payload)
670
- updateSegmentCache(result.segmentInfo);
671
- const navState = updateNavigationState(result.params, currentUrl);
672
- return { ...result, navState };
683
+ const payload = await resolveForFallback(result.payload);
684
+ const navState = commitNavigation(currentUrl, {
685
+ payload,
686
+ params: result.params,
687
+ segmentInfo: result.segmentInfo,
688
+ });
689
+ return { ...result, payload, navState };
673
690
  });
674
- } catch (error) {
675
- if (isAbortError(error)) return;
676
- throw error;
677
- } finally {
678
- // Staleness guard (TIM-1034) — see navigate()'s finally block.
679
- if (currentNavAbort === navAbort) {
680
- currentNavAbort = null;
681
- setPending(false);
682
- deps.completeRouterNavigation?.();
683
- }
684
- }
691
+ });
685
692
  }
686
693
 
687
694
  async function handlePopState(
@@ -697,47 +704,51 @@ export function createRouter(deps: RouterDeps): RouterInstance {
697
704
  if (entry && entry.payload !== null) {
698
705
  // Replay cached payload — no server roundtrip.
699
706
  //
700
- // A forward navigation may still be in flight (back pressed while a
701
- // slow RSC fetch was pending). createNavAbort() supersedes it: aborts
702
- // its fetch and invalidates its render transition so the stale
703
- // forward payload can't commit over this replay (TIM-1022). The
704
- // replay itself is synchronous — nothing to abort later — so the
705
- // controller is cleared immediately, and any pending state left by
706
- // the superseded navigation is reset.
707
- createNavAbort(externalSignal);
708
- currentNavAbort = null;
709
- setPending(false);
710
- const navState = updateNavigationState(entry.params, url);
711
- renderPayload(entry.payload, navState);
712
- restoreScrollAfterPaint(scrollY);
707
+ // runNavigation supersedes any in-flight forward navigation (TIM-1022):
708
+ // aborts its fetch and invalidates its render transition so the stale
709
+ // forward payload can't commit over this replay. The replay itself is
710
+ // synchronous — the fn resolves immediately.
711
+ await runNavigation(
712
+ url,
713
+ async () => {
714
+ // clearSegmentCacheOnEmpty: popstate to an entry without layout
715
+ // metadata (e.g., initial SSR page) clears the cache so the next
716
+ // forward navigation gets a full render.
717
+ const navState = commitNavigation(url, {
718
+ payload: entry.payload,
719
+ params: entry.params,
720
+ segmentInfo: entry.segmentInfo,
721
+ clearSegmentCacheOnEmpty: true,
722
+ });
723
+ renderPayload(entry.payload, navState);
724
+ restoreScrollAfterPaint(scrollY);
725
+ },
726
+ externalSignal
727
+ );
713
728
  } else {
714
729
  // No cached payload — fetch from server.
715
730
  // This happens when navigating back to the initial SSR'd page
716
731
  // (its payload is null since it was rendered via SSR, not RSC fetch)
717
732
  // or when the entry doesn't exist at all.
718
- const navAbort = createNavAbort(externalSignal);
719
- setPending(true, url);
720
- try {
721
- await renderViaTransition(url, async () => {
722
- const stateTree = segmentCache.serializeStateTree();
723
- const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
724
- updateSegmentCache(result.segmentInfo);
725
- const navState = updateNavigationState(result.params, url);
726
- // History push handled by renderViaTransition (stores merged payload)
727
- return { ...result, navState };
728
- });
733
+ await runNavigation(
734
+ url,
735
+ async (navAbort) => {
736
+ await renderViaTransition(url, async () => {
737
+ const stateTree = segmentCache.serializeStateTree();
738
+ const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
739
+ const payload = await resolveForFallback(result.payload);
740
+ const navState = commitNavigation(url, {
741
+ payload,
742
+ params: result.params,
743
+ segmentInfo: result.segmentInfo,
744
+ });
745
+ return { ...result, payload, navState };
746
+ });
729
747
 
730
- restoreScrollAfterPaint(scrollY);
731
- } catch (error) {
732
- if (isAbortError(error)) return;
733
- throw error;
734
- } finally {
735
- // Staleness guard (TIM-1034) — see navigate()'s finally block.
736
- if (currentNavAbort === navAbort) {
737
- currentNavAbort = null;
738
- setPending(false);
739
- }
740
- }
748
+ restoreScrollAfterPaint(scrollY);
749
+ },
750
+ externalSignal
751
+ );
741
752
  }
742
753
  }
743
754
 
@@ -783,21 +794,16 @@ export function createRouter(deps: RouterDeps): RouterInstance {
783
794
  // Render the piggybacked element tree from a server action response.
784
795
  // Updates the current history entry with the fresh payload —
785
796
  // same as refresh() but without a server fetch.
786
- // Revalidation payloads are already resolved (no Flight thenable),
787
- // so no segment merging is needed — just cache and render.
788
797
  const currentUrl = deps.getCurrentUrl();
789
798
 
790
- // Revalidation doesn't change params/pathname — preserve current state.
791
- // DO NOT call updateNavigationState(null, ...) here: that normalizes
792
- // params to {}, clearing dynamic route params on every action response.
793
- const navState = getNavigationState();
794
-
795
- // Keep params on the overwritten entry — dropping them would make
796
- // away-and-back navigation replay this page with params normalized
797
- // to {} (TIM-1037).
798
- historyStack.push(currentUrl, {
799
+ // Preserve existing segmentInfo so away-and-back navigation replays
800
+ // with a correct segment cache (TIM-1037). Preserve current params
801
+ // so dynamic route params aren't cleared to {}.
802
+ const existingEntry = historyStack.get(currentUrl);
803
+ const navState = commitNavigation(currentUrl, {
799
804
  payload: element,
800
- params: navState.params,
805
+ params: getNavigationState().params,
806
+ segmentInfo: existingEntry?.segmentInfo,
801
807
  });
802
808
  renderPayload(element, navState);
803
809
  },
@@ -83,6 +83,7 @@ export function timberAdapterBuild(ctx: PluginContext): Plugin {
83
83
  }
84
84
 
85
85
  const adapterConfig: TimberConfig = {
86
+ root: ctx.root,
86
87
  output: ctx.config.output ?? 'server',
87
88
  clientJavascriptDisabled: ctx.clientJavascript.disabled,
88
89
  manifestInit,
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * timber-cache-transform — Vite plugin that injects stable callsite IDs
3
- * into `cache()` calls imported from `@timber-js/app/cache`.
3
+ * into `cache.data()` calls imported from `@timber-js/app/cache`.
4
4
  *
5
5
  * Detected callsite forms (TIM-1054):
6
- * - named imports, including aliases: `import { cache as c }` + `c(fn, opts)`
7
- * - namespace imports: `import * as ns` + `ns.cache(fn, opts)`
6
+ * - named imports, including aliases: `import { cache as c }` + `c.data(fn, opts)`
7
+ * - namespace imports: `import * as ns` + `ns.cache.data(fn, opts)`
8
8
  * Forms a per-module transform cannot trace — local variable aliases
9
9
  * (`const c = cache`), user re-export wrapper modules, computed access
10
- * (`ns['cache']`) — fall back to the runtime fnId derived from
10
+ * (`cache['data']`) — fall back to the runtime fnId derived from
11
11
  * fnv1a(fn.toString()) (see cache/timber-cache.ts), which is deterministic
12
12
  * across instances and cold starts of the same build. Without either
13
13
  * mechanism, an ordinal counter would collide across instances with a shared
@@ -15,8 +15,8 @@
15
15
  * data to another.
16
16
  *
17
17
  * The plugin transforms:
18
- * cache(fn, opts) → cache(fn, opts, "stable-id")
19
- * ns.cache(fn, opts) → ns.cache(fn, opts, "stable-id")
18
+ * cache.data(fn, opts) → cache.data(fn, opts, "stable-id")
19
+ * ns.cache.data(fn, opts) → ns.cache.data(fn, opts, "stable-id")
20
20
  *
21
21
  * where stable-id =
22
22
  * fnv1a(relPath + ":cache:" + fnv1a(stmtText) + ":" + fnv1a(callSpanText) + ":" + occurrence)
@@ -34,9 +34,9 @@
34
34
  * unrelated edits, while a changed call gets a fresh ID — which is exactly
35
35
  * when invalidation is wanted.
36
36
  *
37
- * The enclosing-statement text is included so that byte-identical cache()
37
+ * The enclosing-statement text is included so that byte-identical cache.data()
38
38
  * calls in different scopes — e.g. two factory helpers that each `return
39
- * cache(fn, { ttl: 60 })` — get distinct, insertion-stable IDs: their
39
+ * cache.data(fn, { ttl: 60 })` — get distinct, insertion-stable IDs: their
40
40
  * enclosing declarations differ by name/body even when the call spans are
41
41
  * identical. Without it, a source-order occurrence counter alone would shift
42
42
  * when an identical span is inserted above, reassigning IDs across deploys.
@@ -75,41 +75,56 @@ import type {
75
75
  } from './callsite-ast.js';
76
76
 
77
77
  /**
78
- * Is this callee a reference to the imported `cache` function — either a bare
79
- * identifier bound by a named import, or `ns.cache` on a namespace import?
80
- * Computed access (`ns['cache']`) is deliberately not matched; it falls back
78
+ * Is this callee a `cache.data(...)` call? Matches two forms:
79
+ * - Named import: `cache.data(fn, opts)` where `cache` is the imported binding
80
+ * - Namespace import: `ns.cache.data(fn, opts)` where `ns` is the namespace
81
+ * Computed access (`cache['data']`) is deliberately not matched; it falls back
81
82
  * to the runtime fnId derivation.
82
83
  */
83
- function isCacheCallee(callee: PositionedNode, bindings: ImportBindings): boolean {
84
- if (callee.type === 'Identifier') {
85
- return bindings.named.has((callee as IdentifierNode).name);
84
+ function isCacheDataCallee(callee: PositionedNode, bindings: ImportBindings): boolean {
85
+ if (callee.type !== 'MemberExpression') return false;
86
+ const member = callee as unknown as MemberExpressionNode;
87
+ if (member.computed) return false;
88
+ if (member.property.type !== 'Identifier' || (member.property as IdentifierNode).name !== 'data')
89
+ return false;
90
+
91
+ const obj = member.object;
92
+ // Named import: `cache.data(...)` — obj is the imported `cache` identifier
93
+ if (obj.type === 'Identifier') {
94
+ return bindings.named.has((obj as IdentifierNode).name);
86
95
  }
87
- if (callee.type === 'MemberExpression') {
88
- const member = callee as unknown as MemberExpressionNode;
96
+ // Namespace import: `ns.cache.data(...)` — obj is `ns.cache`
97
+ if (obj.type === 'MemberExpression') {
98
+ const outer = obj as unknown as MemberExpressionNode;
89
99
  return (
90
- !member.computed &&
91
- member.object.type === 'Identifier' &&
92
- bindings.namespaces.has((member.object as IdentifierNode).name) &&
93
- member.property.type === 'Identifier' &&
94
- (member.property as IdentifierNode).name === 'cache'
100
+ !outer.computed &&
101
+ outer.object.type === 'Identifier' &&
102
+ bindings.namespaces.has((outer.object as IdentifierNode).name) &&
103
+ outer.property.type === 'Identifier' &&
104
+ (outer.property as IdentifierNode).name === 'cache'
95
105
  );
96
106
  }
97
107
  return false;
98
108
  }
99
109
 
100
110
  /**
101
- * Extract the local binding name that a cache callee resolves to — the
102
- * identifier for named imports, or the namespace object for `ns.cache()`.
111
+ * Extract the local binding name that a cache.data callee resolves to — the
112
+ * `cache` identifier for named imports, or the namespace object for `ns.cache.data()`.
103
113
  */
104
114
  function calleeBindingName(callee: PositionedNode, bindings: ImportBindings): string | null {
105
- if (callee.type === 'Identifier') {
106
- const name = (callee as IdentifierNode).name;
115
+ if (callee.type !== 'MemberExpression') return null;
116
+ const member = callee as unknown as MemberExpressionNode;
117
+ const obj = member.object;
118
+ // Named import: `cache.data(...)` — binding is `cache`
119
+ if (obj.type === 'Identifier') {
120
+ const name = (obj as IdentifierNode).name;
107
121
  return bindings.named.has(name) ? name : null;
108
122
  }
109
- if (callee.type === 'MemberExpression') {
110
- const member = callee as unknown as MemberExpressionNode;
111
- if (member.object.type === 'Identifier') {
112
- const name = (member.object as IdentifierNode).name;
123
+ // Namespace import: `ns.cache.data(...)` — binding is `ns`
124
+ if (obj.type === 'MemberExpression') {
125
+ const outer = obj as unknown as MemberExpressionNode;
126
+ if (outer.object.type === 'Identifier') {
127
+ const name = (outer.object as IdentifierNode).name;
113
128
  return bindings.namespaces.has(name) ? name : null;
114
129
  }
115
130
  }
@@ -182,7 +197,7 @@ export function timberCacheTransform(ctx: PluginContext): Plugin {
182
197
  stmt,
183
198
  (call) =>
184
199
  call.arguments.length === 2 &&
185
- isCacheCallee(call.callee, cacheBindings) &&
200
+ isCacheDataCallee(call.callee, cacheBindings) &&
186
201
  !isCalleeShadowed(call),
187
202
  calls
188
203
  );