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

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 (102) hide show
  1. package/dist/_chunks/{actions-cjklt63G.js → actions-CSDD6x7U.js} +2 -2
  2. package/dist/_chunks/{actions-cjklt63G.js.map → actions-CSDD6x7U.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-CzYUlgXA.js → cache-api-eb1gydM7.js} +41 -13
  4. package/dist/_chunks/cache-api-eb1gydM7.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js → cli-schema-sync-mGfRbjh2.js} +2 -2
  6. package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js.map → cli-schema-sync-mGfRbjh2.js.map} +1 -1
  7. package/dist/_chunks/{plugin-context-DeAxFRMq.js → plugin-context-BnaiU_cF.js} +37 -2
  8. package/dist/_chunks/plugin-context-BnaiU_cF.js.map +1 -0
  9. package/dist/_chunks/{walkers-9mz9T7mb.js → walkers-BL3MCMgO.js} +2 -2
  10. package/dist/_chunks/{walkers-9mz9T7mb.js.map → walkers-BL3MCMgO.js.map} +1 -1
  11. package/dist/adapters/cloudflare-kv-cache.d.ts +1 -0
  12. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  13. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  14. package/dist/adapters/nitro.d.ts +11 -0
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +77 -64
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/cache/index.d.ts +3 -0
  19. package/dist/cache/index.d.ts.map +1 -1
  20. package/dist/cache/index.js +1 -1
  21. package/dist/cache/redis-handler.d.ts +1 -0
  22. package/dist/cache/redis-handler.d.ts.map +1 -1
  23. package/dist/cache/tag-aware-handler.d.ts +1 -0
  24. package/dist/cache/tag-aware-handler.d.ts.map +1 -1
  25. package/dist/cache/timber-cache.d.ts.map +1 -1
  26. package/dist/cli.js +2 -2
  27. package/dist/client/slot-context.d.ts +29 -0
  28. package/dist/client/slot-context.d.ts.map +1 -0
  29. package/dist/client/slot-outlet.d.ts +16 -0
  30. package/dist/client/slot-outlet.d.ts.map +1 -0
  31. package/dist/client/slot-provider.d.ts +20 -0
  32. package/dist/client/slot-provider.d.ts.map +1 -0
  33. package/dist/config-types.d.ts +2 -1
  34. package/dist/config-types.d.ts.map +1 -1
  35. package/dist/dev-tools/logs.d.ts.map +1 -1
  36. package/dist/index.js +293 -124
  37. package/dist/index.js.map +1 -1
  38. package/dist/plugin-context.d.ts +27 -0
  39. package/dist/plugin-context.d.ts.map +1 -1
  40. package/dist/plugins/cache.d.ts.map +1 -1
  41. package/dist/plugins/client-chunks.d.ts.map +1 -1
  42. package/dist/plugins/dev-server.d.ts.map +1 -1
  43. package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
  44. package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
  45. package/dist/plugins/prebuilt.d.ts.map +1 -1
  46. package/dist/plugins/prerender-sugar.d.ts.map +1 -1
  47. package/dist/routing/index.js +2 -2
  48. package/dist/server/html-injector-core.d.ts +30 -9
  49. package/dist/server/html-injector-core.d.ts.map +1 -1
  50. package/dist/server/html-injectors.d.ts.map +1 -1
  51. package/dist/server/index.js +1 -1
  52. package/dist/server/internal.js +346 -51
  53. package/dist/server/internal.js.map +1 -1
  54. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  55. package/dist/server/pipeline-phases.d.ts.map +1 -1
  56. package/dist/server/prebuilt/cache-key.d.ts +4 -0
  57. package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
  58. package/dist/server/prebuilt/key-discipline.d.ts +23 -0
  59. package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
  60. package/dist/server/prebuilt/slots.d.ts +74 -0
  61. package/dist/server/prebuilt/slots.d.ts.map +1 -0
  62. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  63. package/dist/server/prebuilt-runtime.d.ts +12 -2
  64. package/dist/server/prebuilt-runtime.d.ts.map +1 -1
  65. package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
  66. package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
  67. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  68. package/docs/api/34-api-config.mdx +165 -3
  69. package/docs/learn/00-introduction.mdx +78 -44
  70. package/docs/learn/13-configuration.mdx +27 -8
  71. package/package.json +3 -2
  72. package/src/adapters/cloudflare-kv-cache.ts +5 -1
  73. package/src/adapters/nitro.ts +82 -64
  74. package/src/cache/index.ts +25 -2
  75. package/src/cache/redis-handler.ts +27 -7
  76. package/src/cache/tag-aware-handler.ts +5 -1
  77. package/src/cache/timber-cache.ts +21 -10
  78. package/src/client/slot-context.ts +48 -0
  79. package/src/client/slot-outlet.tsx +22 -0
  80. package/src/client/slot-provider.tsx +25 -0
  81. package/src/config-types.ts +2 -1
  82. package/src/dev-tools/logs.ts +7 -0
  83. package/src/plugin-context.ts +54 -0
  84. package/src/plugins/cache.ts +1 -2
  85. package/src/plugins/client-chunks.ts +42 -1
  86. package/src/plugins/dev-server.ts +12 -69
  87. package/src/plugins/prebuilt-options-analysis.ts +175 -0
  88. package/src/plugins/prebuilt.ts +82 -127
  89. package/src/plugins/prerender-sugar.ts +1 -2
  90. package/src/server/html-injector-core.ts +85 -27
  91. package/src/server/html-injectors.ts +5 -1
  92. package/src/server/node-stream-transforms.ts +6 -1
  93. package/src/server/pipeline-phases.ts +4 -1
  94. package/src/server/prebuilt/cache-key.ts +74 -0
  95. package/src/server/prebuilt/key-discipline.ts +53 -0
  96. package/src/server/prebuilt/slots.ts +167 -0
  97. package/src/server/prebuilt-builder.ts +57 -23
  98. package/src/server/prebuilt-runtime.ts +144 -60
  99. package/src/server/rsc-entry/deny-fallback.ts +92 -0
  100. package/src/server/rsc-entry/index.ts +16 -70
  101. package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
  102. package/dist/_chunks/plugin-context-DeAxFRMq.js.map +0 -1
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Key-discipline diagnostic for cache.component (design/45 §Enforcement).
3
+ *
4
+ * Runtime-tier cached components MAY read request context, but the cache
5
+ * key is computed from props and segment params only — request-derived
6
+ * values in cached output are the data-leak danger zone (design/06
7
+ * security note). During a capture render the ALS store is wrapped in a
8
+ * transparent observing proxy; any request-context read produces a
9
+ * once-per-component warning.
10
+ */
11
+
12
+ import type { RequestContextStore } from '../als-registry.js';
13
+
14
+ /** Components already warned — exported so tests can reset between cases. */
15
+ export const keyDisciplineWarned = new Set<string>();
16
+
17
+ const REQUEST_CONTEXT_FIELDS: Record<string, string> = {
18
+ headers: 'headers()',
19
+ parsedCookies: 'cookies()',
20
+ searchParams: 'searchParams',
21
+ };
22
+
23
+ /**
24
+ * Wrap a store with a proxy that records per-request field access.
25
+ * The proxy forwards all reads/writes transparently — it only observes.
26
+ */
27
+ export function createKeyDisciplineProxy(store: RequestContextStore): {
28
+ proxy: RequestContextStore;
29
+ accessedApis: Set<string>;
30
+ } {
31
+ const accessedApis = new Set<string>();
32
+ const proxy = new Proxy(store, {
33
+ get(target, prop, receiver) {
34
+ const api = REQUEST_CONTEXT_FIELDS[prop as string];
35
+ if (api) accessedApis.add(api);
36
+ return Reflect.get(target, prop, receiver);
37
+ },
38
+ });
39
+ return { proxy, accessedApis };
40
+ }
41
+
42
+ export function emitKeyDisciplineWarning(id: string, apis: Set<string>): void {
43
+ if (keyDisciplineWarned.has(id)) return;
44
+ keyDisciplineWarned.add(id);
45
+ const apiList = Array.from(apis).join(', ');
46
+ console.warn(
47
+ `[timber] cache.component key-discipline warning: "${id}" reads ${apiList} ` +
48
+ `but the cache key is computed from props and segment params only. ` +
49
+ `If the output varies by ${apiList}, different users may see each ` +
50
+ `other's cached content. Either pass request-derived values as ` +
51
+ `props (included in the cache key) or remove the ${apiList} call.`
52
+ );
53
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Slot-component helpers for cache.component (TIM-1173).
3
+ *
4
+ * A `slots: ['children', ...]` declaration caches the component's output
5
+ * as a shell with a stable <SlotOutlet slot={name} /> client-reference
6
+ * hole per declared slot prop; live slot content composes in per instance
7
+ * at request time via SlotsProvider/SlotContext. This generalizes the
8
+ * TIM-1181 layout mechanism (ChildSegmentOutlet) to explicitly declared,
9
+ * named slots on any cached component.
10
+ *
11
+ * Both the production wrapper (cache path) and the dev wrapper (no cache,
12
+ * same tree shape for dev/prod parity) build their render from these
13
+ * helpers. See design/45-cache-lifetimes.md §Slot Components.
14
+ */
15
+
16
+ import { createElement } from 'react';
17
+
18
+ import type { SlotValues } from '../../client/slot-context.js';
19
+ import { ChildSegmentOutlet } from '../../client/child-segment-outlet.js';
20
+ import { SlotOutlet } from '../../client/slot-outlet.js';
21
+ import { SlotsProvider } from '../../client/slot-provider.js';
22
+ import { splitSlotProps } from './cache-key.js';
23
+ import type { PrebuiltComponentOptions } from '../prebuilt-runtime.js';
24
+
25
+ /**
26
+ * Is this value the framework-injected `<ChildSegmentOutlet />` element?
27
+ * Total: the `in` probe and `.type` read run against a user-controlled
28
+ * value, and a Proxy trap can throw — that must classify as "not the
29
+ * outlet" (the canonicalizer then rejects the value as unkeyable → live
30
+ * render), not abort the request (codex P2 on PR #890).
31
+ */
32
+ export function isChildSegmentOutletElement(value: unknown): boolean {
33
+ try {
34
+ return (
35
+ value != null &&
36
+ typeof value === 'object' &&
37
+ 'type' in value &&
38
+ (value as { type: unknown }).type === ChildSegmentOutlet
39
+ );
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Outcome of validating a `slots` declaration at registration time.
47
+ *
48
+ * - `none` — not declared (or an empty array): plain non-slot component.
49
+ * - `disabled` — declared but unusable (malformed shape, or missing the
50
+ * required runtime lifetime): warned once here (module load); the
51
+ * component falls back WHOLESALE to non-slot semantics — the caller
52
+ * strips `slots` from the options and every other option (including
53
+ * `prerender`) keeps its normal meaning. Safe by construction:
54
+ * element-valued props are unkeyable on the non-slot path, so renders
55
+ * with slot content go live. Slower, never wrong.
56
+ * - `active` — usable slot names.
57
+ */
58
+ export type SlotResolution = { kind: 'none' | 'disabled' } | { kind: 'active'; slots: string[] };
59
+
60
+ /**
61
+ * Dense array of non-empty strings — no holes, no non-string entries.
62
+ * Total: the reads run against a user-controlled value at module load,
63
+ * and a throwing Proxy trap must classify as invalid, not crash startup.
64
+ */
65
+ function isDenseStringArray(value: unknown): value is string[] {
66
+ try {
67
+ if (!Array.isArray(value)) return false;
68
+ for (let i = 0; i < value.length; i++) {
69
+ if (!Object.hasOwn(value, i)) return false; // sparse hole
70
+ const entry: unknown = value[i];
71
+ if (typeof entry !== 'string' || entry.length === 0) return false;
72
+ }
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Validate the `slots` option. Both the production and dev wrappers gate
81
+ * on this — slot substitution is active in dev exactly when it would be
82
+ * active in production, so the tree shape a component sees never
83
+ * diverges between the two.
84
+ */
85
+ export function resolveSlotNames(id: string, options: PrebuiltComponentOptions): SlotResolution {
86
+ const slots = options.slots;
87
+ if (slots === undefined) return { kind: 'none' };
88
+ // Shape first — a non-array (null, object, …) must not reach a .length
89
+ // or iteration and crash module load over an option typo. Explicit
90
+ // index walk with Object.hasOwn: Array.prototype.every skips sparse
91
+ // holes, which would let `slots[1] = 'children'` on an empty array pass
92
+ // as active and flow an undefined slot into the shell (codex P2 on
93
+ // PR #890).
94
+ if (!isDenseStringArray(slots)) {
95
+ console.warn(
96
+ `[timber] cache.component "${id}": invalid slots declaration — expected ` +
97
+ `an array of non-empty prop-name strings. Slot caching is disabled.`
98
+ );
99
+ return { kind: 'disabled' };
100
+ }
101
+ if (slots.length === 0) return { kind: 'none' };
102
+ const hasRuntimeLifetime = options.ttl !== undefined || options.tags != null;
103
+ if (!hasRuntimeLifetime) {
104
+ console.warn(
105
+ `[timber] cache.component "${id}": slots require a runtime lifetime ` +
106
+ `(ttl and/or tags). Slot caching is disabled.`
107
+ );
108
+ return { kind: 'disabled' };
109
+ }
110
+ return { kind: 'active', slots };
111
+ }
112
+
113
+ export interface SlotRender {
114
+ /** Props the shell renders with: data props + a SlotOutlet per slot. */
115
+ shellProps: Record<string, unknown>;
116
+ /** Non-slot props — the only props that participate in the cache key. */
117
+ dataProps: Record<string, unknown>;
118
+ /** Live slot content, keyed by slot name, for the SlotsProvider. */
119
+ slotValues: Record<string, unknown>;
120
+ }
121
+
122
+ /**
123
+ * Split the live props into the cacheable shell render (data props plus a
124
+ * deterministic SlotOutlet placeholder per declared slot — identical
125
+ * whether or not the caller passed the slot) and the per-instance live
126
+ * slot values.
127
+ *
128
+ * Null when the props object itself is unkeyable at the top level
129
+ * (accessors, hidden properties, exotic prototype — see splitSlotProps):
130
+ * the caller must render live with the original props.
131
+ */
132
+ export function prepareSlotRender(
133
+ props: Record<string, unknown>,
134
+ slots: readonly string[]
135
+ ): SlotRender | null {
136
+ const split = splitSlotProps(props ?? {}, slots);
137
+ if (split === null) return null;
138
+ const { dataProps, slotValues } = split;
139
+ // Spread + defineProperty, not assignment: a slot named `__proto__`
140
+ // must become an own property, not mutate the shell props' prototype
141
+ // (spread uses CreateDataProperty, so it is already safe).
142
+ const shellProps: Record<string, unknown> = { ...dataProps };
143
+ for (const slot of slots) {
144
+ Object.defineProperty(shellProps, slot, {
145
+ value: createElement(SlotOutlet, { slot }),
146
+ enumerable: true,
147
+ writable: true,
148
+ configurable: true,
149
+ });
150
+ }
151
+ return { shellProps, dataProps, slotValues };
152
+ }
153
+
154
+ /**
155
+ * Wrap a rendered shell (revived payload or live element) in a
156
+ * per-instance SlotsProvider feeding the live values to its outlets.
157
+ */
158
+ export function wrapInSlotsProvider(
159
+ shell: unknown,
160
+ slotValues: Record<string, unknown>
161
+ ): React.ReactElement {
162
+ // children passed as a prop, not a JSX child — the shell is `unknown`
163
+ // to the type system. Same pattern as route-element-builder's `h` alias
164
+ // for ChildSegmentProvider.
165
+ const h = createElement as (...args: unknown[]) => React.ReactElement;
166
+ return h(SlotsProvider, { values: slotValues as SlotValues, children: shell });
167
+ }
@@ -471,6 +471,18 @@ interface PendingEntry {
471
471
  tags: string[] | undefined;
472
472
  }
473
473
 
474
+ function pendingToCapturedEntry(componentId: string, entry: PendingEntry): CapturedEntry {
475
+ const hasProps = Object.keys(entry.callSiteProps).length > 0;
476
+ return {
477
+ componentId,
478
+ cacheKey: entry.cacheKey,
479
+ params: entry.combo,
480
+ ...(hasProps ? { props: entry.callSiteProps } : {}),
481
+ bytes: entry.bytes,
482
+ tags: entry.tags,
483
+ };
484
+ }
485
+
474
486
  /**
475
487
  * Capture flight payloads for every attributed, registered, prerender-tier
476
488
  * component across the route manifest. See module docblock for the flow.
@@ -685,8 +697,19 @@ export async function capturePrebuiltPayloads(
685
697
  // so a combo that reads params then fails still prevents
686
698
  // param-independent classification (codex P1 on PR #869).
687
699
  if (outcome.segmentParamsRead) {
700
+ const wasParamIndep = !pending.anyParamRead;
688
701
  pending.anyParamRead = true;
689
702
  provenParamIndependent.delete(proofKey);
703
+ // Flush buffered entries immediately — they're confirmed
704
+ // param-dependent and don't need to wait for the emit phase.
705
+ // Avoids retaining all combo payloads in memory (TIM-1179).
706
+ if (wasParamIndep) {
707
+ for (const buffered of pending.entries) {
708
+ await options.onEntry(pendingToCapturedEntry(componentId, buffered));
709
+ summary.captured++;
710
+ }
711
+ pending.entries = [];
712
+ }
690
713
  } else if (outcome.failure === null && !pending.anyParamRead) {
691
714
  provenParamIndependent.add(proofKey);
692
715
  }
@@ -729,42 +752,53 @@ export async function capturePrebuiltPayloads(
729
752
  continue;
730
753
  }
731
754
 
732
- pending.entries.push({
733
- callSiteProps,
734
- cacheKey,
735
- combo,
736
- bytes: outcome.bytes!,
737
- tags: outcome.resolvedTags,
738
- });
755
+ // Once param-dependent is confirmed, emit directly instead of
756
+ // buffering — no need to hold bytes in memory (TIM-1179).
757
+ if (pending.anyParamRead) {
758
+ await options.onEntry(
759
+ pendingToCapturedEntry(componentId, {
760
+ callSiteProps,
761
+ cacheKey,
762
+ combo,
763
+ bytes: outcome.bytes!,
764
+ tags: outcome.resolvedTags,
765
+ })
766
+ );
767
+ summary.captured++;
768
+ } else {
769
+ pending.entries.push({
770
+ callSiteProps,
771
+ cacheKey,
772
+ combo,
773
+ bytes: outcome.bytes!,
774
+ tags: outcome.resolvedTags,
775
+ });
776
+ }
739
777
  }
740
778
  }
741
779
  }
742
780
  }
743
781
 
744
- // Emit buffered entries. Param-independent = ALL combos rendered and
745
- // NONE read segmentParams → collapse to one entry per props set (TIM-1175).
782
+ // Emit remaining buffered entries — only param-independent components
783
+ // still have entries here (param-dependent entries were emitted eagerly
784
+ // above, TIM-1179). Collapse to one entry per props set (TIM-1175).
746
785
  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;
786
+ if (pending.entries.length === 0) continue;
787
+ emittedParamIndependent.add(componentId);
788
+ const emittedProps = new Set<string>();
750
789
  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
- }
790
+ const propsKey = JSON.stringify(entry.callSiteProps);
791
+ if (emittedProps.has(propsKey)) continue;
792
+ emittedProps.add(propsKey);
756
793
  const hasProps = Object.keys(entry.callSiteProps).length > 0;
757
- const finalCacheKey = isParamIndep
758
- ? (computeComponentCacheKey(entry.callSiteProps, {}) ?? entry.cacheKey)
759
- : entry.cacheKey;
760
794
  await options.onEntry({
761
795
  componentId,
762
- cacheKey: finalCacheKey,
763
- params: isParamIndep ? {} : entry.combo,
796
+ cacheKey: computeComponentCacheKey(entry.callSiteProps, {}) ?? entry.cacheKey,
797
+ params: {},
764
798
  ...(hasProps ? { props: entry.callSiteProps } : {}),
765
799
  bytes: entry.bytes,
766
800
  tags: entry.tags,
767
- ...(isParamIndep ? { paramIndependent: true } : {}),
801
+ paramIndependent: true,
768
802
  });
769
803
  summary.captured++;
770
804
  }
@@ -36,8 +36,18 @@
36
36
  import { createElement } from 'react';
37
37
 
38
38
  import { requestContextAls, type RequestContextStore } from './als-registry.js';
39
- import { computeComponentCacheKey, tryCanonicalize } from './prebuilt/cache-key.js';
40
- import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
39
+ import { computeComponentCacheKey, splitSlotProps, tryCanonicalize } from './prebuilt/cache-key.js';
40
+ import {
41
+ isChildSegmentOutletElement,
42
+ resolveSlotNames,
43
+ prepareSlotRender,
44
+ wrapInSlotsProvider,
45
+ } from './prebuilt/slots.js';
46
+ import {
47
+ createKeyDisciplineProxy,
48
+ emitKeyDisciplineWarning,
49
+ keyDisciplineWarned,
50
+ } from './prebuilt/key-discipline.js';
41
51
  import {
42
52
  isCaptureActive,
43
53
  noteWrapperRender,
@@ -93,6 +103,17 @@ export interface PrebuiltComponentOptions {
93
103
  tags?: string[] | ((props: Record<string, unknown>) => string[]);
94
104
  /** Serve stale while a background re-render refreshes (design/45 §SWR). */
95
105
  staleWhileRevalidate?: boolean;
106
+ /**
107
+ * Slot props (TIM-1173): prop names whose values are live React
108
+ * elements composed into the cached shell at request time instead of
109
+ * participating in the cache. Each declared slot renders as a stable
110
+ * SlotOutlet client reference at capture time; the live value flows in
111
+ * through SlotContext per instance. Slot props are excluded from the
112
+ * cache key — only the remaining data props (+ segment params) hash.
113
+ * Runtime tier only: `prerender` is ignored (with a warning) because
114
+ * the build cannot enumerate children variants.
115
+ */
116
+ slots?: string[];
96
117
  /**
97
118
  * On a cache miss, render the component live (default: true). When false,
98
119
  * a miss throws instead — for sites that must never render unknown params
@@ -169,6 +190,27 @@ function resolveKeyParams(store: {
169
190
  return main;
170
191
  }
171
192
 
193
+ /**
194
+ * Mirror the production cache-key decision for a slot render, with no
195
+ * cache IO — used by the dev wrapper so slot substitution happens in dev
196
+ * exactly when production would serve the cache path rather than the
197
+ * live fallback (dev/prod parity, codex P2 on PR #890): requires an ALS
198
+ * store, keyable params (slot-param divergence unkeys), and keyable data
199
+ * props (after the ChildSegmentOutlet strip).
200
+ */
201
+ function isSlotRenderKeyable(dataProps: Record<string, unknown>): boolean {
202
+ const store = requestContextAls.getStore();
203
+ if (!store) return false;
204
+ const keyParams = resolveKeyParams(store);
205
+ if (keyParams === null) return false;
206
+ let keyProps = dataProps;
207
+ if (isChildSegmentOutletElement(keyProps.children)) {
208
+ const { children: _children, ...rest } = keyProps;
209
+ keyProps = rest;
210
+ }
211
+ return computeComponentCacheKey(keyProps, keyParams) !== null;
212
+ }
213
+
172
214
  /** Wrap payload bytes as a single-chunk ReadableStream for the decoder. */
173
215
  function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
174
216
  return new ReadableStream<Uint8Array>({
@@ -184,11 +226,45 @@ function hasRuntimeLifetime(options: PrebuiltComponentOptions): boolean {
184
226
  return options.ttl !== undefined || (options.tags !== undefined && options.tags !== null);
185
227
  }
186
228
 
187
- /** Resolve tags from options (may be static array or function of props). */
229
+ /**
230
+ * Resolve tags from options (may be static array or function of props).
231
+ * Function-form tags receive DATA props only — slot props are excluded so
232
+ * the tags fn never sees SlotOutlet placeholders (capture paths pass
233
+ * placeholder-substituted props) and tags stay deterministic per key.
234
+ */
188
235
  function resolveTags(options: PrebuiltComponentOptions, props: Record<string, unknown>): string[] {
189
236
  if (!options.tags) return [];
190
237
  if (Array.isArray(options.tags)) return options.tags;
191
- return options.tags(props);
238
+ const slots = options.slots;
239
+ // The split cannot fail here — capture paths pass framework-built shell
240
+ // props — but stay total: fall back to the props as given.
241
+ const fnProps =
242
+ slots && slots.length > 0 ? (splitSlotProps(props, slots)?.dataProps ?? props) : props;
243
+ return options.tags(fnProps);
244
+ }
245
+
246
+ // ─── Slot components (TIM-1173) ──────────────────────────────────────────
247
+
248
+ /**
249
+ * Serve a slot component from the cache: split live slot values out of the
250
+ * props, look up / capture the shell (data props + a deterministic
251
+ * SlotOutlet placeholder per slot), and wrap the revived shell in a
252
+ * per-instance SlotsProvider that feeds the live values to the outlets.
253
+ *
254
+ * Null on any miss — the caller renders live with the original props.
255
+ */
256
+ async function tryReviveSlotShell(
257
+ id: string,
258
+ options: PrebuiltComponentOptions,
259
+ slots: readonly string[],
260
+ props: Record<string, unknown>
261
+ ): Promise<{ element: unknown } | null> {
262
+ const render = prepareSlotRender(props, slots);
263
+ if (render === null) return null; // top-level props unkeyable → live render
264
+ const { shellProps, dataProps, slotValues } = render;
265
+ const revived = await tryRevivePayload(id, options, shellProps, dataProps);
266
+ if (revived === null) return null;
267
+ return { element: wrapInSlotsProvider(revived.element, slotValues) };
192
268
  }
193
269
 
194
270
  // ─── RSC function injection ─────────────────────────────────────────────
@@ -246,48 +322,6 @@ function getCaptureTimeoutMs(): number {
246
322
  return componentTimeoutMs;
247
323
  }
248
324
 
249
- // ─── Key-discipline warnings (design/45 §Enforcement) ───────────────────
250
-
251
- export const keyDisciplineWarned = new Set<string>();
252
-
253
- const REQUEST_CONTEXT_FIELDS: Record<string, string> = {
254
- headers: 'headers()',
255
- parsedCookies: 'cookies()',
256
- searchParams: 'searchParams',
257
- };
258
-
259
- /**
260
- * Wrap a store with a proxy that records per-request field access.
261
- * The proxy forwards all reads/writes transparently — it only observes.
262
- */
263
- function createKeyDisciplineProxy(store: RequestContextStore): {
264
- proxy: RequestContextStore;
265
- accessedApis: Set<string>;
266
- } {
267
- const accessedApis = new Set<string>();
268
- const proxy = new Proxy(store, {
269
- get(target, prop, receiver) {
270
- const api = REQUEST_CONTEXT_FIELDS[prop as string];
271
- if (api) accessedApis.add(api);
272
- return Reflect.get(target, prop, receiver);
273
- },
274
- });
275
- return { proxy, accessedApis };
276
- }
277
-
278
- function emitKeyDisciplineWarning(id: string, apis: Set<string>): void {
279
- if (keyDisciplineWarned.has(id)) return;
280
- keyDisciplineWarned.add(id);
281
- const apiList = Array.from(apis).join(', ');
282
- console.warn(
283
- `[timber] cache.component key-discipline warning: "${id}" reads ${apiList} ` +
284
- `but the cache key is computed from props and segment params only. ` +
285
- `If the output varies by ${apiList}, different users may see each ` +
286
- `other's cached content. Either pass request-derived values as ` +
287
- `props (included in the cache key) or remove the ${apiList} call.`
288
- );
289
- }
290
-
291
325
  /**
292
326
  * Render a component in isolation and capture its flight payload bytes.
293
327
  * Used for inline re-renders (tombstone/miss) and SWR background re-renders.
@@ -499,7 +533,8 @@ function backgroundRerender(
499
533
  async function tryRevivePayload(
500
534
  id: string,
501
535
  options: PrebuiltComponentOptions,
502
- props: Record<string, unknown>
536
+ props: Record<string, unknown>,
537
+ keyPropsOverride?: Record<string, unknown>
503
538
  ): Promise<{ element: unknown } | null> {
504
539
  const hasSource = hasPrebuiltPayloadSource();
505
540
  const hasRuntime = hasRuntimeLifetime(options);
@@ -519,19 +554,18 @@ async function tryRevivePayload(
519
554
  // are NOT stripped — they remain unkeyable so the component renders
520
555
  // live, which is correct: the cache cannot distinguish <A> from <B>.
521
556
  // See design/45-cache-lifetimes.md §Layout Propagation, TIM-1181.
557
+ // keyPropsOverride = slot component (TIM-1173): the wrapper already
558
+ // split out slot props; only data props participate in the key. The
559
+ // ChildSegmentOutlet strip applies to EITHER source — a cached layout
560
+ // declaring only named slots (slots: ['modal']) still receives the
561
+ // framework-injected children outlet in its data props, and it must not
562
+ // unkey the render there any more than on the non-slot path (codex P2
563
+ // on PR #890).
522
564
  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;
565
+ let keyProps = keyPropsOverride ?? allProps;
566
+ if (isChildSegmentOutletElement(keyProps.children)) {
567
+ const { children: _children, ...rest } = keyProps;
532
568
  keyProps = rest;
533
- } else {
534
- keyProps = allProps;
535
569
  }
536
570
  const cacheKey = computeComponentCacheKey(keyProps, keyParams);
537
571
  if (cacheKey === null) return null;
@@ -654,6 +688,30 @@ export function __prebuilt<C extends ServerComponent>(
654
688
  component: C,
655
689
  options: PrebuiltComponentOptions = {}
656
690
  ): C {
691
+ // Slot resolution first, then option normalization in its terms
692
+ // (TIM-1173). ACTIVE slots are runtime-tier only — the build cannot
693
+ // enumerate slot content — so `prerender` is stripped (loudly); the
694
+ // capture pass reads the registry's normalized options, so stripping
695
+ // here also keeps the build from capturing slot components. DISABLED
696
+ // declarations (malformed shape, missing lifetime — warned inside
697
+ // resolveSlotNames) fall back wholesale to non-slot semantics: `slots`
698
+ // is stripped so no downstream consumer (resolveTags' slot filtering,
699
+ // the registry, the capture pass) ever sees the raw value, and every
700
+ // other option — including `prerender` — keeps its normal meaning
701
+ // (codex P2s on PR #890).
702
+ const resolution = resolveSlotNames(id, options);
703
+ const slots = resolution.kind === 'active' ? resolution.slots : null;
704
+ if (resolution.kind === 'active' && options.prerender) {
705
+ console.warn(
706
+ `[timber] cache.component "${id}": slots are runtime-tier only — ` +
707
+ `prerender is ignored (the build cannot enumerate slot content). ` +
708
+ `Use ttl/tags for the cache lifetime.`
709
+ );
710
+ options = { ...options, prerender: undefined };
711
+ }
712
+ if (resolution.kind === 'disabled') {
713
+ options = { ...options, slots: undefined };
714
+ }
657
715
  registry.set(id, { id, component, options });
658
716
 
659
717
  const wrapper = async (props: Parameters<C>[0]) => {
@@ -670,7 +728,10 @@ export function __prebuilt<C extends ServerComponent>(
670
728
  // spuriously fail the outer entry (codex P2 on PR #836).
671
729
  return createElement(component as RenderableComponent, props as Record<string, unknown>);
672
730
  }
673
- const revived = await tryRevivePayload(id, options, props as Record<string, unknown>);
731
+
732
+ const revived = slots
733
+ ? await tryReviveSlotShell(id, options, slots, props as Record<string, unknown>)
734
+ : await tryRevivePayload(id, options, props as Record<string, unknown>);
674
735
  if (revived !== null) {
675
736
  return revived.element;
676
737
  }
@@ -715,14 +776,37 @@ export function getPrebuiltRegistry(): ReadonlyMap<string, PrebuiltRegistration>
715
776
  export function __prebuiltDev<C extends ServerComponent>(
716
777
  name: string,
717
778
  component: C,
718
- _options?: PrebuiltComponentOptions
779
+ options: PrebuiltComponentOptions = {}
719
780
  ): C {
781
+ // Dev/prod parity for slot components: in production a slot component
782
+ // ALWAYS receives a SlotOutlet element for each declared slot (never the
783
+ // live children), so a component that introspects a slot prop would
784
+ // behave differently between dev and prod if dev passed children
785
+ // through. Dev renders the same tree shape — outlets in the props,
786
+ // live values via SlotsProvider — just without any caching. The same
787
+ // validation applies (including the lifetime requirement): slots are
788
+ // active in dev exactly when they would be active in production.
789
+ const resolution = resolveSlotNames(name, options);
790
+ const slots = resolution.kind === 'active' ? resolution.slots : null;
720
791
  let logged = false;
721
792
  const wrapper = (props: Parameters<C>[0]) => {
722
793
  if (!logged) {
723
794
  logged = true;
724
795
  console.log(`[timber] cache.component: "${name}" — rendering dynamically (dev mode)`);
725
796
  }
797
+ if (slots) {
798
+ const render = prepareSlotRender(props as Record<string, unknown>, slots);
799
+ // Substitute outlets only when PRODUCTION would: top-level props
800
+ // splittable AND the data props keyable. Unkeyable inputs render
801
+ // live with the original props there, so dev must too (parity —
802
+ // codex P2 on PR #890).
803
+ if (render !== null && isSlotRenderKeyable(render.dataProps)) {
804
+ return wrapInSlotsProvider(
805
+ createElement(component as RenderableComponent, render.shellProps),
806
+ render.slotValues
807
+ );
808
+ }
809
+ }
726
810
  // Element, not a direct call — same dispatcher reasoning as the
727
811
  // production wrapper's live fallback.
728
812
  return createElement(component as RenderableComponent, props as Record<string, unknown>);