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

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 (188) hide show
  1. package/dist/_chunks/{actions-CDPfMp_I.js → actions-O_LsyCE4.js} +3 -3
  2. package/dist/_chunks/{actions-CDPfMp_I.js.map → actions-O_LsyCE4.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-DygSeKCB.js → cache-api-B-lhk9p4.js} +107 -58
  4. package/dist/_chunks/cache-api-B-lhk9p4.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-EXGYPhI2.js → cli-schema-sync-B73L6pMq.js} +5 -3
  6. package/dist/_chunks/cli-schema-sync-B73L6pMq.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/json-lossy-check-ClNvBM_3.js +63 -0
  10. package/dist/_chunks/json-lossy-check-ClNvBM_3.js.map +1 -0
  11. package/dist/_chunks/{logger-t3uxAmbX.js → logger-AWfuX-KJ.js} +2 -19
  12. package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -0
  13. package/dist/_chunks/{walkers-DBVzXuWc.js → walkers-CoOC8Hga.js} +2 -2
  14. package/dist/_chunks/{walkers-DBVzXuWc.js.map → walkers-CoOC8Hga.js.map} +1 -1
  15. package/dist/adapters/cloudflare-dev.js +1 -1
  16. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  17. package/dist/adapters/cloudflare-kv-cache.js +10 -3
  18. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  19. package/dist/adapters/cloudflare.d.ts +12 -1
  20. package/dist/adapters/cloudflare.d.ts.map +1 -1
  21. package/dist/adapters/cloudflare.js +2 -461
  22. package/dist/adapters/types.d.ts +2 -0
  23. package/dist/adapters/types.d.ts.map +1 -1
  24. package/dist/cache/cache-api.d.ts +33 -11
  25. package/dist/cache/cache-api.d.ts.map +1 -1
  26. package/dist/cache/index.d.ts +1 -1
  27. package/dist/cache/index.d.ts.map +1 -1
  28. package/dist/cache/index.js +1 -1
  29. package/dist/cache/json-lossy-check.d.ts +11 -0
  30. package/dist/cache/json-lossy-check.d.ts.map +1 -0
  31. package/dist/cache/redis-handler.d.ts +42 -0
  32. package/dist/cache/redis-handler.d.ts.map +1 -1
  33. package/dist/cache/singleflight.d.ts.map +1 -1
  34. package/dist/cache/stores/cloudflare-kv.d.ts +1 -1
  35. package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -1
  36. package/dist/cache/stores/memory.d.ts +1 -1
  37. package/dist/cache/stores/memory.d.ts.map +1 -1
  38. package/dist/cache/stores/redis.d.ts +1 -1
  39. package/dist/cache/stores/redis.d.ts.map +1 -1
  40. package/dist/cache/stores/vercel.d.ts +1 -1
  41. package/dist/cache/stores/vercel.d.ts.map +1 -1
  42. package/dist/cache/tag-aware-handler.d.ts.map +1 -1
  43. package/dist/cache/timber-cache.d.ts.map +1 -1
  44. package/dist/cdn/cloudflare-purge.d.ts.map +1 -1
  45. package/dist/cdn/fastly-purge.d.ts.map +1 -1
  46. package/dist/cdn/workers-cache-purge.d.ts.map +1 -1
  47. package/dist/cli.d.ts +1 -1
  48. package/dist/cli.d.ts.map +1 -1
  49. package/dist/cli.js +3 -2
  50. package/dist/cli.js.map +1 -1
  51. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  52. package/dist/client/browser-entry/router-init.d.ts +1 -0
  53. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  54. package/dist/client/child-segment-context.d.ts +2 -2
  55. package/dist/client/child-segment-context.d.ts.map +1 -1
  56. package/dist/client/error-boundary.d.ts.map +1 -1
  57. package/dist/client/history.d.ts +10 -0
  58. package/dist/client/history.d.ts.map +1 -1
  59. package/dist/client/internal.js +141 -116
  60. package/dist/client/internal.js.map +1 -1
  61. package/dist/client/router.d.ts +6 -0
  62. package/dist/client/router.d.ts.map +1 -1
  63. package/dist/client/rsc-fetch.d.ts.map +1 -1
  64. package/dist/client/segment-cache.d.ts.map +1 -1
  65. package/dist/client/segment-update-context.d.ts +3 -3
  66. package/dist/client/segment-update-context.d.ts.map +1 -1
  67. package/dist/client/slot-outlet.d.ts +1 -1
  68. package/dist/codec.d.ts.map +1 -1
  69. package/dist/codec.js +2 -1
  70. package/dist/codec.js.map +1 -1
  71. package/dist/config-types.d.ts +16 -37
  72. package/dist/config-types.d.ts.map +1 -1
  73. package/dist/config-validation.d.ts.map +1 -1
  74. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  75. package/dist/fonts/pipeline.d.ts +19 -0
  76. package/dist/fonts/pipeline.d.ts.map +1 -1
  77. package/dist/fonts/transform.d.ts.map +1 -1
  78. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +378 -109
  81. package/dist/index.js.map +1 -1
  82. package/dist/plugin-context.d.ts.map +1 -1
  83. package/dist/plugins/adapter-build.d.ts +1 -0
  84. package/dist/plugins/adapter-build.d.ts.map +1 -1
  85. package/dist/plugins/cache.d.ts +8 -8
  86. package/dist/plugins/cache.d.ts.map +1 -1
  87. package/dist/plugins/entries.d.ts +27 -3
  88. package/dist/plugins/entries.d.ts.map +1 -1
  89. package/dist/plugins/fonts.d.ts.map +1 -1
  90. package/dist/plugins/server-bundle.d.ts.map +1 -1
  91. package/dist/routing/index.js +2 -2
  92. package/dist/server/action-client.d.ts +1 -1
  93. package/dist/server/action-client.d.ts.map +1 -1
  94. package/dist/server/als-registry.d.ts +7 -0
  95. package/dist/server/als-registry.d.ts.map +1 -1
  96. package/dist/server/body-limits.d.ts.map +1 -1
  97. package/dist/server/deny-boundary.d.ts +3 -1
  98. package/dist/server/deny-boundary.d.ts.map +1 -1
  99. package/dist/server/form-data.d.ts.map +1 -1
  100. package/dist/server/html-injector-core.d.ts.map +1 -1
  101. package/dist/server/index.js +20 -3
  102. package/dist/server/index.js.map +1 -1
  103. package/dist/server/internal.js +28 -41
  104. package/dist/server/internal.js.map +1 -1
  105. package/dist/server/middleware-runner.d.ts +0 -17
  106. package/dist/server/middleware-runner.d.ts.map +1 -1
  107. package/dist/server/param-coercion.d.ts.map +1 -1
  108. package/dist/server/pipeline-phases.d.ts +6 -3
  109. package/dist/server/pipeline-phases.d.ts.map +1 -1
  110. package/dist/server/pipeline.d.ts +18 -0
  111. package/dist/server/pipeline.d.ts.map +1 -1
  112. package/dist/server/prebuilt/capture-state.d.ts.map +1 -1
  113. package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
  114. package/dist/server/primitives.d.ts.map +1 -1
  115. package/dist/server/render-timeout.d.ts.map +1 -1
  116. package/dist/server/route-element-builder.d.ts +9 -0
  117. package/dist/server/route-element-builder.d.ts.map +1 -1
  118. package/dist/server/rsc-entry/action-middleware-runner.d.ts +14 -14
  119. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  120. package/dist/server/rsc-entry/render-route.d.ts +1 -0
  121. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  122. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  123. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  124. package/dist/server/rsc-entry/rsc-stream.d.ts +8 -0
  125. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  126. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +44 -74
  127. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -1
  128. package/dist/server/safe-load.d.ts.map +1 -1
  129. package/dist/server/ssr-entry.d.ts.map +1 -1
  130. package/dist/shared/redirect-type.d.ts +2 -2
  131. package/dist/shared/redirect-type.d.ts.map +1 -1
  132. package/dist/shims/font-google.d.ts.map +1 -1
  133. package/dist/shims/image.d.ts +120 -120
  134. package/dist/shims/image.d.ts.map +1 -1
  135. package/docs/api/32-api-cache.mdx +116 -4
  136. package/docs/api/34-api-config.mdx +0 -26
  137. package/docs/learn/09-caching.mdx +126 -10
  138. package/docs/learn/12-client-navigation.mdx +9 -1
  139. package/docs/learn/13-configuration.mdx +6 -8
  140. package/docs/learn/14-deploying.mdx +18 -18
  141. package/docs/more/03-coming-from-nextjs.mdx +2 -2
  142. package/docs/more/04-metadata-and-fonts.mdx +1 -1
  143. package/docs/more/50-ai-agent-instructions.mdx +5 -5
  144. package/package.json +6 -5
  145. package/src/adapters/cloudflare-kv-cache.ts +27 -6
  146. package/src/adapters/cloudflare.ts +63 -25
  147. package/src/adapters/types.ts +2 -0
  148. package/src/cache/cache-api.ts +84 -84
  149. package/src/cache/index.ts +1 -1
  150. package/src/cache/json-lossy-check.ts +75 -0
  151. package/src/cache/redis-handler.ts +65 -14
  152. package/src/cache/timber-cache.ts +10 -2
  153. package/src/client/browser-entry/index.ts +2 -0
  154. package/src/client/browser-entry/post-hydration.ts +16 -9
  155. package/src/client/browser-entry/router-init.ts +2 -0
  156. package/src/client/history.ts +11 -0
  157. package/src/client/router.ts +224 -208
  158. package/src/codec.ts +3 -1
  159. package/src/config-types.ts +16 -37
  160. package/src/config-validation.ts +7 -5
  161. package/src/fonts/pipeline.ts +32 -0
  162. package/src/fonts/transform.ts +30 -24
  163. package/src/plugins/adapter-build.ts +132 -13
  164. package/src/plugins/cache.ts +45 -30
  165. package/src/plugins/entries.ts +40 -43
  166. package/src/plugins/fonts.ts +30 -0
  167. package/src/plugins/server-bundle.ts +7 -15
  168. package/src/routing/scanner.ts +7 -0
  169. package/src/server/action-client.ts +1 -1
  170. package/src/server/als-registry.ts +7 -0
  171. package/src/server/deny-boundary.ts +6 -3
  172. package/src/server/middleware-runner.ts +0 -44
  173. package/src/server/param-coercion.ts +1 -0
  174. package/src/server/pipeline-phases.ts +11 -13
  175. package/src/server/pipeline.ts +51 -3
  176. package/src/server/route-element-builder.ts +59 -9
  177. package/src/server/rsc-entry/action-middleware-runner.ts +14 -14
  178. package/src/server/rsc-entry/index.ts +30 -40
  179. package/src/server/rsc-entry/render-route.ts +6 -2
  180. package/src/server/rsc-entry/rsc-payload.ts +21 -4
  181. package/src/server/rsc-entry/rsc-stream.ts +8 -0
  182. package/src/server/rsc-entry/wrap-action-dispatch.ts +109 -372
  183. package/dist/_chunks/cache-api-DygSeKCB.js.map +0 -1
  184. package/dist/_chunks/cli-schema-sync-EXGYPhI2.js.map +0 -1
  185. package/dist/_chunks/logger-t3uxAmbX.js.map +0 -1
  186. package/dist/_chunks/tree-match-D2l830j2.js +0 -102
  187. package/dist/_chunks/tree-match-D2l830j2.js.map +0 -1
  188. package/dist/adapters/cloudflare.js.map +0 -1
@@ -155,6 +155,13 @@ export interface RouterDeps {
155
155
  * as a full page load with an unknown fragment landing at the top.
156
156
  */
157
157
  scrollToHash?: (hash: string) => boolean;
158
+
159
+ /**
160
+ * Whether the client segment cache is enabled. When false (the default),
161
+ * the router does not send X-Timber-State-Tree headers and does not
162
+ * populate the segment cache. Every navigation gets a full RSC payload.
163
+ */
164
+ clientSegmentCache?: boolean;
158
165
  }
159
166
 
160
167
  export interface RouterInstance {
@@ -292,6 +299,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
292
299
 
293
300
  /** Update the segment cache from server-provided segment metadata. */
294
301
  function updateSegmentCache(segmentInfo: SegmentInfo[] | null | undefined): void {
302
+ if (!deps.clientSegmentCache) return;
295
303
  if (!segmentInfo || segmentInfo.length === 0) return;
296
304
  const tree = buildSegmentTree(segmentInfo);
297
305
  if (tree) {
@@ -306,6 +314,89 @@ export function createRouter(deps: RouterDeps): RouterInstance {
306
314
  }
307
315
  }
308
316
 
317
+ /**
318
+ * Atomically update all navigation-owned state for a new page. Every
319
+ * code path that changes the "current page" must go through this
320
+ * function — making "forgot a field" impossible by construction.
321
+ *
322
+ * The three operations:
323
+ * 1. Segment cache — update from server-provided segment metadata
324
+ * 2. Navigation state — params + pathname for useSegmentParams/usePathname
325
+ * 3. History stack — store the payload for instant back/forward replay
326
+ */
327
+ function commitNavigation(
328
+ url: string,
329
+ opts: {
330
+ payload: unknown;
331
+ params?: Record<string, string | string[]> | null;
332
+ segmentInfo?: SegmentInfo[] | null;
333
+ /** When true, clear the segment cache if segmentInfo is empty
334
+ * (popstate replay for entries without layout metadata). */
335
+ clearSegmentCacheOnEmpty?: boolean;
336
+ }
337
+ ): NavigationState {
338
+ if (opts.segmentInfo && opts.segmentInfo.length > 0) {
339
+ updateSegmentCache(opts.segmentInfo);
340
+ } else if (opts.clearSegmentCacheOnEmpty) {
341
+ segmentCache.clear();
342
+ }
343
+
344
+ const navState = updateNavigationState(opts.params, url);
345
+
346
+ historyStack.push(url, {
347
+ payload: opts.payload,
348
+ params: navState.params,
349
+ segmentInfo: opts.segmentInfo,
350
+ });
351
+
352
+ return navState;
353
+ }
354
+
355
+ /**
356
+ * Wrap a navigation in the standard abort/pending/cleanup lifecycle.
357
+ * Consolidates the createNavAbort + setPending + staleness-guarded
358
+ * finally that was duplicated across navigate, refresh, and both
359
+ * handlePopState paths. AbortErrors are swallowed (not application
360
+ * errors); all other errors propagate to the caller.
361
+ */
362
+ async function runNavigation(
363
+ url: string,
364
+ fn: (navAbort: AbortController) => Promise<void>,
365
+ externalSignal?: AbortSignal
366
+ ): Promise<void> {
367
+ const navAbort = createNavAbort(externalSignal);
368
+ setPending(true, url);
369
+ try {
370
+ await fn(navAbort);
371
+ } catch (error) {
372
+ if (isAbortError(error)) return;
373
+ throw error;
374
+ } finally {
375
+ if (currentNavAbort === navAbort) {
376
+ currentNavAbort = null;
377
+ setPending(false);
378
+ deps.completeRouterNavigation?.();
379
+ }
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Resolve thenable payloads in the test/fallback path (no navigateTransition).
385
+ * In production, React handles thenables from createFromFetch directly via
386
+ * Suspense. In tests, renderRoot is a plain mock that expects resolved values.
387
+ */
388
+ async function resolveForFallback(payload: unknown): Promise<unknown> {
389
+ if (
390
+ !deps.navigateTransition &&
391
+ payload != null &&
392
+ typeof payload === 'object' &&
393
+ 'then' in payload
394
+ ) {
395
+ return await (payload as PromiseLike<unknown>);
396
+ }
397
+ return payload;
398
+ }
399
+
309
400
  function isPartialNavigation(skippedSegments: string[] | null | undefined): boolean {
310
401
  return skippedSegments != null && skippedSegments.length > 0;
311
402
  }
@@ -357,9 +448,11 @@ export function createRouter(deps: RouterDeps): RouterInstance {
357
448
 
358
449
  /**
359
450
  * 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).
451
+ * The perform callback should fetch data, call commitNavigation, and return
452
+ * the FetchResult plus the NavigationState.
453
+ *
454
+ * State management (segmentCache, navState, historyStack) is handled by
455
+ * commitNavigation inside perform — this function only handles rendering.
363
456
  */
364
457
  async function renderViaTransition(
365
458
  url: string,
@@ -372,11 +465,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
372
465
  if (isPartialNavigation(result.skippedSegments)) {
373
466
  const segmentUpdates = buildSegmentUpdates(result);
374
467
 
375
- historyStack.push(url, {
376
- payload: null,
377
- params: result.params,
378
- });
379
-
380
468
  // Re-wrap the CURRENT element with new context values.
381
469
  // SegmentOutlets read updates from SegmentUpdateContext;
382
470
  // NavigationProvider gets new params/pathname.
@@ -389,10 +477,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
389
477
  }
390
478
 
391
479
  // Full navigation — empty updates, render the new tree.
392
- historyStack.push(url, {
393
- payload: result.payload,
394
- params: result.params,
395
- });
396
480
  const element = wrapPayload(result.payload, result.navState);
397
481
  return { element, decodePromise: result.decodePromise };
398
482
  });
@@ -400,24 +484,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
400
484
  }
401
485
  // Fallback: no transition (tests, no React tree)
402
486
  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);
487
+ if (!isPartialNavigation(result.skippedSegments)) {
488
+ renderPayload(result.payload, result.navState);
421
489
  }
422
490
  }
423
491
 
@@ -480,7 +548,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
480
548
  if (result === undefined) {
481
549
  // Fetch RSC payload with state tree for partial rendering.
482
550
  // Send current URL for intercepting route resolution (modal pattern).
483
- const stateTree = segmentCache.serializeStateTree();
551
+ const stateTree = deps.clientSegmentCache ? segmentCache.serializeStateTree() : undefined;
484
552
  const rawCurrentUrl = deps.getCurrentUrl();
485
553
  const currentUrl = rawCurrentUrl.startsWith('http')
486
554
  ? new URL(rawCurrentUrl).pathname
@@ -505,18 +573,21 @@ export function createRouter(deps: RouterDeps): RouterInstance {
505
573
  deps.setRouterNavigating?.(false);
506
574
  }
507
575
 
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);
576
+ // Resolve thenable payloads in the test path so popstate replay
577
+ // and renderPayload receive plain values (see resolveForFallback).
578
+ const payload = await resolveForFallback(result.payload);
579
+
580
+ // Atomically update all navigation state via commitNavigation.
581
+ // Partial navigations store null payload — the partial RSC tree
582
+ // can't be replayed standalone; popstate will fetch fresh.
583
+ const isPartial = isPartialNavigation(result.skippedSegments);
584
+ const navState = commitNavigation(url, {
585
+ payload: isPartial ? null : payload,
586
+ params: result.params,
587
+ segmentInfo: result.segmentInfo,
588
+ });
518
589
 
519
- return { ...result, navState };
590
+ return { ...result, payload, navState };
520
591
  }
521
592
 
522
593
  async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
@@ -534,10 +605,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
534
605
  const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
535
606
  const fetchUrl = hashIndex === -1 ? url : url.slice(0, hashIndex);
536
607
 
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
608
  // Capture the departing page's scroll position for scroll={false} preservation.
542
609
  const currentScrollY = deps.getScrollY();
543
610
 
@@ -549,114 +616,69 @@ export function createRouter(deps: RouterDeps): RouterInstance {
549
616
  deps.replaceState({ timber: true, scrollY: currentScrollY }, '', deps.getCurrentUrl());
550
617
  }
551
618
 
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
619
  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
-
567
- setPending(true, url);
568
620
 
569
- try {
570
- await renderViaTransition(fetchUrl, () =>
571
- performNavigationFetch(fetchUrl, {
572
- replace,
573
- commitUrl: url,
574
- signal: navAbort.signal,
575
- skipHistory: effectiveSkipHistory,
576
- })
577
- );
621
+ await runNavigation(
622
+ url,
623
+ async (navAbort) => {
624
+ // When Navigation API is active, initiate the navigation via
625
+ // navigation.navigate() BEFORE the fetch. Must happen after
626
+ // createNavAbort supersedes the previous navigation (done by
627
+ // runNavigation) so the old deferred is resolved first.
628
+ if (!effectiveSkipHistory && deps.navigationNavigate) {
629
+ deps.setRouterNavigating?.(true);
630
+ deps.navigationNavigate(url, replace);
631
+ deps.setRouterNavigating?.(false);
632
+ effectiveSkipHistory = true;
633
+ }
578
634
 
579
- // Notify nuqs adapter (and any other listeners) that navigation completed.
580
- window.dispatchEvent(new Event('timber:navigation-end'));
635
+ try {
636
+ await renderViaTransition(fetchUrl, () =>
637
+ performNavigationFetch(fetchUrl, {
638
+ replace,
639
+ commitUrl: url,
640
+ signal: navAbort.signal,
641
+ skipHistory: effectiveSkipHistory,
642
+ })
643
+ );
581
644
 
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
- }
645
+ // Notify nuqs adapter (and any other listeners) that navigation completed.
646
+ window.dispatchEvent(new Event('timber:navigation-end'));
647
+
648
+ // Scroll-to-top on forward navigation, scroll to the #fragment target
649
+ // when the URL has one, or restore captured position for scroll={false}.
650
+ if (scroll && hash) {
651
+ scrollToHashAfterPaint(hash);
652
+ } else {
653
+ restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
654
+ }
655
+ } catch (error) {
656
+ if (error instanceof VersionSkewError) {
657
+ setHardNavigating(true);
658
+ window.location.reload();
659
+ await new Promise(() => {});
660
+ }
661
+ if (error instanceof RedirectError) {
662
+ if (currentNavAbort !== navAbort) return;
663
+ await navigate(error.redirectUrl, { replace: true });
664
+ return;
665
+ }
666
+ if (error instanceof ServerErrorResponse) {
667
+ setHardNavigating(true);
668
+ window.location.href = error.url;
669
+ await new Promise(() => {});
670
+ }
671
+ throw error;
672
+ }
673
+ },
674
+ externalSignal
675
+ );
651
676
  }
652
677
 
653
678
  async function refresh(): Promise<void> {
654
679
  const currentUrl = deps.getCurrentUrl();
655
- const navAbort = createNavAbort();
656
680
 
657
- setPending(true, currentUrl);
658
-
659
- try {
681
+ await runNavigation(currentUrl, async (navAbort) => {
660
682
  await renderViaTransition(currentUrl, async () => {
661
683
  // No state tree sent — server renders the complete RSC payload
662
684
  const result = await fetchRscPayload(
@@ -666,22 +688,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
666
688
  undefined,
667
689
  navAbort.signal
668
690
  );
669
- // History push handled by renderViaTransition (stores merged payload)
670
- updateSegmentCache(result.segmentInfo);
671
- const navState = updateNavigationState(result.params, currentUrl);
672
- return { ...result, navState };
691
+ const payload = await resolveForFallback(result.payload);
692
+ const navState = commitNavigation(currentUrl, {
693
+ payload,
694
+ params: result.params,
695
+ segmentInfo: result.segmentInfo,
696
+ });
697
+ return { ...result, payload, navState };
673
698
  });
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
- }
699
+ });
685
700
  }
686
701
 
687
702
  async function handlePopState(
@@ -697,47 +712,53 @@ export function createRouter(deps: RouterDeps): RouterInstance {
697
712
  if (entry && entry.payload !== null) {
698
713
  // Replay cached payload — no server roundtrip.
699
714
  //
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);
715
+ // runNavigation supersedes any in-flight forward navigation (TIM-1022):
716
+ // aborts its fetch and invalidates its render transition so the stale
717
+ // forward payload can't commit over this replay. The replay itself is
718
+ // synchronous — the fn resolves immediately.
719
+ await runNavigation(
720
+ url,
721
+ async () => {
722
+ // clearSegmentCacheOnEmpty: popstate to an entry without layout
723
+ // metadata (e.g., initial SSR page) clears the cache so the next
724
+ // forward navigation gets a full render.
725
+ const navState = commitNavigation(url, {
726
+ payload: entry.payload,
727
+ params: entry.params,
728
+ segmentInfo: entry.segmentInfo,
729
+ clearSegmentCacheOnEmpty: true,
730
+ });
731
+ renderPayload(entry.payload, navState);
732
+ restoreScrollAfterPaint(scrollY);
733
+ },
734
+ externalSignal
735
+ );
713
736
  } else {
714
737
  // No cached payload — fetch from server.
715
738
  // This happens when navigating back to the initial SSR'd page
716
739
  // (its payload is null since it was rendered via SSR, not RSC fetch)
717
740
  // 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
- });
741
+ await runNavigation(
742
+ url,
743
+ async (navAbort) => {
744
+ await renderViaTransition(url, async () => {
745
+ const stateTree = deps.clientSegmentCache
746
+ ? segmentCache.serializeStateTree()
747
+ : undefined;
748
+ const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
749
+ const payload = await resolveForFallback(result.payload);
750
+ const navState = commitNavigation(url, {
751
+ payload,
752
+ params: result.params,
753
+ segmentInfo: result.segmentInfo,
754
+ });
755
+ return { ...result, payload, navState };
756
+ });
729
757
 
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
- }
758
+ restoreScrollAfterPaint(scrollY);
759
+ },
760
+ externalSignal
761
+ );
741
762
  }
742
763
  }
743
764
 
@@ -756,7 +777,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
756
777
  if (historyStack.has(fetchUrl)) return;
757
778
 
758
779
  // Fire-and-forget fetch
759
- const stateTree = segmentCache.serializeStateTree();
780
+ const stateTree = deps.clientSegmentCache ? segmentCache.serializeStateTree() : undefined;
760
781
  void fetchRscPayload(fetchUrl, deps, stateTree).then(
761
782
  (result) => {
762
783
  result.decodePromise?.catch(() => {});
@@ -783,21 +804,16 @@ export function createRouter(deps: RouterDeps): RouterInstance {
783
804
  // Render the piggybacked element tree from a server action response.
784
805
  // Updates the current history entry with the fresh payload —
785
806
  // 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
807
  const currentUrl = deps.getCurrentUrl();
789
808
 
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, {
809
+ // Preserve existing segmentInfo so away-and-back navigation replays
810
+ // with a correct segment cache (TIM-1037). Preserve current params
811
+ // so dynamic route params aren't cleared to {}.
812
+ const existingEntry = historyStack.get(currentUrl);
813
+ const navState = commitNavigation(currentUrl, {
799
814
  payload: element,
800
- params: navState.params,
815
+ params: getNavigationState().params,
816
+ segmentInfo: existingEntry?.segmentInfo,
801
817
  });
802
818
  renderPayload(element, navState);
803
819
  },
package/src/codec.ts CHANGED
@@ -80,8 +80,10 @@ export const codec = {
80
80
  integer: {
81
81
  parse(value: string | string[] | undefined): number {
82
82
  const str = Array.isArray(value) ? value[0] : value;
83
+ if (!str || !/^(?:0|-?[1-9]\d*)$/.test(str))
84
+ throw new Error(`Expected integer, got '${str}'`);
83
85
  const n = Number(str);
84
- if (!Number.isInteger(n)) throw new Error(`Expected integer, got '${str}'`);
86
+ if (!Number.isSafeInteger(n)) throw new Error(`Expected integer, got '${str}'`);
85
87
  return n;
86
88
  },
87
89
  serialize(value: number): string | null {
@@ -46,9 +46,6 @@ export interface TimberUserConfig {
46
46
  */
47
47
  clientJavascript?: boolean | ClientJavascriptConfig;
48
48
  adapter?: unknown;
49
- cacheHandler?: unknown;
50
- /** CDN purge handler — called on revalidateTag/cache.invalidate to purge CDN-cached responses. */
51
- cdnPurge?: unknown;
52
49
  allowedOrigins?: string[];
53
50
  csrf?: boolean;
54
51
  limits?: {
@@ -56,40 +53,6 @@ export interface TimberUserConfig {
56
53
  uploadBodySize?: string;
57
54
  maxFields?: number;
58
55
  };
59
- /**
60
- * Server-action form handling.
61
- *
62
- * See design/08-forms-and-actions.md §"Validation errors" and
63
- * design/13-security.md §"Sensitive field stripping".
64
- */
65
- /**
66
- * Server action runtime behavior.
67
- *
68
- * See design/08-forms-and-actions.md §"Middleware for Server Actions".
69
- */
70
- actions?: {
71
- /**
72
- * Run `middleware.ts` on server action requests before dispatching.
73
- *
74
- * **Default: `true`** — middleware runs on every action POST so
75
- * authentication, rate limiting, tenant isolation, IP allow-listing,
76
- * and request-header injection apply uniformly to page renders, route
77
- * handlers, and server actions. This closes the auth-bypass class of
78
- * issue identified by Next.js CVE-2025-29927: developers can put a
79
- * single auth check in `middleware.ts` and trust that it gates every
80
- * unsafe-method request.
81
- *
82
- * Set to `false` to restore the legacy behavior where actions skip
83
- * middleware entirely. This is **not recommended** outside of niche
84
- * cases (e.g. middleware that rewrites POST bodies and would corrupt
85
- * action submissions). When false, you are responsible for placing
86
- * auth, rate limiting, and other cross-cutting checks inside every
87
- * action — typically via `createActionClient({ middleware: ... })`.
88
- *
89
- * See TIM-871.
90
- */
91
- runMiddleware?: boolean;
92
- };
93
56
  forms?: {
94
57
  /**
95
58
  * Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the
@@ -286,6 +249,22 @@ export interface TimberUserConfig {
286
249
  * ```
287
250
  */
288
251
  buildDir?: string;
252
+ /**
253
+ * Enable the client-side segment cache for partial navigation.
254
+ *
255
+ * When enabled, the router serializes the mounted segment tree into an
256
+ * `X-Timber-State-Tree` header on every client navigation. The server
257
+ * skips re-rendering unchanged sync layouts and sends a partial RSC
258
+ * payload. The client merges the partial payload with its cached segments.
259
+ *
260
+ * When disabled (the default), every navigation gets a full RSC payload.
261
+ *
262
+ * This is a performance optimization only — not a security boundary.
263
+ * The server always runs all `access.ts` files regardless.
264
+ *
265
+ * See design/19-client-navigation.md §"X-Timber-State-Tree Header"
266
+ */
267
+ clientSegmentCache?: boolean;
289
268
  topLoader?: {
290
269
  /** Whether the top-loader is enabled. Default: true. */
291
270
  enabled?: boolean;