Skip to content

Commit 13077d7

Browse files
authored
feat(react): support pure computed render functions (#1246)
* feat(react): support owned pure computed functions * chore(react): align computed API with merged element
1 parent 565f606 commit 13077d7

15 files changed

Lines changed: 615 additions & 11 deletions

File tree

‎apps/website/content/AGENTS.md.template‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,5 @@ Repeat Loops has an experimental local React preview at https://threadplane.ai/d
107107
Component Registry has an experimental local React preview at https://threadplane.ai/docs/render/guides/registry?frontend=react. The host supplies stable read-only component maps to installed native RenderSpec. Badge display chooses Registered, Omitted or an authored Fallback; fallback receives resolved props, element key and loading, while container views display native children exactly once. Choice survives partial playback, seek, Reset and sample selection without modifying JSON; reload restores Registered. Complete admission permits only four authored literal types. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-registry-react` and verify with `npx nx e2e cockpit-render-registry-react`.
108108

109109
Element Rendering has an experimental local React preview at https://threadplane.ai/docs/render/api/render-spec-component?frontend=react. Installed native RenderSpec owns ordered children, nested readonly views and visibility from explicit caller-owned boolean state. Show detail survives playback, seek, Reset and sample changes without modifying JSON; reload shows detail. The bounded projection withholds the conditional node until its exact /showDetail visibility path is available, preventing hidden text from flashing during partial playback. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-element-rendering-react` and verify with `npx nx e2e cockpit-render-element-rendering-react`.
110+
111+
The private React render entry also accepts an optional `ReactRenderFunctions` host map for authored `$computed` expressions. `RenderComputedFunction` receives owned recursively frozen arguments and synchronously returns `RenderValue`; calculations must be pure and can run repeatedly during React rendering. Plain or null-prototype maps admit own callable data entries, including non-enumerable entries, and reject accessors, symbols and non-callable values. Results retain the existing plain-data ownership guards. This adds no actions, watchers, state ownership or asynchronous effect execution, and raw specs still cannot contain executable callbacks.

‎apps/website/content/CLAUDE.md.template‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,5 @@ Repeat Loops has an experimental local React preview at https://threadplane.ai/d
107107
Component Registry has an experimental local React preview at https://threadplane.ai/docs/render/guides/registry?frontend=react. The host supplies stable read-only component maps to installed native RenderSpec. Badge display chooses Registered, Omitted or an authored Fallback; fallback receives resolved props, element key and loading, while container views display native children exactly once. Choice survives partial playback, seek, Reset and sample selection without modifying JSON; reload restores Registered. Complete admission permits only four authored literal types. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-registry-react` and verify with `npx nx e2e cockpit-render-registry-react`.
108108

109109
Element Rendering has an experimental local React preview at https://threadplane.ai/docs/render/api/render-spec-component?frontend=react. Installed native RenderSpec owns ordered children, nested readonly views and visibility from explicit caller-owned boolean state. Show detail survives playback, seek, Reset and sample changes without modifying JSON; reload shows detail. The bounded projection withholds the conditional node until its exact /showDetail visibility path is available, preventing hidden text from flashing during partial playback. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-element-rendering-react` and verify with `npx nx e2e cockpit-render-element-rendering-react`.
110+
111+
The private React render entry also accepts an optional `ReactRenderFunctions` host map for authored `$computed` expressions. `RenderComputedFunction` receives owned recursively frozen arguments and synchronously returns `RenderValue`; calculations must be pure and can run repeatedly during React rendering. Plain or null-prototype maps admit own callable data entries, including non-enumerable entries, and reject accessors, symbols and non-callable values. Results retain the existing plain-data ownership guards. This adds no actions, watchers, state ownership or asynchronous effect execution, and raw specs still cannot contain executable callbacks.

‎apps/website/public/AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,5 @@ Repeat Loops has an experimental local React preview at https://threadplane.ai/d
107107
Component Registry has an experimental local React preview at https://threadplane.ai/docs/render/guides/registry?frontend=react. The host supplies stable read-only component maps to installed native RenderSpec. Badge display chooses Registered, Omitted or an authored Fallback; fallback receives resolved props, element key and loading, while container views display native children exactly once. Choice survives partial playback, seek, Reset and sample selection without modifying JSON; reload restores Registered. Complete admission permits only four authored literal types. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-registry-react` and verify with `npx nx e2e cockpit-render-registry-react`.
108108

109109
Element Rendering has an experimental local React preview at https://threadplane.ai/docs/render/api/render-spec-component?frontend=react. Installed native RenderSpec owns ordered children, nested readonly views and visibility from explicit caller-owned boolean state. Show detail survives playback, seek, Reset and sample changes without modifying JSON; reload shows detail. The bounded projection withholds the conditional node until its exact /showDetail visibility path is available, preventing hidden text from flashing during partial playback. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-element-rendering-react` and verify with `npx nx e2e cockpit-render-element-rendering-react`.
110+
111+
The private React render entry also accepts an optional `ReactRenderFunctions` host map for authored `$computed` expressions. `RenderComputedFunction` receives owned recursively frozen arguments and synchronously returns `RenderValue`; calculations must be pure and can run repeatedly during React rendering. Plain or null-prototype maps admit own callable data entries, including non-enumerable entries, and reject accessors, symbols and non-callable values. Results retain the existing plain-data ownership guards. This adds no actions, watchers, state ownership or asynchronous effect execution, and raw specs still cannot contain executable callbacks.

‎apps/website/public/CLAUDE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,5 @@ Repeat Loops has an experimental local React preview at https://threadplane.ai/d
107107
Component Registry has an experimental local React preview at https://threadplane.ai/docs/render/guides/registry?frontend=react. The host supplies stable read-only component maps to installed native RenderSpec. Badge display chooses Registered, Omitted or an authored Fallback; fallback receives resolved props, element key and loading, while container views display native children exactly once. Choice survives partial playback, seek, Reset and sample selection without modifying JSON; reload restores Registered. Complete admission permits only four authored literal types. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-registry-react` and verify with `npx nx e2e cockpit-render-registry-react`.
108108

109109
Element Rendering has an experimental local React preview at https://threadplane.ai/docs/render/api/render-spec-component?frontend=react. Installed native RenderSpec owns ordered children, nested readonly views and visibility from explicit caller-owned boolean state. Show detail survives playback, seek, Reset and sample changes without modifying JSON; reload shows detail. The bounded projection withholds the conditional node until its exact /showDetail visibility path is available, preventing hidden text from flashing during partial playback. No backend runtime, model request or execution configuration is used. Build with `npx nx build cockpit-render-element-rendering-react` and verify with `npx nx e2e cockpit-render-element-rendering-react`.
110+
111+
The private React render entry also accepts an optional `ReactRenderFunctions` host map for authored `$computed` expressions. `RenderComputedFunction` receives owned recursively frozen arguments and synchronously returns `RenderValue`; calculations must be pure and can run repeatedly during React rendering. Plain or null-prototype maps admit own callable data entries, including non-enumerable entries, and reject accessors, symbols and non-callable values. Results retain the existing plain-data ownership guards. This adds no actions, watchers, state ownership or asynchronous effect execution, and raw specs still cannot contain executable callbacks.

‎fixtures/react-parity/runtime/render-runtime.mjs‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,75 @@ assert.equal(
5454
renderToStaticMarkup(createElement(RenderSpec, { spec: null, registry })),
5555
''
5656
);
57+
const produced = { list: [{ value: 42 }] };
58+
const functions = Object.create(null);
59+
Object.defineProperty(functions, 'inner', { value: () => produced });
60+
functions.outer = (args) => {
61+
assert.equal(Object.isFrozen(args), true);
62+
assert.equal(Object.isFrozen(args.value), true);
63+
assert.equal(Object.isFrozen(args.value.list), true);
64+
assert.notEqual(args.value, produced);
65+
return String(args.value.list[0].value);
66+
};
67+
functions.rowLabel = (args) => `${args.value}/${args.index}`;
68+
functions.constructor = () => 'own constructor';
69+
const computed = {
70+
root: 'root',
71+
elements: {
72+
root: { type: 'Box', props: {}, children: ['value', 'rows', 'named'] },
73+
value: {
74+
type: 'Text',
75+
props: {
76+
text: { $computed: 'outer', args: { value: { $computed: 'inner' } } },
77+
},
78+
},
79+
rows: {
80+
type: 'Box',
81+
props: {},
82+
children: ['row'],
83+
repeat: { statePath: '/rows', key: 'id' },
84+
},
85+
row: {
86+
type: 'Text',
87+
props: {
88+
text: {
89+
$computed: 'rowLabel',
90+
args: { value: { $item: 'text' }, index: { $index: true } },
91+
},
92+
},
93+
},
94+
named: { type: 'Text', props: { text: { $computed: 'constructor' } } },
95+
},
96+
};
97+
const computedHtml = renderToStaticMarkup(
98+
createElement(RenderSpec, { spec: computed, registry, state, functions })
99+
);
100+
assert.match(computedHtml, /<p>42<\/p>/);
101+
assert.match(computedHtml, /<p>A\/0<\/p><p>B\/1<\/p>/);
102+
assert.match(computedHtml, /<p>own constructor<\/p>/);
103+
assert.equal(Object.isFrozen(produced), false);
104+
assert.equal(Object.isFrozen(produced.list), false);
105+
for (const label of [
106+
() => Promise.resolve('later'),
107+
() => new Date(0),
108+
() => Infinity,
109+
]) {
110+
assert.throws(
111+
() =>
112+
renderToStaticMarkup(
113+
createElement(RenderSpec, {
114+
spec: {
115+
root: 'text',
116+
elements: {
117+
text: { type: 'Text', props: { text: { $computed: 'label' } } },
118+
},
119+
},
120+
registry,
121+
functions: { label },
122+
})
123+
),
124+
TypeError
125+
);
126+
}
57127
console.log('Installed read-only RenderSpec runtime contracts passed.');
128+
console.log('Installed pure computed RenderSpec contracts passed.');

‎fixtures/react-parity/runtime/render-types.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import {
44
type RenderSpecData,
55
type RenderViewProps,
66
type ReactRenderRegistry,
7+
type ReactRenderFunctions,
8+
type RenderComputedFunction,
9+
type RenderValue,
710
} from '@threadplane/react/render';
811
const registry: ReactRenderRegistry = {
912
Text: ({ props }) => <p>{String(props['text'])}</p>,
@@ -15,6 +18,21 @@ const spec = {
1518
export const view = (
1619
<RenderSpec spec={spec} registry={registry} state={{ count: 1 } as const} />
1720
);
21+
const functions: ReactRenderFunctions = {
22+
uppercase: ({ value }) => String(value).toUpperCase(),
23+
};
24+
export const computedView = (
25+
<RenderSpec spec={spec} registry={registry} functions={functions} />
26+
);
27+
export function computedContracts(args: Readonly<Record<string, RenderValue>>) {
28+
// @ts-expect-error installed callback arguments are readonly
29+
args['changed'] = 1;
30+
// @ts-expect-error installed pure functions cannot be asynchronous
31+
const asynchronous: RenderComputedFunction = async () => 'later';
32+
// @ts-expect-error installed result values cannot be executable callbacks
33+
const executable: RenderComputedFunction = () => () => 1;
34+
return { asynchronous, executable };
35+
}
1836
export function readonlyContracts(
1937
data: RenderSpecData,
2038
element: RenderElementData,

‎libs/react/README.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,42 @@ resolvers. Repeat containers render their children per array item, with `$item`,
292292
key preserves mounted identity across reorder; absent or unsupported keys use the
293293
item index. String, number and index identities have separate namespaces.
294294

295+
Supply an optional `functions` map for authored `$computed` expressions:
296+
297+
```tsx
298+
import type { ReactRenderFunctions } from '@threadplane/react/render';
299+
300+
const functions: ReactRenderFunctions = {
301+
uppercase: ({ value }) => String(value).toUpperCase(),
302+
};
303+
const computedSpec = {
304+
root: 'title',
305+
elements: {
306+
title: {
307+
type: 'Title',
308+
props: { text: { $computed: 'uppercase', args: { value: { $state: '/title' } } } },
309+
},
310+
},
311+
} as const;
312+
<RenderSpec spec={computedSpec} state={{ title: 'Trip recap' }} registry={registry} functions={functions} />;
313+
```
314+
315+
`RenderComputedFunction` receives an owned, recursively frozen argument record
316+
and synchronously returns `RenderValue`. Literal, state, item, index and nested
317+
computed arguments use the existing resolver. Function results cross the same
318+
plain-data ownership boundary as other resolved values. Promises, executable
319+
callbacks and non-plain results are rejected. Calculations must be pure: React
320+
can evaluate them repeatedly during rendering, and no call count, caching or
321+
effect behavior is promised. Callback errors propagate as render errors.
322+
323+
Function maps must be plain records or null-prototype records. Every own
324+
string-keyed callable data property is registered, including non-enumerable
325+
entries; inherited names are unavailable. Accessors, symbol keys and non-callable
326+
entries are rejected without invoking getters. The caller's map and data remain
327+
unfrozen and unmodified. Unknown function names resolve to `undefined` with the
328+
upstream warning behavior. This optional host map does not permit executable
329+
callbacks in raw specs or add actions, watchers or state ownership.
330+
295331
State and resolved values must be finite, acyclic plain data. Functions, symbols,
296332
accessors and non-plain objects are rejected. Raw props also reject `undefined`
297333
and own `__proto__` keys because the upstream raw-prop resolver cannot preserve

0 commit comments

Comments
 (0)