Web components
Custom elements are the platform’s interop standard, the one component format every framework, CMS and plain HTML page can consume. In v6 you do not write one. You derive it:
import { createWebComponent } from 'lemonadejs';
import Switch from '@lemonadejs/switch';
createWebComponent(Switch); // defines <lm-switch>, everything derived
<lm-switch label="Dark mode" color="red" checked></lm-switch>
Zero options, because the contract already says everything an element needs to know: which attributes to observe, which properties to expose, which events to dispatch, what the value is.
Background
Writing a custom element by hand means maintaining several parallel
pieces, and every piece drifts independently: the observedAttributes
list must be kept in sync with the properties, attributes are strings so
every typed property needs its own parsing, attribute→property and
property→render paths are wired one by one, events are dispatched
manually, and teardown in disconnectedCallback is on you. The element
is a second description of an interface the component already has,
written by hand, in a different dialect.
There is also a lifecycle issue the v5 engine ran into: a host may remove an element from the DOM without calling any unmount API. The v5 policy was keep-alive: survive removal in case the host re-attaches. When a host removes DOM freely, every removed element left a live, subscribed component tree behind. Memory only grew.
Derived element surface
createWebComponent(C) derives the entire element surface from the
contract, so there is no second description to drift:
- Observed attributes = declared props, live. Set an attribute
before mount, after mount, from devtools, the component updates, and
the string is coerced to the declared type (
pagination="4"arrives as the number4; booleans use HTML presence semantics). Attributes set before the element is even defined are replayed at connect. - Element properties = declared props. Every contract prop is a real
accessor on the element:
el.label = 'x'writes the live state,el.labelreads it back. This is the core-of-HTML surface, the way rich values (arrays, objects, callbacks) travel to custom elements, since attributes can only carry strings. - Declared events dispatch real CustomEvents on the host, bubbling
and composed, with the callback’s first argument as
event.detail. A contractonchangebecomes achangeevent: Vue’s@change, Angular’s(change)and plainaddEventListenerall work. bindis the element’svalue. The two-way state maps to anel.valueproperty and an observedvalueattribute, and user-initiated changes dispatch achangeCustomEvent even when the contract declares noonchange: form-control semantics, derived.- The tag is
lm-+ the contract name ({ prefix: 'app' }for<app-switch>). Defining the same tag twice is a no-op that returns the tag name; registration is idempotent.
Destroy by default. A removed element unmounts its instance, so the
direct reversal of the v5 keep-alive policy and the reasoning is in the
source: hosts remove elements without calling unmount(), and a
kept-alive instance subscribed to a store pins its whole
tree forever. One subtlety is handled for you: removal gets a
one-microtask grace period, so a same-tick move (reparenting, which
the DOM implements as remove + insert) survives with state intact, while
a real removal unmounts. Reconnecting later remounts fresh from the
element’s preserved attribute states. The full memory story, heap
snapshots included, is the Destroy chapter.
Components without a contract keep the legacy form:
createWebComponent('name', fn) passes connect-time attributes once as
string props, with rich values via el.props = before connecting.
It works, but the live surface above is the reason to publish.
The element is one of three deployments of the same block, derived from the same contract that types it, documents it and proves it.
In practice
Every Studio block playground registers its element with the one line:
createWebComponent(Switch) sits at the top of the switch demo next to
by-value usage of the same import. The behavior is pinned in the library
suite (tests/deployments.test.ts), against the real datagrid:
const tag = createWebComponent(Datagrid); // 'lm-datagrid'
const el = document.createElement(tag);
el.data = rows; // rich values as element PROPERTIES
el.columns = columns;
el.pagination = 4;
document.body.appendChild(el);
// → 4 rendered rows
el.pagination = 2; // contract-derived accessor: LIVE
// → 2 rendered rows, no remount
el.remove(); // destroy-by-default
// one microtask later: instance unmounted, nothing leaked
And because object slot values become element properties inside lemonade templates, the element composes back into its own ecosystem without serialization:
html`<lm-datagrid data="${rows}" columns="${columns}" pagination="${3}"></lm-datagrid>`
Reference
import { createWebComponent } from 'lemonadejs';
createWebComponent(C) // contract required → 'lm-<name>'
createWebComponent(C, { prefix: 'app' }) // → 'app-<name>'
createWebComponent('name', fn) // no contract: connect-time string
// props + el.props for rich values
// returns the tag name; idempotent if the tag is already defined
Derived from the contract: observed attributes (declared props,
lowercase, coerced, plus value when bind is declared); property
accessors per prop; el.value for bind; CustomEvents (bubbling,
composed, first callback argument as detail) for declared events, plus
change for bind. Lifecycle: removal unmounts after a one-microtask
grace (same-tick moves survive); reconnect remounts fresh; el.unmount()
forces disposal synchronously. For composition inside lemonade apps,
prefer <${C} /> or a registered <C />; see
Deployments.