Solar lets you compose components by passing rendered component vnodes as slot props. Rather than treating children as untyped markup, the slot system enforces which component type can fill each slot at runtime — pass the wrong component (or a plain DOM element) and Solar throws a structured ContractError immediately. This makes composition rules explicit and machine-verifiable, which is exactly what AI agents need to generate trees that are correct on the first attempt.
Basic composition
The simplest form of composition passes a child component directly as a prop value. No slot typing is needed when you just want to nest one component inside another.
import { createElement, defineComponent, registry } from './solar/index.js'
import Button from './components/Button.js'
const Toolbar = defineComponent({
name: 'Toolbar',
props: {
title: { type: 'string', required: true },
saveButton: { type: 'any', required: true },
},
render({ title, saveButton }) {
return createElement('div', { class: 'toolbar' },
createElement('span', {}, title),
saveButton,
)
},
})
registry.register(Toolbar)
// Usage — pass the rendered Button vnode as a prop
Toolbar({
title: 'Document Editor',
saveButton: Button({ label: 'Save', onClick: handleSave }),
})
Use type: 'any' when the slot can accept any vnode. Switch to type: 'slot' with an accepts field when you want to enforce a specific component type.
Typed slot composition
Declare a slot prop with type: 'slot' and accepts: 'ComponentName' to restrict what can fill the slot. Solar validates the vnode’s _source field at the call site — only vnodes produced by the named component are accepted.
import { createElement, defineComponent, registry } from './solar/index.js'
import Button from './components/Button.js'
const Card = defineComponent({
name: 'Card',
props: {
title: { type: 'string', required: true },
body: { type: 'string', required: true },
action: { type: 'slot', accepts: 'Button', required: true },
},
render({ title, body, action }) {
return createElement('div', { class: 'card' },
createElement('h3', { class: 'card__title' }, title),
createElement('p', { class: 'card__body' }, body),
createElement('div', { class: 'card__action' }, action),
)
},
})
registry.register(Card)
// Valid — action is a vnode produced by Button
Card({
title: 'User Profile',
body: 'Manage your account settings.',
action: Button({ label: 'Edit', onClick: handleEdit }),
})
The accepts field in the prop schema communicates slot constraints to both the runtime validator and any agent reading the registry manifest — without any additional documentation.
Compact composition with h()
The h() array notation builds the same vnode tree with significantly less syntax. Component names resolve automatically from the registry, so 'Card' dispatches to the registered Card component with full prop validation.
import { h } from './solar/index.js'
h(['Card', {
title: 'User Profile',
body: 'Manage your account settings.',
action: h(['Button', { label: 'Edit', onClick: handleEdit }]),
}])
Slot props work the same way — h(['Button', { ... }]) produces a vnode with _source: 'Button', which passes the accepts: 'Button' validation on the Card component.
Unregistered names in h() arrays fall through to createElement() as plain DOM elements. h(['div', {}, 'text']) produces a <div> node, not a component. Only names that appear in the registry are dispatched to components.
Nesting components
Build deeper trees by nesting h() calls. Each level is validated independently as the tree is constructed, so errors surface at the exact point of the bad prop — not after the whole tree renders.
import { h } from './solar/index.js'
h(['div', { class: 'page' },
['Card', {
title: 'Profile',
body: 'Update your personal details.',
action: h(['Button', { label: 'Save', onClick: onSave }]),
}],
['Card', {
title: 'Settings',
body: 'Manage notifications and preferences.',
action: h(['Button', { label: 'Edit', onClick: onEdit, variant: 'secondary' }]),
}],
])
Each Card call validates its own action slot independently. If either Button receives a wrong prop type, the ContractError names the exact component and prop — "Button: prop \"label\": expected string, got number" — so you never have to guess which node in the tree caused the failure.
When slot validation fails
Passing a plain createElement() call — or a vnode from the wrong component — to a typed slot throws a ContractError with a precise message and a fix field.
import { createElement } from './solar/index.js'
import Card from './components/Card.js'
// Throws — a raw DOM element is not a Button vnode
try {
Card({
title: 'Bad card',
body: 'This will throw.',
action: createElement('button', { class: 'btn' }, 'Raw button'),
})
} catch (err) {
if (err.name === 'ContractError') {
console.log(err.toJSON())
// {
// "error": "ContractError",
// "component": "Card",
// "prop": "action",
// "expected": "slot(Button)",
// "received": "vnode with no _source (plain createElement)",
// "fix": "Pass a vnode produced by the Button component",
// "message": "Card: prop \"action\": expected slot(Button), got vnode with no _source (plain createElement)"
// }
}
}
The fix field tells you — and any AI agent — exactly what to change: replace the raw createElement('button', ...) call with Button({ label: '...', onClick: ... }). Feed the toJSON() output back to your agent and it can self-correct without further explanation.
Slot validation checks _source — the internal tag Solar sets when a component renders a vnode. A vnode produced by createElement() directly has no _source, so it always fails a typed slot check, even if the tag name looks right. Always call the component function to produce slot values.