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

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 (81) 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/use-cookie.d.ts.map +1 -1
  29. package/dist/cookies/index.js +5 -1
  30. package/dist/cookies/index.js.map +1 -1
  31. package/dist/index.js +256 -9
  32. package/dist/index.js.map +1 -1
  33. package/dist/plugins/cache.d.ts.map +1 -1
  34. package/dist/plugins/callsite-ast.d.ts +27 -5
  35. package/dist/plugins/callsite-ast.d.ts.map +1 -1
  36. package/dist/plugins/fonts.d.ts.map +1 -1
  37. package/dist/plugins/prebuilt-capture.d.ts.map +1 -1
  38. package/dist/plugins/shims.d.ts.map +1 -1
  39. package/dist/routing/index.js +2 -2
  40. package/dist/server/actions.d.ts.map +1 -1
  41. package/dist/server/als-registry.d.ts +1 -0
  42. package/dist/server/als-registry.d.ts.map +1 -1
  43. package/dist/server/index.d.ts +1 -1
  44. package/dist/server/index.d.ts.map +1 -1
  45. package/dist/server/index.js +2 -2
  46. package/dist/server/internal.js +85 -10
  47. package/dist/server/internal.js.map +1 -1
  48. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  49. package/dist/server/prebuilt-runtime.d.ts.map +1 -1
  50. package/dist/server/primitives.d.ts +5 -12
  51. package/dist/server/primitives.d.ts.map +1 -1
  52. package/dist/server/route-element-builder.d.ts +7 -0
  53. package/dist/server/route-element-builder.d.ts.map +1 -1
  54. package/dist/server/rsc-entry/index.d.ts +2 -2
  55. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  56. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  57. package/package.json +3 -2
  58. package/src/adapters/cloudflare.ts +41 -63
  59. package/src/adapters/nitro.ts +0 -11
  60. package/src/adapters/types.ts +0 -7
  61. package/src/client/child-segment-context.ts +40 -0
  62. package/src/client/child-segment-outlet.tsx +25 -0
  63. package/src/client/child-segment-provider.tsx +27 -0
  64. package/src/client/use-cookie.ts +11 -1
  65. package/src/plugins/cache.ts +44 -1
  66. package/src/plugins/callsite-ast.ts +310 -5
  67. package/src/plugins/fonts.ts +8 -0
  68. package/src/plugins/prebuilt-capture.ts +7 -0
  69. package/src/plugins/shims.ts +24 -0
  70. package/src/server/actions.ts +10 -1
  71. package/src/server/als-registry.ts +1 -0
  72. package/src/server/index.ts +1 -1
  73. package/src/server/prebuilt-builder.ts +120 -42
  74. package/src/server/prebuilt-runtime.ts +24 -1
  75. package/src/server/primitives.ts +5 -20
  76. package/src/server/route-element-builder.ts +31 -6
  77. package/src/server/rsc-entry/index.ts +40 -40
  78. package/src/server/rsc-entry/render-route.ts +6 -2
  79. package/src/server/rsc-entry/rsc-payload.ts +2 -2
  80. package/src/server/server-only-guard-noop.js +3 -0
  81. 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,6 +44,8 @@ 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';
@@ -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
  }
@@ -650,13 +663,18 @@ export async function buildRouteElement(
650
663
  : segment.urlPath;
651
664
 
652
665
  // 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.
666
+ // Layouts receive <ChildSegmentOutlet /> as children — a stable client
667
+ // reference — instead of the varying inner content. The actual child
668
+ // content is provided via ChildSegmentProvider above the layout.
669
+ // This makes layout cache.component hits possible: the cached payload
670
+ // contains a deterministic children reference, not varying content.
671
+ // See design/45-cache-lifetimes.md §Layout Propagation.
672
+ const childOutlet = h(ChildSegmentOutlet, {});
655
673
  let layoutElement: React.ReactElement;
656
674
  if (isClientReference(layoutComponent)) {
657
675
  layoutElement = h(layoutComponent, {
658
676
  ...slotProps,
659
- children: element,
677
+ children: childOutlet,
660
678
  });
661
679
  } else {
662
680
  // Server component layout — wrap with OTEL tracing AND DenySignal
@@ -686,10 +704,17 @@ export async function buildRouteElement(
686
704
  };
687
705
  layoutElement = h(TracedLayout, {
688
706
  ...slotProps,
689
- children: element,
707
+ children: childOutlet,
690
708
  });
691
709
  }
692
710
 
711
+ // Wrap the layout in ChildSegmentProvider so ChildSegmentOutlet
712
+ // (inside the layout's children) can read the actual child content.
713
+ layoutElement = h(ChildSegmentProvider, {
714
+ childContent: element,
715
+ children: layoutElement,
716
+ });
717
+
693
718
  const segmentProviderElement = h(SegmentProvider, {
694
719
  segments: segmentPath,
695
720
  segmentId,
@@ -108,12 +108,6 @@ export function setDevSourceMapHandler(handler: (error: Error) => void): void {
108
108
  * 103 Early Hints → middleware.ts → render (RSC → SSR → HTML).
109
109
  */
110
110
  async function createRequestHandler(manifest: typeof routeManifest, runtimeConfig: typeof config) {
111
- // Load the user's instrumentation.ts — register() is awaited before the
112
- // server accepts any requests. The logger and onRequestError hooks are
113
- // wired into the framework. This runs once at startup.
114
- // See design/17-logging.md §"register() — Server Startup"
115
- await loadInstrumentation(loadUserInstrumentation);
116
-
117
111
  // Initialize deployment ID for version skew detection (TIM-446).
118
112
  // The manifest init module sets globalThis.__TIMBER_DEPLOYMENT_ID__ at startup.
119
113
  // In dev mode this is undefined — skew checks are skipped.
@@ -125,16 +119,6 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
125
119
  setDeploymentId(deploymentId);
126
120
  }
127
121
 
128
- // Wire the configured cache handler into the module-level singleton (TIM-599).
129
- // The cacheHandler is a class instance (e.g. RedisCacheHandler) that can't be
130
- // JSON-serialized into virtual:timber-config. Instead, virtual:timber-cache-handler
131
- // dynamically imports the user's timber.config.ts and extracts the handler.
132
- const userCacheHandler = await loadCacheHandler();
133
- if (userCacheHandler) {
134
- const { setCacheHandler } = await import('../../cache/handler-store.js');
135
- setCacheHandler(userCacheHandler as Parameters<typeof setCacheHandler>[0]);
136
- }
137
-
138
122
  // Wire the CDN purge handler so revalidateTag triggers CDN purge (TIM-1166).
139
123
  const userCdnPurge = await loadCdnPurgeHandler();
140
124
  if (userCdnPurge) {
@@ -148,17 +132,6 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
148
132
  setCdnPurgeHandler(new WorkersCachePurge());
149
133
  }
150
134
 
151
- // Propagate renderTimeoutMs into the default singleflight so cache calls
152
- // without a per-call timeoutMs still get deadlock protection (TIM-1032).
153
- const renderTimeoutMs = (runtimeConfig as Record<string, unknown>).renderTimeoutMs as
154
- | number
155
- | undefined;
156
- if (renderTimeoutMs) {
157
- const { setDefaultSingleflightTimeout } = await import('../../cache/timber-cache.js');
158
- setDefaultSingleflightTimeout(renderTimeoutMs);
159
- setComponentRefreshTimeout(renderTimeoutMs);
160
- }
161
-
162
135
  // Wire the prebuilt payload source so cache.component wrappers can look
163
136
  // up cached flight payloads at request time (TIM-1122). The source is
164
137
  // platform-aware: Node/Nitro reads from disk; Cloudflare reads from
@@ -216,13 +189,6 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
216
189
  const matchRoute = createRouteMatcher(manifest);
217
190
  const matchMetadataRoute = createMetadataRouteMatcher(manifest);
218
191
 
219
- // TIM-931: Register global schema codecs for param coercion.
220
- // When app/schema.ts exists, globalSchemaCodecs is a Record<string, Codec>;
221
- // otherwise null. Registered once at startup so all coercion paths
222
- // (pipeline-phases.ts, action-middleware-runner.ts, revalidation) share
223
- // the same codec source.
224
- setGlobalSchemaCodecs(globalSchemaCodecs);
225
-
226
192
  // Build the client bootstrap configuration.
227
193
  // When client JavaScript is disabled, no scripts are injected
228
194
  // (unless enableHMRInDev is true in dev mode — then only HMR client).
@@ -559,15 +525,49 @@ export function __timber_capturePrebuilt(
559
525
  return capturePrebuiltPayloads(routeManifest, options);
560
526
  }
561
527
 
562
- const { handler: _handler, pipelineConfig: _pipelineConfig } = await createRequestHandler(
563
- routeManifest,
564
- config
565
- );
528
+ // Schema codecs are needed by both the capture path (param coercion) and
529
+ // the request handler, so register them unconditionally.
530
+ setGlobalSchemaCodecs(globalSchemaCodecs);
531
+
532
+ // Singleflight timeout protects against deadlocked cache calls — needed
533
+ // in both capture mode (components may call timber.cache) and normal mode.
534
+ const renderTimeoutMs = (config as Record<string, unknown>).renderTimeoutMs as number | undefined;
535
+ if (renderTimeoutMs) {
536
+ const { setDefaultSingleflightTimeout } = await import('../../cache/timber-cache.js');
537
+ setDefaultSingleflightTimeout(renderTimeoutMs);
538
+ setComponentRefreshTimeout(renderTimeoutMs);
539
+ }
540
+
541
+ // Run instrumentation before the capture-mode guard — components may
542
+ // depend on globals or SDKs initialized in register().
543
+ await loadInstrumentation(loadUserInstrumentation);
544
+
545
+ // Wire the cache handler — prebuilt components may call timber.cache
546
+ // during capture and must see the configured store, not the fallback.
547
+ const userCacheHandler = await loadCacheHandler();
548
+ if (userCacheHandler) {
549
+ const { setCacheHandler } = await import('../../cache/handler-store.js');
550
+ setCacheHandler(userCacheHandler as Parameters<typeof setCacheHandler>[0]);
551
+ }
552
+
553
+ // In capture mode the plugin only calls __timber_capturePrebuilt — skip
554
+ // the remaining production startup (CDN purge, OTEL, route matchers)
555
+ // which is expensive and unnecessary.
556
+ const isCaptureMode = !!(globalThis as Record<string, unknown>).__TIMBER_CAPTURE_MODE__;
557
+
558
+ let _handler: ((req: Request) => Promise<Response>) | undefined;
559
+ let _pipelineConfig: PipelineConfig | undefined;
560
+
561
+ if (!isCaptureMode) {
562
+ const result = await createRequestHandler(routeManifest, config);
563
+ _handler = result.handler;
564
+ _pipelineConfig = result.pipelineConfig;
565
+ }
566
566
 
567
567
  /**
568
568
  * The pipeline configuration object. Exported so the dev server can set
569
569
  * `devPipelineErrorHandler` after import without a module-level singleton.
570
570
  */
571
- export const pipelineConfig = _pipelineConfig;
571
+ export const pipelineConfig = _pipelineConfig!;
572
572
 
573
- export default _handler;
573
+ export default _handler!;
@@ -31,7 +31,11 @@ import type { LayoutEntry } from '../deny-renderer.js';
31
31
  import { renderDenyPage, renderDenyPageAsRsc } from '../deny-renderer.js';
32
32
  import type { ClientBootstrapConfig } from '../html-injectors.js';
33
33
  import type { InterceptionContext, RouteMatch } from '../pipeline.js';
34
- import { buildRouteElement, ParamCoercionError } from '../route-element-builder.js';
34
+ import {
35
+ buildRouteElement,
36
+ NoPageComponentError,
37
+ ParamCoercionError,
38
+ } from '../route-element-builder.js';
35
39
  import type { ManifestSegmentNode } from '../route-matcher.js';
36
40
  import { recordTiming } from '../server-timing.js';
37
41
  import { requestContextAls } from '../als-registry.js';
@@ -117,7 +121,7 @@ export async function renderRoute(
117
121
  return renderNoMatchPage(req, rootSegment, responseHeaders, clientBootstrap);
118
122
  }
119
123
  // No PageComponent found — same treatment as param coercion: render custom 404.
120
- if (error instanceof Error && error.message.startsWith('No page component')) {
124
+ if (error instanceof NoPageComponentError) {
121
125
  if (process.env.NODE_ENV !== 'production') {
122
126
  console.warn(`[timber] ${error.message}`);
123
127
  }
@@ -101,11 +101,11 @@ export async function buildRscPayloadResponse(
101
101
  // Check for redirect/deny signals detected during initial rendering
102
102
  const trackedRedirect = signals.redirectSignal as RedirectSignal | null;
103
103
  if (trackedRedirect) {
104
- reader.cancel();
104
+ reader.cancel().catch(() => {});
105
105
  return buildRedirectResponse(trackedRedirect, req, responseHeaders);
106
106
  }
107
107
  if (signals.denySignal) {
108
- reader.cancel();
108
+ reader.cancel().catch(() => {});
109
109
  return renderDenyPageAsRsc(
110
110
  signals.denySignal,
111
111
  segments,
@@ -0,0 +1,3 @@
1
+ // No-op module for Node.js and vitest resolution of #server-only-guard.
2
+ // The shims plugin intercepts this specifier in the client environment
3
+ // and returns the server-only poison pill instead.