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 number 4; 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.label reads 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 contract onchange becomes a change event: Vue’s @change, Angular’s (change) and plain addEventListener all work.
  • bind is the element’s value. The two-way state maps to an el.value property and an observed value attribute, and user-initiated changes dispatch a change CustomEvent even when the contract declares no onchange: 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.