Skip to content

Commit e3d7094

Browse files
Merge pull request #78 from RtlZeroMemory/vnode-factory-audit
Audit ui.* VNode factories and harden interactive prop validation
2 parents 4acee6f + ae17eb4 commit e3d7094

7 files changed

Lines changed: 1623 additions & 62 deletions

File tree

docs/widgets/index.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,21 @@ ui.column({}, [
208208

209209
Falsy values (`false`, `null`, `undefined`) are filtered from children.
210210

211+
### VNode Factory Guarantees
212+
213+
`ui.*` factories are contract-tested for deterministic VNode creation.
214+
215+
- Factories that expose a `key` prop forward it to the resulting VNode for reconciliation.
216+
- Container-style child arrays filter `null`, `false`, and `undefined` values.
217+
- Nested child arrays are flattened before VNode children are stored.
218+
- Interactive widgets validate required runtime props before layout:
219+
- `button`: non-empty `id`, `label`
220+
- `input`: non-empty `id`, `value`
221+
- `select`: non-empty `id`, `value`, `options` array
222+
- `slider`: non-empty `id`, finite numeric range with `min <= max`, `step > 0`
223+
- `checkbox`: non-empty `id`, boolean `checked`
224+
- `radioGroup`: non-empty `id`, `value`, non-empty `options`
225+
211226
### Dynamic Lists
212227

213228
When rendering lists, provide a `key` prop for efficient reconciliation:
@@ -244,4 +259,13 @@ All focusable widgets can be clicked with the mouse to receive focus. Scrollable
244259

245260
## API Reference
246261

262+
### Baseline Lock
263+
264+
- Timestamp: `2026-02-18T11:26:33Z`
265+
- Base commit: `a441bba78ddc99ece4eb76965ce36c0aec9225fe`
266+
- Branch: `vnode-factory-audit`
267+
- Node: `v20.19.5`
268+
- npm: `10.8.2`
269+
- Baseline tests: `2488` passing
270+
247271
For complete type definitions, see the [API Reference](../api.md).

packages/core/src/layout/kinds/leaf.ts

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import type { Axis, Size } from "../types.js";
1212
import type { LayoutResult } from "../validateProps.js";
1313
import {
1414
validateButtonProps,
15+
validateCheckboxProps,
1516
validateInputProps,
17+
validateRadioGroupProps,
18+
validateSelectProps,
19+
validateSliderProps,
1620
validateSpacerProps,
1721
validateTextProps,
1822
} from "../validateProps.js";
@@ -56,29 +60,20 @@ export function measureLeaf(
5660
return ok({ w, h });
5761
}
5862
case "slider": {
59-
const props = vnode.props as {
60-
value?: number;
61-
min?: number;
62-
max?: number;
63-
step?: number;
64-
width?: number;
65-
label?: string;
66-
showValue?: boolean;
67-
};
63+
const propsRes = validateSliderProps(vnode.props);
64+
if (!propsRes.ok) return propsRes;
65+
const props = propsRes.value;
6866
const normalized = normalizeSliderState({
69-
value: props.value ?? Number.NaN,
67+
value: props.value,
7068
min: props.min,
7169
max: props.max,
7270
step: props.step,
7371
});
7472
const labelText =
75-
typeof props.label === "string" && props.label.length > 0 ? `${props.label} ` : "";
76-
const showValue = props.showValue !== false;
73+
props.label !== undefined && props.label.length > 0 ? `${props.label} ` : "";
74+
const showValue = props.showValue;
7775
const valueText = showValue ? ` ${formatSliderValue(normalized.value, normalized.step)}` : "";
78-
const explicitTrack =
79-
typeof props.width === "number" && Number.isFinite(props.width) && props.width > 0
80-
? Math.trunc(props.width)
81-
: undefined;
76+
const explicitTrack = props.width !== undefined && props.width > 0 ? props.width : undefined;
8277
const trackWidth = explicitTrack ?? DEFAULT_SLIDER_TRACK_WIDTH;
8378
const intrinsicW = measureTextCells(labelText) + 2 + trackWidth + measureTextCells(valueText);
8479
return ok({ w: Math.min(maxW, intrinsicW), h: Math.min(maxH, 1) });
@@ -290,32 +285,40 @@ export function measureLeaf(
290285
return ok({ w: Math.min(maxW, totalW), h: Math.min(maxH, 1) });
291286
}
292287
case "select": {
293-
// Select dropdowns show selected value in a fixed-width area.
294-
const textW = measureTextCells(vnode.props.placeholder ?? "Select...");
288+
const propsRes = validateSelectProps(vnode.props);
289+
if (!propsRes.ok) return propsRes;
290+
const selected = propsRes.value.options.find(
291+
(option) => option.value === propsRes.value.value,
292+
);
293+
const displayText = selected?.label ?? propsRes.value.placeholder ?? "Select…";
294+
const textW = measureTextCells(displayText);
295295
return ok({ w: Math.min(maxW, textW + 4), h: Math.min(maxH, 1) });
296296
}
297297
case "checkbox": {
298-
// Checkbox: [x] + optional label
299-
const labelW = vnode.props.label ? measureTextCells(vnode.props.label) + 1 : 0;
298+
const propsRes = validateCheckboxProps(vnode.props);
299+
if (!propsRes.ok) return propsRes;
300+
const labelW =
301+
propsRes.value.label === undefined ? 0 : measureTextCells(propsRes.value.label) + 1;
300302
return ok({ w: Math.min(maxW, 3 + labelW), h: Math.min(maxH, 1) });
301303
}
302304
case "radioGroup": {
303-
// Radio group: stack of options
304-
const direction = vnode.props.direction ?? "vertical";
305+
const propsRes = validateRadioGroupProps(vnode.props);
306+
if (!propsRes.ok) return propsRes;
307+
const direction = propsRes.value.direction;
305308
if (direction === "horizontal") {
306309
let totalW = 0;
307-
for (const opt of vnode.props.options) {
310+
for (const opt of propsRes.value.options) {
308311
totalW += measureTextCells(opt.label) + 5; // "(x) label "
309312
}
310313
return ok({ w: Math.min(maxW, totalW), h: Math.min(maxH, 1) });
311314
}
312315
// Vertical: max width of options, height = num options
313316
let maxOptW = 0;
314-
for (const opt of vnode.props.options) {
317+
for (const opt of propsRes.value.options) {
315318
const w = measureTextCells(opt.label) + 4; // "(x) label"
316319
if (w > maxOptW) maxOptW = w;
317320
}
318-
return ok({ w: Math.min(maxW, maxOptW), h: Math.min(maxH, vnode.props.options.length) });
321+
return ok({ w: Math.min(maxW, maxOptW), h: Math.min(maxH, propsRes.value.options.length) });
319322
}
320323
default:
321324
return {
@@ -425,34 +428,36 @@ export function layoutLeafKind(
425428
});
426429
}
427430
case "select": {
428-
// Select dropdown is an interactive leaf widget.
431+
const propsRes = validateSelectProps(vnode.props);
432+
if (!propsRes.ok) return propsRes;
429433
return ok({
430434
vnode,
431435
rect: { x, y, w: rectW, h: Math.min(rectH, 1) },
432436
children: Object.freeze([]),
433437
});
434438
}
435439
case "slider": {
436-
// Slider is an interactive leaf widget.
440+
const propsRes = validateSliderProps(vnode.props);
441+
if (!propsRes.ok) return propsRes;
437442
return ok({
438443
vnode,
439444
rect: { x, y, w: rectW, h: Math.min(rectH, 1) },
440445
children: Object.freeze([]),
441446
});
442447
}
443448
case "checkbox": {
444-
// Checkbox is an interactive leaf widget.
449+
const propsRes = validateCheckboxProps(vnode.props);
450+
if (!propsRes.ok) return propsRes;
445451
return ok({
446452
vnode,
447453
rect: { x, y, w: rectW, h: Math.min(rectH, 1) },
448454
children: Object.freeze([]),
449455
});
450456
}
451457
case "radioGroup": {
452-
// Radio group layout depends on direction.
453-
const direction = vnode.props.direction ?? "vertical";
454-
const optionCount = Array.isArray(vnode.props.options) ? vnode.props.options.length : 0;
455-
const naturalH = direction === "vertical" ? optionCount : 1;
458+
const propsRes = validateRadioGroupProps(vnode.props);
459+
if (!propsRes.ok) return propsRes;
460+
const naturalH = propsRes.value.direction === "vertical" ? propsRes.value.options.length : 1;
456461
return ok({
457462
vnode,
458463
rect: { x, y, w: rectW, h: Math.min(rectH, naturalH) },

0 commit comments

Comments
 (0)