wrapCompound
wrapCompound creates a stateful XMLUI renderer that manages the full value lifecycle: initialValue parsing, local state, external value syncing, and automatic updateState calls on change. It extends everything wrapComponent does -- prop forwarding, event auto-tracing, resource URLs -- and adds state management via a StateWrapper.
When to use it: Components with initialValue and didChange -- Slider, TextBox, Select, DatePicker, ColorPicker, FileInput. If the component has a value that changes, use wrapCompound.
Architecture
SETUP (once, at registration time)
===================================
SliderWrapped.tsx wrapCompound.tsx
──────────────── ────────────────
SliderWMd = createMetadata(...) mergeWithMetadata(metadata, config)
│ │
▼ ▼
wrapCompound( auto-classifies props/events
"SliderW", ──────────► (same as wrapComponent)
SliderRender, │
SliderWMd, ▼
{ parseInitialValue, builds StateWrapper:
formatExternalValue, CallbackSync (outer, not memoized)
rename, callbacks } └─► MemoizedInner (memoized)
) └─► SliderRender
│ │
▼ ▼
sliderWComponentRenderer createComponentRenderer(type, metadata, renderFn)
│ │
▼ ▼
registered with XMLUI engine registers renderFn with the engine
RENDER (every time engine evaluates <SliderW .../>)
===================================================
XMLUI markup engine wrapCompound's renderFn
──────────── ────── ──────────────────────
<SliderW parses node, receives context
minValue="0" ──► creates context ──► │
maxValue="100" ▼
initialValue="50" builds props:
onDidChange= ┌────────────────────────────────┐
"{(v)=>x=v}" │ __initialValue ← extractValue │
/> │ __value ← engine state │
│ __updateState ← engine │
│ __onDidChange = (newVal) => { │
│ pushTrace() │
│ updateState({value:newVal}) │
│ handler(newVal) │
│ pushXsLog(value:change ...) │
│ popTrace() │
│ } │
│ min, max, step ← extractValue │
└────────────────────────────────┘
│
▼
<StateWrapper {...props} />
INSIDE StateWrapper (React component tree)
===========================================
CallbackSync (outer — renders every time, cheap)
┌─────────────────────────────────────────���───────────────────┐
│ Receives __onDidChange, __updateState, etc. from renderer. │
│ Writes them into a stable ref (callbackRef). │
│ No DOM work — just keeps the ref current. │
└────────────────────────────┬──────────────────────���─────────┘
│ passes callbackRef + value props
▼
MemoizedInner (inner — only re-renders when value/className change)
┌─────────────────────────────────────────────────────────────┐
│ Owns localValue state (React.useState). │
│ On mount: parseInitialValue("50") → [50], tell engine. │
│ On external change: formatExternalValue, setLocalValue. │
│ Provides stable onChange and registerApi via useCallback. │
│ │
│ onChange(newVal): │
│ setLocalValue(newVal) ← update React state │
│ callbackRef.__onDidChange(53) ← triggers trace + engine │
└────────────────────────────┬────────────────────────────────┘
│ passes value, onChange, registerApi
▼
SliderRender (plain React component — no XMLUI imports)
┌─────────────────────────────────────────────────────────────┐
│ Receives: value=[50], onChange, registerApi, min, max, ... │
│ Renders: Radix UI <Slider.Root> with Radix props. │
│ On user drag: calls onChange(53). │
│ On mount: calls registerApi({ focus, setValue, value }). │
└─────────────────────────────────────────────────────────────┘
Why mergeWithMetadata enables progressive enhancement
Both wrapComponent and wrapCompound call mergeWithMetadata at setup time. It reads valueType annotations and event declarations from the metadata and auto-classifies them -- so the wrapper config only needs to specify exceptions. Here's what SliderW's config would look like without it:
wrapCompound(COMP, SliderRender, SliderWMd, {
booleans: ["enabled", "readOnly", "required", "autoFocus", "showValues"],
numbers: ["minValue", "maxValue", "step", "minStepsBetweenThumbs"],
events: {
didChange: "onDidChange",
gotFocus: "onGotFocus",
lostFocus: "onLostFocus",
},
callbacks: { valueFormat: "valueFormat" },
rename: { minValue: "min", maxValue: "max" },
parseInitialValue: ...,
formatExternalValue: ...,
})Instead, mergeWithMetadata infers booleans, numbers, and events from the metadata, so the actual config is just:
wrapCompound(COMP, SliderRender, SliderWMd, {
callbacks: { valueFormat: "valueFormat" },
rename: { minValue: "min", maxValue: "max" },
parseInitialValue: ...,
formatExternalValue: ...,
})No booleans, no numbers, no events -- because the metadata already declares those types. This makes progressive enhancement incremental: add valueType: "boolean" to a prop's metadata and mergeWithMetadata picks it up automatically. The wrapper config never grows -- only the metadata does.
What you get beyond wrapComponent:
- Value lifecycle. The wrapper parses
initialValue, maintainslocalValuestate in React, syncs external value changes, and callsupdateStateautomatically when the value changes. - parseInitialValue / formatExternalValue. Optional hooks to transform values at the boundaries -- parse a string
"[10,90]"into an array, clamp to min/max, normalize date formats. - StateWrapper (CallbackSync + MemoizedInner). A two-component split that solves React.memo's stale-closure problem. The outer component (CallbackSync) always renders but does no DOM work -- it just updates a ref. The inner component (MemoizedInner) only re-renders when non-function props change. This keeps wrapped components performant even when XMLUI re-evaluates frequently.
- Clean render component interface. The React render component receives
value,onChange, andregisterApi-- no XMLUI imports needed.
Example: SliderW
SliderW wraps a Radix UI slider. It uses parseInitialValue to parse string values and clamp to min/max, formatExternalValue to validate incoming values, rename to map XMLUI's minValue/maxValue to Radix's min/max, and callbacks for a custom valueFormat function.
// SliderWrapped.tsx — exports sliderWComponentRenderer (registered with engine)
import { SliderRender } from "./SliderRender"; // plain React component
import {
wrapCompound, createMetadata, d,
dDidChange, dEnabled, dGotFocus, dInitialValue,
dLostFocus, dReadonly, dRequired,
} from "xmlui";
const COMP = "SliderW";
// Metadata → consumed by wrapCompound, docs site, and IDE plugins.
export const SliderWMd = createMetadata({
status: "stable",
description: "SliderW — wrapCompound version of Slider.",
props: {
initialValue: dInitialValue(),
minValue: { valueType: "number", defaultValue: 0 },
maxValue: { valueType: "number", defaultValue: 10 },
step: { valueType: "number", defaultValue: 1 },
enabled: dEnabled(),
readOnly: dReadonly(),
showValues: { valueType: "boolean", defaultValue: true },
valueFormat: { valueType: "any" },
},
events: {
didChange: dDidChange(COMP),
gotFocus: dGotFocus(COMP),
lostFocus: dLostFocus(COMP),
},
});
export const sliderWComponentRenderer = wrapCompound(
COMP,
SliderRender,
SliderWMd,
{
callbacks: { valueFormat: "valueFormat" },
rename: { minValue: "min", maxValue: "max" },
parseInitialValue: (raw, props) => {
const min = Number(props.min) || 0;
const max = Number(props.max) || 10;
let val = raw;
if (typeof raw === "string") {
try { val = JSON.parse(raw); }
catch { val = parseFloat(raw); }
}
if (val == null || (typeof val === "number" && isNaN(val))) val = min;
const arr = Array.isArray(val) ? val : [val];
return arr.map((v) =>
Math.min(max, Math.max(min, Number(v) || min)));
},
formatExternalValue: (value, props) => {
const min = Number(props.min) || 0;
const max = Number(props.max) || 10;
const arr = Array.isArray(value)
? value : value != null ? [value] : [min];
return arr.map((v) =>
Math.min(max, Math.max(min, Number(v) || min)));
},
},
);