@timber-js/app 0.2.0-alpha.160 → 0.2.0-alpha.163

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 (90) hide show
  1. package/dist/_chunks/{actions-C9yAuoX3.js → actions-cjklt63G.js} +7 -4
  2. package/dist/_chunks/{actions-C9yAuoX3.js.map → actions-cjklt63G.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-swbefQ6F.js → cache-api-CzYUlgXA.js} +2 -2
  4. package/dist/_chunks/{cache-api-swbefQ6F.js.map → cache-api-CzYUlgXA.js.map} +1 -1
  5. package/dist/_chunks/{cli-schema-sync-BhYDz_-x.js → cli-schema-sync-NfLbLnDw.js} +2 -2
  6. package/dist/_chunks/{cli-schema-sync-BhYDz_-x.js.map → cli-schema-sync-NfLbLnDw.js.map} +1 -1
  7. package/dist/_chunks/{logger-CPoQkGK6.js → logger-B_O6-mdJ.js} +7 -45
  8. package/dist/_chunks/logger-B_O6-mdJ.js.map +1 -0
  9. package/dist/_chunks/{walkers-c8aReVgo.js → walkers-9mz9T7mb.js} +2 -2
  10. package/dist/_chunks/{walkers-c8aReVgo.js.map → walkers-9mz9T7mb.js.map} +1 -1
  11. package/dist/adapters/cloudflare.d.ts +13 -13
  12. package/dist/adapters/cloudflare.d.ts.map +1 -1
  13. package/dist/adapters/cloudflare.js +44 -46
  14. package/dist/adapters/cloudflare.js.map +1 -1
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +1 -8
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/adapters/types.d.ts +0 -6
  19. package/dist/adapters/types.d.ts.map +1 -1
  20. package/dist/cache/index.js +1 -1
  21. package/dist/cli.js +1 -1
  22. package/dist/client/child-segment-context.d.ts +22 -0
  23. package/dist/client/child-segment-context.d.ts.map +1 -0
  24. package/dist/client/child-segment-outlet.d.ts +18 -0
  25. package/dist/client/child-segment-outlet.d.ts.map +1 -0
  26. package/dist/client/child-segment-provider.d.ts +21 -0
  27. package/dist/client/child-segment-provider.d.ts.map +1 -0
  28. package/dist/client/internal.js +1 -2
  29. package/dist/client/internal.js.map +1 -1
  30. package/dist/client/segment-cache.d.ts.map +1 -1
  31. package/dist/client/use-cookie.d.ts.map +1 -1
  32. package/dist/cookies/index.js +5 -1
  33. package/dist/cookies/index.js.map +1 -1
  34. package/dist/index.js +256 -9
  35. package/dist/index.js.map +1 -1
  36. package/dist/plugins/cache.d.ts.map +1 -1
  37. package/dist/plugins/callsite-ast.d.ts +27 -5
  38. package/dist/plugins/callsite-ast.d.ts.map +1 -1
  39. package/dist/plugins/fonts.d.ts.map +1 -1
  40. package/dist/plugins/prebuilt-capture.d.ts.map +1 -1
  41. package/dist/plugins/shims.d.ts.map +1 -1
  42. package/dist/routing/index.js +2 -2
  43. package/dist/server/actions.d.ts.map +1 -1
  44. package/dist/server/als-registry.d.ts +1 -0
  45. package/dist/server/als-registry.d.ts.map +1 -1
  46. package/dist/server/index.d.ts +1 -1
  47. package/dist/server/index.d.ts.map +1 -1
  48. package/dist/server/index.js +2 -2
  49. package/dist/server/internal.js +85 -10
  50. package/dist/server/internal.js.map +1 -1
  51. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  52. package/dist/server/prebuilt-runtime.d.ts.map +1 -1
  53. package/dist/server/primitives.d.ts +5 -12
  54. package/dist/server/primitives.d.ts.map +1 -1
  55. package/dist/server/route-element-builder.d.ts +7 -0
  56. package/dist/server/route-element-builder.d.ts.map +1 -1
  57. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  58. package/dist/server/rsc-entry/index.d.ts +2 -2
  59. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  60. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  61. package/dist/server/state-tree-diff.d.ts +26 -3
  62. package/dist/server/state-tree-diff.d.ts.map +1 -1
  63. package/package.json +3 -2
  64. package/src/adapters/cloudflare.ts +41 -63
  65. package/src/adapters/nitro.ts +0 -11
  66. package/src/adapters/types.ts +0 -7
  67. package/src/client/child-segment-context.ts +40 -0
  68. package/src/client/child-segment-outlet.tsx +25 -0
  69. package/src/client/child-segment-provider.tsx +27 -0
  70. package/src/client/segment-cache.ts +4 -5
  71. package/src/client/use-cookie.ts +11 -1
  72. package/src/plugins/cache.ts +44 -1
  73. package/src/plugins/callsite-ast.ts +310 -5
  74. package/src/plugins/fonts.ts +8 -0
  75. package/src/plugins/prebuilt-capture.ts +7 -0
  76. package/src/plugins/shims.ts +24 -0
  77. package/src/server/actions.ts +10 -1
  78. package/src/server/als-registry.ts +1 -0
  79. package/src/server/index.ts +1 -1
  80. package/src/server/prebuilt-builder.ts +120 -42
  81. package/src/server/prebuilt-runtime.ts +24 -1
  82. package/src/server/primitives.ts +5 -20
  83. package/src/server/route-element-builder.ts +113 -79
  84. package/src/server/rsc-entry/helpers.ts +12 -10
  85. package/src/server/rsc-entry/index.ts +40 -40
  86. package/src/server/rsc-entry/render-route.ts +6 -2
  87. package/src/server/rsc-entry/rsc-payload.ts +2 -2
  88. package/src/server/server-only-guard-noop.js +3 -0
  89. package/src/server/state-tree-diff.ts +49 -4
  90. package/dist/_chunks/logger-CPoQkGK6.js.map +0 -1
@@ -46,6 +46,7 @@ import { createElement } from 'react';
46
46
 
47
47
  import { requestContextAls } from './als-registry.js';
48
48
  import { ParamCoercionError } from './route-element-builder.js';
49
+ import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
49
50
  import { coerceSegmentParams } from './param-coercion.js';
50
51
  import { DenySignal, RedirectSignal } from './primitives.js';
51
52
  import {
@@ -154,6 +155,8 @@ interface CandidateRoute {
154
155
  layoutsToLoad: ManifestFile[];
155
156
  /** Component IDs attributed to this route's page + layout chain. */
156
157
  componentIds: string[];
158
+ /** Component IDs from layout files (receive ChildSegmentOutlet as children). */
159
+ layoutComponentIds: Set<string>;
157
160
  }
158
161
 
159
162
  /**
@@ -176,15 +179,20 @@ function collectCandidateRoutes(
176
179
 
177
180
  if (node.page) {
178
181
  const componentIds = new Set<string>();
179
- for (const file of [...chain, node.page]) {
180
- for (const id of fileComponentMap[file.filePath] ?? []) componentIds.add(id);
181
- }
182
+ const layoutIds = new Set<string>();
183
+ for (const f of chain)
184
+ for (const id of fileComponentMap[f.filePath] ?? []) {
185
+ componentIds.add(id);
186
+ layoutIds.add(id);
187
+ }
188
+ for (const id of fileComponentMap[node.page.filePath] ?? []) componentIds.add(id);
182
189
  if (componentIds.size > 0) {
183
190
  routes.push({
184
191
  urlPath: node.urlPath,
185
192
  page: node.page,
186
193
  layoutsToLoad: chain.filter((l) => (fileComponentMap[l.filePath] ?? []).length > 0),
187
194
  componentIds: Array.from(componentIds),
195
+ layoutComponentIds: layoutIds,
188
196
  });
189
197
  }
190
198
  }
@@ -252,15 +260,14 @@ async function enumerateCombos(
252
260
 
253
261
  // ─── Rendering ────────────────────────────────────────────────────────────
254
262
 
255
- /** Concatenate drained chunks without Buffer (the bundle also runs on Workers). */
256
263
  function concatChunks(chunks: Uint8Array[]): Uint8Array {
257
264
  let total = 0;
258
- for (const chunk of chunks) total += chunk.byteLength;
265
+ for (const c of chunks) total += c.byteLength;
259
266
  const out = new Uint8Array(total);
260
- let offset = 0;
261
- for (const chunk of chunks) {
262
- out.set(chunk, offset);
263
- offset += chunk.byteLength;
267
+ let off = 0;
268
+ for (const c of chunks) {
269
+ out.set(c, off);
270
+ off += c.byteLength;
264
271
  }
265
272
  return out;
266
273
  }
@@ -299,10 +306,10 @@ async function drainWithTimeout(
299
306
 
300
307
  /** Wrap payload bytes as a single-chunk stream for the smoke revival. */
301
308
  function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
302
- return new ReadableStream<Uint8Array>({
303
- start(controller) {
304
- controller.enqueue(bytes);
305
- controller.close();
309
+ return new ReadableStream({
310
+ start(c) {
311
+ c.enqueue(bytes);
312
+ c.close();
306
313
  },
307
314
  });
308
315
  }
@@ -332,7 +339,8 @@ async function renderEntry(
332
339
  registration: PrebuiltRegistration,
333
340
  props: Record<string, unknown>,
334
341
  coercedParams: Record<string, string | string[]>,
335
- timeoutMs: number
342
+ timeoutMs: number,
343
+ isLayout: boolean
336
344
  ): Promise<EntryOutcome> {
337
345
  const storeHandle = createBuildTimeStore(registration.id, coercedParams);
338
346
  const { store, violations } = storeHandle;
@@ -348,9 +356,15 @@ async function renderEntry(
348
356
  // synthetic store (segmentParams.get() inside the component).
349
357
  // Tag resolution also runs inside this scope so function-form tags
350
358
  // that read segment params see the synthetic store (codex P2 on PR #858).
359
+ // Layout captures get <ChildSegmentOutlet /> as children so the
360
+ // cached payload has a context-driven hole (design/45, TIM-1181).
361
+ const renderProps =
362
+ isLayout && !('children' in props)
363
+ ? { ...props, children: createElement(ChildSegmentOutlet as React.FC) }
364
+ : props;
351
365
  bytes = await requestContextAls.run(store, async () => {
352
366
  const stream = renderToReadableStream(
353
- createElement(registration.component as RenderableComponent, props),
367
+ createElement(registration.component as RenderableComponent, renderProps),
354
368
  {
355
369
  onError(error: unknown) {
356
370
  failure ??= error ?? new Error('unknown render error');
@@ -422,7 +436,7 @@ function warnNestingClamps(
422
436
  let count = 0;
423
437
  for (const { innerId, options } of nested) {
424
438
  if (options.ttl === undefined && options.tags === undefined) continue;
425
- const pairKey = `${outerId}${innerId}`;
439
+ const pairKey = `${outerId} ${innerId}`;
426
440
  if (warnedPairs.has(pairKey)) continue;
427
441
  warnedPairs.add(pairKey);
428
442
  count++;
@@ -448,6 +462,15 @@ function resolveCapturedTags(
448
462
  return resolved.length > 0 ? resolved : undefined;
449
463
  }
450
464
 
465
+ /** Buffered entry pending the param-independence decision (TIM-1175). */
466
+ interface PendingEntry {
467
+ callSiteProps: Record<string, unknown>;
468
+ cacheKey: string;
469
+ combo: ParamCombo;
470
+ bytes: Uint8Array;
471
+ tags: string[] | undefined;
472
+ }
473
+
451
474
  /**
452
475
  * Capture flight payloads for every attributed, registered, prerender-tier
453
476
  * component across the route manifest. See module docblock for the flow.
@@ -465,9 +488,18 @@ export async function capturePrebuiltPayloads(
465
488
  const warnedPairs = new Set<string>();
466
489
  const reportedMissingRegistrations = new Set<string>();
467
490
  const eagerLoadedChunks = new Set<string>();
468
- // Components confirmed to not read segmentParams during their first
469
- // render — subsequent param combos reuse the first render's bytes.
470
- const paramIndependentComponents = new Set<string>();
491
+ // Components already emitted as param-independent — skip on second route.
492
+ const emittedParamIndependent = new Set<string>();
493
+ // (componentId, propsKey) pairs that rendered at least once without
494
+ // reading segment params. Segment param access is structural (the
495
+ // component either calls getSegmentParams() or it doesn't), so if the
496
+ // first combo doesn't trigger it, subsequent combos won't either —
497
+ // skip them to avoid redundant renders. Tracked per props set so a
498
+ // failed variant doesn't suppress retries for a different variant.
499
+ const provenParamIndependent = new Set<string>();
500
+ // Per-component: whether ANY combo read segmentParams, plus buffered
501
+ // entries awaiting the param-independence decision after all combos.
502
+ const pendingByComponent = new Map<string, { anyParamRead: boolean; entries: PendingEntry[] }>();
471
503
 
472
504
  const skip = (issue: CaptureIssue) => {
473
505
  summary.skipped.push(issue);
@@ -594,8 +626,8 @@ export async function capturePrebuiltPayloads(
594
626
  }
595
627
  // Runtime tier (ttl/tags without prerender) does no build work.
596
628
  if (registration.options.prerender !== true) continue;
597
- // Param-independent: first combo's renders cover all combos.
598
- if (paramIndependentComponents.has(componentId)) continue;
629
+ // Already emitted as param-independent from a prior route.
630
+ if (emittedParamIndependent.has(componentId)) continue;
599
631
 
600
632
  // One entry per distinct call-site props object (design/44 §Props
601
633
  // Derivation, TIM-1142). When no call-site props were captured,
@@ -608,7 +640,23 @@ export async function capturePrebuiltPayloads(
608
640
  const propsSets: Record<string, unknown>[] =
609
641
  capturedProps && capturedProps.length > 0 ? [...capturedProps] : [{}];
610
642
 
643
+ let pending = pendingByComponent.get(componentId);
644
+ if (!pending) {
645
+ pending = { anyParamRead: false, entries: [] };
646
+ pendingByComponent.set(componentId, pending);
647
+ }
648
+
611
649
  for (const callSiteProps of propsSets) {
650
+ // Already rendered this (component, props) pair without reading
651
+ // params — additional combos produce identical payloads. Tracked
652
+ // per props set so a failed variant doesn't suppress retries.
653
+ // Guard on !anyParamRead: if another prop variant already proved
654
+ // param-dependent, this variant needs per-combo entries too since
655
+ // the emit phase uses a component-level paramIndependent flag.
656
+ const propsKey = JSON.stringify(callSiteProps);
657
+ const proofKey = `${componentId}\0${propsKey}`;
658
+ if (provenParamIndependent.has(proofKey) && !pending.anyParamRead) continue;
659
+
612
660
  const cacheKey = computeComponentCacheKey(callSiteProps, coercedParams);
613
661
  if (cacheKey === null) {
614
662
  skip({
@@ -621,11 +669,27 @@ export async function capturePrebuiltPayloads(
621
669
  });
622
670
  continue;
623
671
  }
624
- const entryKey = `${componentId}${cacheKey}`;
672
+ const entryKey = `${componentId} ${cacheKey}`;
625
673
  if (seenEntries.has(entryKey)) continue;
626
674
  seenEntries.add(entryKey);
627
675
 
628
- const outcome = await renderEntry(registration, callSiteProps, coercedParams, timeoutMs);
676
+ const isLayout = route.layoutComponentIds.has(componentId);
677
+ const outcome = await renderEntry(
678
+ registration,
679
+ callSiteProps,
680
+ coercedParams,
681
+ timeoutMs,
682
+ isLayout
683
+ );
684
+ // Track param reads immediately — before any failure continue —
685
+ // so a combo that reads params then fails still prevents
686
+ // param-independent classification (codex P1 on PR #869).
687
+ if (outcome.segmentParamsRead) {
688
+ pending.anyParamRead = true;
689
+ provenParamIndependent.delete(proofKey);
690
+ } else if (outcome.failure === null && !pending.anyParamRead) {
691
+ provenParamIndependent.add(proofKey);
692
+ }
629
693
  summary.nestingWarnings += warnNestingClamps(
630
694
  componentId,
631
695
  outcome.nested,
@@ -665,32 +729,46 @@ export async function capturePrebuiltPayloads(
665
729
  continue;
666
730
  }
667
731
 
668
- // After the first successful render, if the component never
669
- // read segmentParams, mark it param-independent — remaining
670
- // combos reuse this render's bytes without re-rendering.
671
- const isParamIndep = !outcome.segmentParamsRead;
672
- if (isParamIndep) {
673
- paramIndependentComponents.add(componentId);
674
- }
675
-
676
- const finalCacheKey = isParamIndep
677
- ? (computeComponentCacheKey(callSiteProps, {}) ?? cacheKey)
678
- : cacheKey;
679
- const hasProps = Object.keys(callSiteProps).length > 0;
680
- await options.onEntry({
681
- componentId,
682
- cacheKey: finalCacheKey,
683
- params: isParamIndep ? {} : combo,
684
- ...(hasProps ? { props: callSiteProps } : {}),
732
+ pending.entries.push({
733
+ callSiteProps,
734
+ cacheKey,
735
+ combo,
685
736
  bytes: outcome.bytes!,
686
737
  tags: outcome.resolvedTags,
687
- ...(isParamIndep ? { paramIndependent: true } : {}),
688
738
  });
689
- summary.captured++;
690
739
  }
691
740
  }
692
741
  }
693
742
  }
694
743
 
744
+ // Emit buffered entries. Param-independent = ALL combos rendered and
745
+ // NONE read segmentParams → collapse to one entry per props set (TIM-1175).
746
+ for (const [componentId, pending] of pendingByComponent) {
747
+ const isParamIndep = !pending.anyParamRead && pending.entries.length > 0;
748
+ if (isParamIndep) emittedParamIndependent.add(componentId);
749
+ const emittedProps = isParamIndep ? new Set<string>() : null;
750
+ for (const entry of pending.entries) {
751
+ if (emittedProps) {
752
+ const propsKey = JSON.stringify(entry.callSiteProps);
753
+ if (emittedProps.has(propsKey)) continue;
754
+ emittedProps.add(propsKey);
755
+ }
756
+ const hasProps = Object.keys(entry.callSiteProps).length > 0;
757
+ const finalCacheKey = isParamIndep
758
+ ? (computeComponentCacheKey(entry.callSiteProps, {}) ?? entry.cacheKey)
759
+ : entry.cacheKey;
760
+ await options.onEntry({
761
+ componentId,
762
+ cacheKey: finalCacheKey,
763
+ params: isParamIndep ? {} : entry.combo,
764
+ ...(hasProps ? { props: entry.callSiteProps } : {}),
765
+ bytes: entry.bytes,
766
+ tags: entry.tags,
767
+ ...(isParamIndep ? { paramIndependent: true } : {}),
768
+ });
769
+ summary.captured++;
770
+ }
771
+ }
772
+
695
773
  return summary;
696
774
  }
@@ -37,6 +37,7 @@ import { createElement } from 'react';
37
37
 
38
38
  import { requestContextAls, type RequestContextStore } from './als-registry.js';
39
39
  import { computeComponentCacheKey, tryCanonicalize } from './prebuilt/cache-key.js';
40
+ import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
40
41
  import {
41
42
  isCaptureActive,
42
43
  noteWrapperRender,
@@ -510,7 +511,29 @@ async function tryRevivePayload(
510
511
  const keyParams = paramIndep ? {} : resolveKeyParams(store);
511
512
  if (keyParams === null) return null;
512
513
 
513
- const cacheKey = computeComponentCacheKey(props ?? {}, keyParams);
514
+ // Strip `children` from the cache key ONLY when it is the framework-
515
+ // injected ChildSegmentOutlet. Layout components receive
516
+ // <ChildSegmentOutlet /> as children — a deterministic client reference
517
+ // that is the same on every request, so it should not vary the key.
518
+ // User-provided React element children (e.g., <Cached><A /></Cached>)
519
+ // are NOT stripped — they remain unkeyable so the component renders
520
+ // live, which is correct: the cache cannot distinguish <A> from <B>.
521
+ // See design/45-cache-lifetimes.md §Layout Propagation, TIM-1181.
522
+ const allProps = (props ?? {}) as Record<string, unknown>;
523
+ let keyProps: Record<string, unknown>;
524
+ const childrenValue = allProps.children;
525
+ const isChildSegmentOutlet =
526
+ childrenValue != null &&
527
+ typeof childrenValue === 'object' &&
528
+ 'type' in childrenValue &&
529
+ (childrenValue as { type: unknown }).type === ChildSegmentOutlet;
530
+ if (isChildSegmentOutlet) {
531
+ const { children: _children, ...rest } = allProps;
532
+ keyProps = rest;
533
+ } else {
534
+ keyProps = allProps;
535
+ }
536
+ const cacheKey = computeComponentCacheKey(keyProps, keyParams);
514
537
  if (cacheKey === null) return null;
515
538
 
516
539
  const outcome =
@@ -416,11 +416,6 @@ export class RenderError<
416
416
 
417
417
  // ─── waitUntil ──────────────────────────────────────────────────────────────
418
418
 
419
- /** Minimal interface for adapters that support background work. */
420
- export interface WaitUntilAdapter {
421
- waitUntil?(promise: Promise<unknown>): void;
422
- }
423
-
424
419
  // Intentional per-app singleton — warn-once flag that persists for the
425
420
  // lifetime of the process/isolate. Not per-request; do not migrate to ALS.
426
421
  let _waitUntilWarned = false;
@@ -429,30 +424,20 @@ let _waitUntilWarned = false;
429
424
  * Register a promise to be kept alive after the response is sent.
430
425
  * Maps to `ctx.waitUntil()` on Cloudflare Workers and similar platforms.
431
426
  *
432
- * In production, the platform adapter installs a per-request waitUntil
433
- * function via ALS (see waituntil-bridge.ts). This function checks the
434
- * ALS bridge first, then falls back to the legacy adapter argument.
435
- *
436
- * If neither is available, a warning is logged once and the promise is
437
- * left to resolve (or reject) without being tracked.
427
+ * The platform adapter installs a per-request waitUntil function via ALS
428
+ * (see waituntil-bridge.ts). If no ALS handler is available, a warning
429
+ * is logged once and the promise is left to resolve (or reject) without
430
+ * being tracked.
438
431
  *
439
432
  * @param promise - The background work to keep alive.
440
- * @param adapter - Optional legacy adapter (prefer ALS bridge in production).
441
433
  */
442
- export function waitUntil(promise: Promise<unknown>, adapter?: WaitUntilAdapter): void {
443
- // Check ALS bridge first (installed by generated entry points)
434
+ export function waitUntil(promise: Promise<unknown>): void {
444
435
  const alsFn = _getWaitUntil();
445
436
  if (alsFn) {
446
437
  alsFn(promise);
447
438
  return;
448
439
  }
449
440
 
450
- // Fall back to legacy adapter argument
451
- if (adapter && typeof adapter.waitUntil === 'function') {
452
- adapter.waitUntil(promise);
453
- return;
454
- }
455
-
456
441
  if (!_waitUntilWarned) {
457
442
  _waitUntilWarned = true;
458
443
  console.warn(
@@ -44,10 +44,12 @@ import type { DenyPageEntry } from './deny-boundary.js';
44
44
  import { resolveSlotElement } from './slot-resolver.js';
45
45
  import { SegmentProvider } from '../client/segment-context.js';
46
46
  import { SegmentOutlet } from '../client/segment-outlet.js';
47
+ import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
48
+ import { ChildSegmentProvider } from '../client/child-segment-provider.js';
47
49
 
48
50
  import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
49
51
  import type { InterceptionContext } from './pipeline.js';
50
- import { shouldSkipSegment } from './state-tree-diff.js';
52
+ import { shouldSkipSegment, computeSegmentKeys } from './state-tree-diff.js';
51
53
  import type { HeadElement } from './metadata.js';
52
54
 
53
55
  /**
@@ -126,7 +128,7 @@ export function isClientReference(component: unknown): boolean {
126
128
  );
127
129
  }
128
130
 
129
- // ─── Param Coercion Error ─────────────────────────────────────────────────
131
+ // ─── Typed Errors ────────────────────────────────────────────────────────
130
132
 
131
133
  /**
132
134
  * Thrown when a defineSegmentParams codec's parse() fails.
@@ -139,6 +141,17 @@ export class ParamCoercionError extends Error {
139
141
  }
140
142
  }
141
143
 
144
+ /**
145
+ * Thrown when a matched route has no page component in its leaf segment.
146
+ * The pipeline catches this and renders the custom 404 page.
147
+ */
148
+ export class NoPageComponentError extends Error {
149
+ constructor(message: string) {
150
+ super(message);
151
+ this.name = 'NoPageComponentError';
152
+ }
153
+ }
154
+
142
155
  // ─── Types ────────────────────────────────────────────────────────────────
143
156
 
144
157
  /** Layout entry with component and segment. */
@@ -352,7 +365,7 @@ export async function buildRouteElement(
352
365
  ` [${i}] ${s.segmentName} (page: ${s.page ? 'yes' : 'no'}, layout: ${s.layout ? 'yes' : 'no'}, children: ${s.children?.length ?? 0})${i === segments.length - 1 ? ' ← leaf' : ''}`
353
366
  )
354
367
  .join('\n');
355
- throw new Error(
368
+ throw new NoPageComponentError(
356
369
  `No page component found for route: ${new URL(req.url).pathname}\nMatched segments:\n${segmentInfo}`
357
370
  );
358
371
  }
@@ -502,61 +515,96 @@ export async function buildRouteElement(
502
515
  // The client uses this to merge the partial payload with its cached segments.
503
516
  const skippedSegments: string[] = [];
504
517
 
505
- // Wrap from innermost (leaf) to outermost (root), processing every
506
- // segment in the chain. Each segment may contribute:
507
- // 1. Error boundaries (status files + error.tsx)
508
- // 2. Layout component — wraps children + parallel slots
509
- // 3. SegmentProvider — records position for useSelectedLayoutSegment
518
+ // Compute state-tree keys for all segments. Route groups accumulate
519
+ // ancestor group names for globally unique keys (e.g., "/(a)/(shared)"
520
+ // instead of just "/(shared)"). This is the single key computation —
521
+ // the same keys are used for skip decisions, SegmentOutlet props, and
522
+ // the X-Timber-Segments header (via buildSegmentInfo).
523
+ const segmentKeys = computeSegmentKeys(segments);
524
+
525
+ // Pre-compute the contiguous skippable prefix (top-down).
510
526
  //
511
- // When clientStateTree is provided (from X-Timber-State-Tree header on
512
- // client navigation), sync layouts the client already has are skipped.
513
- // Access.ts was pre-checked eagerly above for metadata gating (TIM-1027).
514
- // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
527
+ // Skipped segments must form a contiguous prefix from root to avoid
528
+ // non-contiguous skips that break the client merge. Walk from root
529
+ // toward leaf, marking segments as skippable. Stop at the first
530
+ // segment that can't be skipped (async, not in client tree, leaf).
531
+ // Layoutless segments are transparent (no outlet, no layout to skip).
515
532
  //
516
- // hasRenderedLayoutBelow tracks whether a non-skipped layout has been
517
- // seen below the current segment. A segment can ONLY be skipped if
518
- // there is a rendered layout below it — the client merger can only
519
- // replace inner SegmentProviders (client component boundaries), not
520
- // page content embedded in a layout's server-rendered output.
521
- // Without this guard, skipping the innermost layout causes the merger
522
- // to drop the layout entirely and replace it with just the page.
523
- let hasRenderedLayoutBelow = false;
533
+ // After finding the prefix, validate: the first non-skipped layout
534
+ // must exist in the client's state tree (the client needs a mounted
535
+ // outlet there to receive the partial payload).
536
+ const skippableSet = new Set<number>();
537
+ if (clientStateTree) {
538
+ for (let i = 0; i < segments.length; i++) {
539
+ const isLeaf = i === segments.length - 1;
540
+ const layoutComponent = layoutBySegment.get(segments[i]);
541
+
542
+ // Never skip denied segments — their AccessGate must render the
543
+ // deny page, not reuse the client's cached (passing) layout.
544
+ if (i >= firstDeniedIndex) break;
545
+
546
+ if (shouldSkipSegment(segmentKeys[i], layoutComponent, isLeaf, clientStateTree)) {
547
+ skippableSet.add(i);
548
+ } else if (layoutComponent) {
549
+ break;
550
+ }
551
+ }
552
+
553
+ // Ensure there's a merge point: a non-skipped layout whose key is
554
+ // in the client's state tree (so the client has a mounted outlet).
555
+ // If all layouts are in the prefix, keep the innermost as merge point.
556
+ if (skippableSet.size > 0) {
557
+ let mergePointIndex = -1;
558
+ for (let i = 0; i < segments.length; i++) {
559
+ if (skippableSet.has(i)) continue;
560
+ if (layoutBySegment.has(segments[i])) {
561
+ mergePointIndex = i;
562
+ break;
563
+ }
564
+ }
565
+
566
+ if (mergePointIndex === -1) {
567
+ // All layouts are skippable — keep the innermost as merge point
568
+ for (let i = segments.length - 1; i >= 0; i--) {
569
+ if (skippableSet.has(i) && layoutBySegment.has(segments[i])) {
570
+ skippableSet.delete(i);
571
+ mergePointIndex = i;
572
+ break;
573
+ }
574
+ }
575
+ }
576
+
577
+ // Cross-section guard: at least one rendered layout below the
578
+ // prefix must exist in the client's state tree (the client needs
579
+ // a mounted outlet to receive the partial payload). Check ALL
580
+ // non-skipped layouts, not just the merge point — async layouts
581
+ // are excluded from the client state tree but may sit above a
582
+ // sync layout that IS in the tree.
583
+ let hasClientOutlet = false;
584
+ for (let j = 0; j < segments.length; j++) {
585
+ if (skippableSet.has(j)) continue;
586
+ if (layoutBySegment.has(segments[j]) && clientStateTree.has(segmentKeys[j])) {
587
+ hasClientOutlet = true;
588
+ break;
589
+ }
590
+ }
591
+ if (!hasClientOutlet) {
592
+ skippableSet.clear();
593
+ }
594
+ }
595
+ }
596
+
524
597
  let outermostSegmentProvider: React.ReactElement | null = null;
525
- // Track whether any rendered inner layout also exists in the client's
526
- // state tree. This prevents cross-section skipping: e.g., navigating
527
- // from /(group-a) to /dashboard shouldn't skip root "/" because the
528
- // client has no mounted outlet at "/dashboard".
529
- let innerRenderedInClientTree = false;
530
598
 
531
599
  for (let i = segments.length - 1; i >= 0; i--) {
532
600
  const segment = segments[i];
533
601
  const isLeaf = i === segments.length - 1;
534
602
  const layoutComponent = layoutBySegment.get(segment);
535
603
 
536
- // Check if this segment's layout can be skipped for partial rendering.
537
- // Skipped segments: no layout wrapping, no error boundaries, no slots,
538
- // no AccessGate in element tree (access already ran pre-render).
539
- //
540
- // Additional constraints beyond shouldSkipSegment:
541
- // - Must have a rendered layout below (so the merger can find an
542
- // inner SegmentProvider to splice the new content into)
543
- // - Route groups are never skipped because sibling groups share the
544
- // same urlPath (e.g., /(marketing) and /(app) both have "/"),
545
- // which would cause the wrong cached layout to be reused
546
- // - At least one inner rendered layout must exist in the client's
547
- // state tree, ensuring the client has a mounted outlet to receive
548
- // the partial payload
549
- const skip =
550
- shouldSkipSegment(segment.urlPath, layoutComponent, isLeaf, clientStateTree ?? null) &&
551
- hasRenderedLayoutBelow &&
552
- segment.segmentType !== 'group' &&
553
- innerRenderedInClientTree;
554
-
555
- if (skip) {
556
- // Skip this segment's layout/error boundaries — the client uses its cached version.
557
- // Metadata was already resolved above (head elements are correct).
604
+ // Skip decision was pre-computed in the top-down prefix pass.
605
+ if (skippableSet.has(i)) {
558
606
  // Record for X-Timber-Skipped-Segments header (outermost first, so prepend).
559
- skippedSegments.unshift(segment.urlPath);
607
+ skippedSegments.unshift(segmentKeys[i]);
560
608
 
561
609
  // SECURITY: Even though the layout is skipped, AccessGate MUST still
562
610
  // wrap the element tree. access.ts runs on every navigation regardless
@@ -566,9 +614,6 @@ export async function buildRouteElement(
566
614
  const accessMod = await loadModule(segment.access);
567
615
  const accessFn = accessMod.default as (() => unknown) | undefined;
568
616
  if (accessFn) {
569
- // Pass verdict for denied/redirected segments so AccessGate replays
570
- // without re-execution. Passing segments omit verdict so AccessGate
571
- // re-runs access during render for React.cache population.
572
617
  const verdict = accessVerdicts.get(i);
573
618
  element = h(AccessGate, {
574
619
  accessFn,
@@ -583,23 +628,6 @@ export async function buildRouteElement(
583
628
  continue;
584
629
  }
585
630
 
586
- // This segment is rendered — mark that future (outer) segments have
587
- // a rendered layout below them and can safely be skipped.
588
- if (layoutComponent) {
589
- hasRenderedLayoutBelow = true;
590
- // Use segmentId for the client state tree check. Route groups share
591
- // their parent's urlPath (both "/"), but their segmentId includes
592
- // the group name (e.g., "/(group-a)"), so the check correctly
593
- // fails when the client has never visited that group.
594
- const outletKey =
595
- segment.segmentType === 'group'
596
- ? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
597
- : segment.urlPath;
598
- if (clientStateTree?.has(outletKey)) {
599
- innerRenderedInClientTree = true;
600
- }
601
- }
602
-
603
631
  // Wrap with error boundaries from this segment (inside layout).
604
632
  // Keep ALL error boundaries (including 4xx) — they're the safety net for
605
633
  // DenySignal from nested server components that escape AccessGate/PageDenyBoundary
@@ -641,22 +669,21 @@ export async function buildRouteElement(
641
669
  const segmentPath = rawPath.length === 2 && rawPath[1] === '' ? [''] : rawPath;
642
670
  const parallelRouteKeys = Object.keys(segment.slots ?? {});
643
671
 
644
- // For route groups, urlPath is shared with the parent (both "/"),
645
- // so include the group name to distinguish them. Used for both OTEL
646
- // span labels and client-side element caching (segmentId).
647
- const segmentId =
648
- segment.segmentType === 'group'
649
- ? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
650
- : segment.urlPath;
672
+ const segmentId = segmentKeys[i];
651
673
 
652
674
  // Build the layout element.
653
- // Same client reference guard as pages — client layouts must not be
654
- // called as functions. OTEL tracing is skipped for client components.
675
+ // Layouts receive <ChildSegmentOutlet /> as children — a stable client
676
+ // reference — instead of the varying inner content. The actual child
677
+ // content is provided via ChildSegmentProvider above the layout.
678
+ // This makes layout cache.component hits possible: the cached payload
679
+ // contains a deterministic children reference, not varying content.
680
+ // See design/45-cache-lifetimes.md §Layout Propagation.
681
+ const childOutlet = h(ChildSegmentOutlet, {});
655
682
  let layoutElement: React.ReactElement;
656
683
  if (isClientReference(layoutComponent)) {
657
684
  layoutElement = h(layoutComponent, {
658
685
  ...slotProps,
659
- children: element,
686
+ children: childOutlet,
660
687
  });
661
688
  } else {
662
689
  // Server component layout — wrap with OTEL tracing AND DenySignal
@@ -686,10 +713,17 @@ export async function buildRouteElement(
686
713
  };
687
714
  layoutElement = h(TracedLayout, {
688
715
  ...slotProps,
689
- children: element,
716
+ children: childOutlet,
690
717
  });
691
718
  }
692
719
 
720
+ // Wrap the layout in ChildSegmentProvider so ChildSegmentOutlet
721
+ // (inside the layout's children) can read the actual child content.
722
+ layoutElement = h(ChildSegmentProvider, {
723
+ childContent: element,
724
+ children: layoutElement,
725
+ });
726
+
693
727
  const segmentProviderElement = h(SegmentProvider, {
694
728
  segments: segmentPath,
695
729
  segmentId,