@timber-js/app 0.2.0-alpha.161 → 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.
- package/dist/_chunks/{actions-cjklt63G.js → actions-CSDD6x7U.js} +2 -2
- package/dist/_chunks/{actions-cjklt63G.js.map → actions-CSDD6x7U.js.map} +1 -1
- package/dist/_chunks/{cache-api-CzYUlgXA.js → cache-api-eb1gydM7.js} +41 -13
- package/dist/_chunks/cache-api-eb1gydM7.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js → cli-schema-sync-mGfRbjh2.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js.map → cli-schema-sync-mGfRbjh2.js.map} +1 -1
- package/dist/_chunks/{plugin-context-DeAxFRMq.js → plugin-context-BnaiU_cF.js} +37 -2
- package/dist/_chunks/plugin-context-BnaiU_cF.js.map +1 -0
- package/dist/_chunks/{walkers-9mz9T7mb.js → walkers-BL3MCMgO.js} +2 -2
- package/dist/_chunks/{walkers-9mz9T7mb.js.map → walkers-BL3MCMgO.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.d.ts +1 -0
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/nitro.d.ts +11 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +77 -64
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.d.ts +3 -0
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cache/redis-handler.d.ts +1 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/tag-aware-handler.d.ts +1 -0
- package/dist/cache/tag-aware-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/internal.js +1 -2
- package/dist/client/internal.js.map +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/slot-context.d.ts +29 -0
- package/dist/client/slot-context.d.ts.map +1 -0
- package/dist/client/slot-outlet.d.ts +16 -0
- package/dist/client/slot-outlet.d.ts.map +1 -0
- package/dist/client/slot-provider.d.ts +20 -0
- package/dist/client/slot-provider.d.ts.map +1 -0
- package/dist/config-types.d.ts +2 -1
- package/dist/config-types.d.ts.map +1 -1
- package/dist/dev-tools/logs.d.ts.map +1 -1
- package/dist/index.js +293 -124
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts +27 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
- package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
- package/dist/plugins/prebuilt.d.ts.map +1 -1
- package/dist/plugins/prerender-sugar.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/html-injector-core.d.ts +30 -9
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +346 -51
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/prebuilt/cache-key.d.ts +4 -0
- package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
- package/dist/server/prebuilt/key-discipline.d.ts +23 -0
- package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
- package/dist/server/prebuilt/slots.d.ts +74 -0
- package/dist/server/prebuilt/slots.d.ts.map +1 -0
- package/dist/server/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/prebuilt-runtime.d.ts +12 -2
- package/dist/server/prebuilt-runtime.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
- package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +26 -3
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/docs/api/34-api-config.mdx +165 -3
- package/docs/learn/00-introduction.mdx +78 -44
- package/docs/learn/13-configuration.mdx +27 -8
- package/package.json +3 -2
- package/src/adapters/cloudflare-kv-cache.ts +5 -1
- package/src/adapters/nitro.ts +82 -64
- package/src/cache/index.ts +25 -2
- package/src/cache/redis-handler.ts +27 -7
- package/src/cache/tag-aware-handler.ts +5 -1
- package/src/cache/timber-cache.ts +21 -10
- package/src/client/segment-cache.ts +4 -5
- package/src/client/slot-context.ts +48 -0
- package/src/client/slot-outlet.tsx +22 -0
- package/src/client/slot-provider.tsx +25 -0
- package/src/config-types.ts +2 -1
- package/src/dev-tools/logs.ts +7 -0
- package/src/plugin-context.ts +54 -0
- package/src/plugins/cache.ts +1 -2
- package/src/plugins/client-chunks.ts +42 -1
- package/src/plugins/dev-server.ts +12 -69
- package/src/plugins/prebuilt-options-analysis.ts +175 -0
- package/src/plugins/prebuilt.ts +82 -127
- package/src/plugins/prerender-sugar.ts +1 -2
- package/src/server/html-injector-core.ts +85 -27
- package/src/server/html-injectors.ts +5 -1
- package/src/server/node-stream-transforms.ts +6 -1
- package/src/server/pipeline-phases.ts +4 -1
- package/src/server/prebuilt/cache-key.ts +74 -0
- package/src/server/prebuilt/key-discipline.ts +53 -0
- package/src/server/prebuilt/slots.ts +167 -0
- package/src/server/prebuilt-builder.ts +57 -23
- package/src/server/prebuilt-runtime.ts +144 -60
- package/src/server/route-element-builder.ts +82 -73
- package/src/server/rsc-entry/deny-fallback.ts +92 -0
- package/src/server/rsc-entry/helpers.ts +12 -10
- package/src/server/rsc-entry/index.ts +16 -70
- package/src/server/state-tree-diff.ts +49 -4
- package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
- 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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
|
745
|
-
//
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
const emittedProps =
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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:
|
|
763
|
-
params:
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>);
|