Gotchas

Things that trip developers up when learning mx.js.

Positional Rendering

mx reconciles children left-to-right by tag name, not by key. If you reorder a list, the DOM nodes stay in place and their content updates - but user-entered state (typed input values, checkbox ticks) stays at the old position.

// ✗ User input stays at position 0 after sort
container.render(...items.map(i => mx.div(
    mx.span(i.name),
    mx.input({ placeholder: 'notes...' })  // ← this input doesn't move
)));

// ✓ Use dom() + Map for identity-stable rows
let rows = new Map;
for (let i of items) {
    let el = rows.get(i.id) ?? rows.set(i.id, dom.div()).get(i.id);
    el.render(mx.span(i.name), mx.input({ placeholder: 'notes...' }));
}
container.render(...items.map(i => rows.get(i.id)));  // nodes physically move

When render encounters a real DOM node, it matches by identity (===), not tag name. The node moves to the correct position and all internal state travels with it.

Positional mx() in Front of an Identity Node

The two matching rules - mx() descriptions reconcile positionally by tag name, real dom() nodes match by identity - collide when you interleave them in one render(). An mx() node placed in front of a dom() node of the same tag is processed first at that position, so it reconciles into the identity node and consumes it:

let row = dom.div(dom.input());   // a real node you hold a handle to
root.render(row);                  // <div><input></div>

// ✗ the header is also a <div>, sitting where row is:
root.render(mx.div('Header'), row);
// mx.div reconciles INTO row -> row becomes <div>Header</div>, its <input> destroyed.
// Result: ONE child; the input and any typed state are silently gone.

Give the interleaved node a distinct tag

A tag that differs from the identity node sidesteps the collision: the mismatch makes mx insert a fresh node instead of reusing the identity one. A different HTML tag, a custom component tag, or making the sibling a dom() node all work:

root.render(mx.h2('Header'), row);    // ✓ distinct tag -> [h2, row], input kept
root.render(mx.myHeader(), row);      // ✓ a component has its own tag, never collides
root.render(dom.div('Header'), row);  // ✓ dom() header matches by identity too

Or stay in one mode: an all-dom() list reordered by identity, or an all-mx() tree reconciled positionally. The hazard lives only in the interleaved-same-tag seam. The seam does not cut the other way: a dom() node the new list no longer names is removed as soon as placement walks past it, so a later positional mx() description gets a fresh element rather than the dropped one. One mode per list.

Positionally-Reused Components Keep Their Setup

A component that does one-time work at creation - starts a timer, adds a listener, fetches data keyed to an id, instantiates a widget - assumes it maps to one logical item for its whole life. Inside an mx.* list that reconciles positionally by tag name, that assumption breaks: after a sort or insert, mx reuses the node at position 0 for whatever datum now sits there, but the node still carries the interval or subscription it set up for the old datum.

// ✗ Each row polls a device on creation. After a sort, row 0's poller
//   is still bound to the device it was built for - now under the wrong row.
define('device-row', {
    $({ device }) {
        this._poll ??= setInterval(() => refresh(device.id), 1000);
        return mx.span(device.name);
    }
});
container.render(...devices.map(d => mx.deviceRow({ device: d })));

// ✓ Build setup-heavy rows with dom() + a keyed Map, so each node
//   stays bound to its own datum across reorders.
let rows = this._rows ||= new Map;
for (let d of devices) {
    let el = rows.get(d.id) ?? rows.set(d.id, dom.deviceRow({ device: d })).get(d.id);
    el.$({ device: d });   // refresh this row's data without recreating it
}
container.render(...devices.map(d => rows.get(d.id)));

Rule of thumb: if a component's $ does anything at creation tied to a specific id - a subscription, an interval, an imperative widget - it must be identity-stable. Hold it in a keyed Map of dom() nodes, never a positionally-reconciled mx.* slot. Pure render-from-props rows are safe positionally; stateful ones are not. This is the same identity rule as Positional Rendering, sharpened for components that set up side effects.

Merged $state travels with the reused slot too, not only setup side effects: a row rendered with { id: 1, note: 'first' } and then, after the list shrinks from the front, with { id: 2 } still holds note: 'first' (see State Accumulation). Pass every key on every render, or key the rows.

State Accumulation

$state merges via Object.assign. Keys you don't pass persist:

// Render 1: set type
el.$({ type: 'text-field', content: [] });

// Render 2: change content, forget to clear type
el.$({ content: ['tag1', 'tag2'] });

// ✗ $state is now { type: 'text-field', content: ['tag1', 'tag2'] }
// The stale 'type' causes a text field AND tags to render

// ✓ Always send complete state when switching modes
el.$({ type: null, content: ['tag1', 'tag2'] });

The destructuring defaults in $({ type = null }) document what "neutral" means for each prop. Use those values when resetting.

This includes props that make $ throw - the merge happens before your function runs, so the offending key is already in $state and a prop-less re-render throws again. Re-render with the key explicitly reset.

Definition-Object Properties Are Shared By Reference

define(name, obj) stores obj once and does Object.assign(element, obj) on every element it creates. There is no clone. Every property you put on the definition object is copied by reference, so a mutable value - an object, an array, a Map, a Set - becomes one value shared by every instance on the page:

// ✗ Every <expand-row> shares ONE $state object and ONE items array
define('expand-row', {
    $state: { open: false },
    items: [],
    $({ open = false }) {
        return mx.span(open ? '▾' : '▸');
    }
});

let a = dom.expandRow({}), b = dom.expandRow({});
a.$state === b.$state    // true
a.items === b.items      // true

a.$({ open: true });
b.$state.open            // true - b never asked for this

Nothing crashes and nothing warns. The second row simply shows the first row's data the next time anything re-renders it, and a push into this.items from one instance appears in all of them. It looks like a reconciler bug; it is Object.assign doing exactly what it says.

Initialize state inside $, not on the definition object

Anything per-instance has to be created during $(), where this is the element:

// ✓ Per-instance: the ??= runs against this element's own $state
define('expand-row', {
    $({ open }) {
        this.$state.open ??= false;
        this._seen ||= new Set;          // private, per-instance
        return mx.span(this.$state.open ? '▾' : '▸');
    }
});

// ✓ Or just take it as a prop with a destructuring default
define('expand-row', {
    $({ open = false }) { return mx.span(open ? '▾' : '▸'); }
});

Immutable primitives on the definition object are fine - rowHeight: 48 is shared too, but sharing a number nobody reassigns is harmless. The rule is only about values you mutate.

The copy happens once, at element creation. Calling define() again with the same name replaces components[name] but leaves every existing host on the definition it was created with - new siblings get the new $, reused ones keep the old. To migrate a live host, Object.assign(host, components[name]) or replace it.

Getters and setters don't survive either

Object.assign reads each source property and writes a plain data property. An accessor on a definition object is therefore flattened into a one-time snapshot, computed at element-creation time with this bound to the definition object - not the element - and its setter is discarded entirely:

define('bad-accessor', {
    get tagUpper() { return String(this.localName).toUpperCase(); },
    set thing(v) { this._got = v; },
    $() { return mx.span('x'); }
});

let el = dom.badAccessor({});
el.tagUpper            // "UNDEFINED" - this.localName was read off the plain object
el.thing = 42;
el._got                // undefined - the setter is gone, 42 just overwrote a data property

Use a plain method and call it (el.tagUpper()), or compute the value inside $. Accessors that must live on the element need Object.defineProperty inside $.

Neither do prototype methods: Object.assign copies own enumerable properties only, so define(name, new SomeClass) leaves this.helper() undefined and the first render throws. Pass an object literal.

Event Listener Stacking

addEventListener with a new closure inside $() adds another listener on every re-render:

// ✗ 10 renders = 10 listeners
$() {
    this.addEventListener('click', () => doStuff());
}

// ✓ Property assignment overwrites safely
$() {
    this.onclick = () => doStuff();
}

// ✓ Or use a stable reference (addEventListener is idempotent for same fn)
let handler = () => doStuff();
$() {
    this.addEventListener('click', handler);
}

The element's attribute cache auto-cleans handlers set via render() attrs (onclick: fn), but addEventListener on this is outside that cache.

Custom Events Need addEventListener

onclick: fn works because the browser registers a listener when you assign a known handler property. For a custom event type, onping is not a known handler property - the assignment becomes a plain expando and never fires:

// ✗ Silent no-op - 'ping' is not a standard event type
mx.div({ onping: e => doStuff() }, ...)

// ✓ Custom events need addEventListener, armed once
$() {
    (this.$ev ||= {}).armed ||= (this.addEventListener('ping', e => doStuff()), 1);
}

This is platform behavior, not an mx choice: only spec-defined on* properties (onclick, oninput, ontoggle, ...) register listeners when assigned. The addEventListener fallback is one-time - guard it so it doesn't stack (see Event Listener Stacking).

Two ways to write that latch wrong

The guard has to store a truthy value, and it must not use this._ as its name:

// ✗ addEventListener returns undefined, so the flag is never set.
//   Three renders = three listeners; one dispatch fires the handler 3x.
(this._ ||= {}).armed || (this._.armed = this.addEventListener('ping', fn));

// ✗ this._ is also render()'s mx-description marker. An element carrying
//   a truthy _ is destructured as [tag, attrs, kids] when passed as a
//   real DOM child: TypeError: object is not iterable
root.render(mx.div(el));

// ✓ Comma operator returns 1, and $ev doesn't collide with the marker
(this.$ev ||= {}).armed ||= (this.addEventListener('ping', e => doStuff()), 1);

Any truthy sentinel and any non-_ property name will do - this.$ev, this._armed, a Set of registered types. The two rules are: never let the flag be undefined, and never name the holder _ (see Why ._ Marks an mx Description).

Children Render Before Parent Attributes

Inside render(), an element's children are reconciled before its own attributes are applied. This ordering is load-bearing - it's what makes .value on a <select> see its <option> children - but four consequences are observable:

  1. An event a child dispatches synchronously during its mount is missed by a parent handler from the same render description - the parent's onclick isn't attached yet. Dispatch asynchronously (setTimeout/queueMicrotask) or after mount.
  2. A child that reads a parent attribute or handler during $() sees a three-way split: one the new description dropped is already gone (null - removals are undone before the children reconcile), one it changed still shows the previous render's value, and one it added is not there yet (null, including everything on first mount). Pass data down through $state props, not by reading parent attributes.
  3. multiple and size on a <select> land after its options, so on first mount the options are selected as a single-select: only the last selected: true sticks, and a listbox (size > 1) with no selected option auto-selects option 0. A re-render fixes the multi-select; the listbox keeps option 0 until you describe '.selectedIndex': -1. Or hold the select as a dom() node created before its options: this._s ||= dom.select({ multiple: true }); this._s.render(...options); return [this._s].
  4. A real custom element (customElements.define) rendered from a description is inserted first, so its connectedCallback runs on an empty, attribute-less element and attributeChangedCallback fires afterwards. Read attributes in attributeChangedCallback or a queueMicrotask, or build the element with dom(), which applies attributes and children before you insert it - the parser-like order.

Pinned by tests in the parity suite - this is intended semantics, not a bug to be fixed.

render() Is Not Re-Entrant

Never re-render a host synchronously from inside a child's $ - this.parentElement.$(), this.$() inside your own $, or a bus emit whose listener re-renders an ancestor. The inner render walks the newer description, then the outer render resumes with its older description and finishes writing it, so the DOM ends up as a merge of both:

// ✗ Child re-renders the parent while the parent is mid-render.
//   The outer pass resumes with its stale description and wins.
define('kid', {
    $() {
        this.parentElement.$({ v: 2 });   // synchronous, inside $
        return mx.b('c1');
    }
});

// ✓ Defer it, or do it from an event handler after mount
define('kid', {
    $() {
        queueMicrotask(_ => this.parentElement.$({ v: 2 }));
        return mx.b('c1');
    }
});

Render-phase side effects that reach outside the component are the trigger. Route child-driven updates through a callback prop (see this.$({}) Is a Code Smell) or a microtask; both produce exactly the newest description.

Mutating Input Arrays

// ✗ Sorts the parent's array - side effect
$({ items = [] }) {
    items.sort((a, b) => a.name.localeCompare(b.name));
}

// ✓ Copy first
$({ items = [] }) {
    let sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
}

Props point to the parent's data. Mutating them changes state outside the component.

Interval Stacking

Every $() call runs your function again. If you set an interval without clearing the previous one, they stack:

// ✗ New interval every re-render
$({ isPlaying = false }) {
    if (isPlaying) {
        this._interval = setInterval(() => tick(), 1000);
    }
}

// ✓ Always clear first
$({ isPlaying = false }) {
    clearInterval(this._interval);
    if (isPlaying) {
        this._interval = setInterval(() => tick(), 1000);
    }
}

clearInterval at the top of $(), before any conditional logic.

Numbers Leak Into Render

render() treats numbers as text nodes - it creates a text node with that number's string value. This means 0 from .length doesn't get skipped like false or null would:

// ✗ When items is empty, renders a literal "0" text node
container.render(
    items.length && mx.div('Has items')
);

// ✓ Coerce to boolean - false is skipped by render()
container.render(
    !!items.length && mx.div('Has items')
);

This applies to any numeric value on the left side of &&. Booleans (false), null, and undefined are skipped by render(). Numbers are not - they're valid content.

// Same issue with any number
count && mx.span('Count: ' + count)     // ✗ renders "0" when count is 0
!!count && mx.span('Count: ' + count)   // ✓ skipped when count is 0

Rule of thumb: if the left side of && can be a number, prefix with !!.

mx() Where dom() is Needed

// ✗ Can't call .$() on an mx description
let picker = mx.datePicker({ value: today });
picker.$({ value: tomorrow });  // TypeError: picker.$ is not a function

// ✓ Use dom for elements you need to update later
let picker = dom.datePicker({ value: today });
picker.$({ value: tomorrow });  // works

mx.tag() returns an array. dom.tag() returns a real element. If you need to hold a reference and call .$() on it, use dom.tag().

onClick vs onclick

// ✗ React convention - won't fire in mx
mx.button({ onClick: handler })

// ✓ Lowercase - standard DOM event names
mx.button({ onclick: handler })

mx uses native DOM event property names. Always lowercase.

render() Removes Excess Children

render() removes DOM children that have no corresponding node in the new render call:

// Render 3 items
container.render(mx.div('A'), mx.div('B'), mx.div('C'));

// Render 2 items - 'C' is removed from the DOM
container.render(mx.div('A'), mx.div('B'));

This is by design - the rendered children are the source of truth. If you need to preserve extra children, append them separately (not through render()).

Global getElementById in Components

// ✗ Fragile - breaks if two instances exist
$() {
    return mx.input({ id: 'search' });
}
// Later: document.getElementById('search')  // which one?

// ✓ Scope queries to the component
$() {
    return mx.input({ class: 'search' });
}
// Later: this.querySelector('.search')

Global IDs collide when multiple instances of a component exist. Query within the component's own DOM tree.

dom(Component) Without Attrs Skips $()

dom.myComp(...) returns an element immediately. For a defined component (one with a $ function), the attrs path is what triggers the first $() call. Calling dom.myComp() with no attrs falls to the "child" branch and skips initialization:

// ✗ $() never runs. Element exists but is empty.
let picker = dom.datePicker();

// ✓ Pass an empty attrs object to trigger $()
let picker = dom.datePicker({});

// ✓ Or use mx() inside render() - mx path always calls $()
container.render(mx.datePicker());

The asymmetry exists because the arg slot is overloaded: a plain object is attrs, anything else is a child. With no arg at all, dom() has no way to know you meant "attrs = {}" vs "no attrs + no children," and picks the latter.

Arrays as Children Must Be Spread

render() and mx() classify each child: an mx description (array with ._), a DOM node, a string, a number, null/false. A plain array from .map() matches none of those - it falls to the text-node branch and gets stringified:

let items = ['a', 'b', 'c'];

// ✗ Renders literal text: "a,b,c"
mx.ul(items.map(i => mx.li(i)));

// ✓ Spread so each li is a direct child
mx.ul(...items.map(i => mx.li(i)));

Asymmetric with $ return values: returning an array from $ is fine because the wrapper spreads for you. But anywhere else - including inside mx.*() call args - you have to spread manually.

Rule of thumb

Anywhere you're about to pass .map() output as a child argument, prefix with ....

Why ._ Marks an mx Description

render() has to distinguish three things in its argument list: an mx description (mx.div(...)), a real DOM node, and anything else (text content, nullish). It picks between them with a single property check: value._.

// Inside render(), simplified:
if (value._)             { /* mx description: [tag, attrs, kids] */ }
else if (value.nodeType) { /* a real DOM node */ }
else                     { /* text content, or skip if null/false */ }

Why _ specifically? Three reasons, and they're all just JavaScript:

The collision surface

If you pass a plain object whose _ field is truthy as a child, render() destructures it as [tag, attrs, kids] and either crashes or mis-renders. This is rare in practice - data fetched from APIs typically uses semantic keys - but worth knowing if you build render arguments from foreign data:

// ✗ This object collides with the descriptor marker
let data = { _: 'metadata', name: 'Alice' };
mx.div(data);                   // render thinks it's an mx description

// ✓ Stringify, project the field, or wrap explicitly
mx.div(JSON.stringify(data));
mx.div(mx.span(data.name));

Reserved Component and Tag Names

The internal tag-name cache and component registry are plain objects. That means they inherit Object.prototype methods. If you name a component - or call mx.* with a tag name - that collides with one of those methods, you get broken output:

// ✗ Forbidden - all produce broken descriptions or crashes
mx.toString()             // returns Object.prototype.toString as the tag
mx.valueOf()
mx.constructor()
mx.hasOwnProperty()
mx.__proto__

define('toString', {...}) // silently shadowed in createElement

// ✓ Use any name that doesn't collide with Object.prototype
mx.myButton()
define('my-button', {...})

Reserved names to avoid: constructor, hasOwnProperty, isPrototypeOf, propertyIsEnumerable, toLocaleString, toString, valueOf, __defineGetter__, __defineSetter__, __lookupGetter__, __lookupSetter__, __proto__.

This applies to tag and component names only. Attribute names are safe, because the one lookup table an attribute key touches - the value/checked/selected special-attribute map - is built with a null prototype ({ __proto__: null, ... }). A lookup for toString, hasOwnProperty, valueOf or constructor returns undefined instead of an inherited Object.prototype function, so those keys take the ordinary setAttribute path and never write a property onto the element. The attr-tracking sweep uses own-key checks (Object.hasOwn) on top of that, so { toString: 'x' } both sets and removes cleanly.

Failure mode

Worst case is InvalidCharacterError at createElement time - the rendered component fails to mount. Not exploitable, but a loud, confusing crash. Kept as a known hazard to preserve the sub-1KB budget over Object.create(null) for the tag cache and component registry.

Reserved property names: $, render, $attrs, R

mx aliases removeAttribute to R on Element.prototype, and the minified build calls el.R(name) everywhere it drops an attribute. Anything that shadows R on an element breaks attribute removal for that element:

// ✗ All three shadow Element.prototype.R
mx.div({ '.R': 'oops' })          // property attr: el.R = 'oops'
mx.div({ R: () => {} })           // function values are assigned as properties
mx.form(mx.input({ name: 'R' }))  // named form control: form.R is the <input>

// The next render that drops an attribute on that element:
// ✗ TypeError: m.R is not a function - and the attribute silently stays

A plain string R attribute ({ R: 'plain' }) is harmless - it only reaches setAttribute. The hazard is the property slot on an element the reconciler drops attributes on: the .-prefix, a function value, or a named form control. An R key on a define() object is safe in practice - a component element takes the $ path and never reaches the attribute sweep - but there is no reason to use the name.

The R alias is a minified-build hazard only - ui-mx.js calls removeAttribute directly; ui-mx.min.js - the file you actually ship - routes through the alias. Test against the minified build.

The other three bite both builds. A <form> exposes every descendant control by name or id as an own property that shadows the prototype, so a control named $, render or $attrs turns form.$ into the <input>. The first mount succeeds; the second render reads form.$ to decide whether the form is a component and throws is not a function. The same getter shadows the host-side DOM methods the engine calls on a <form> it renders into - append, removeChild, insertBefore - so a control named insertBefore breaks reordering of dom() children in that form. Never use any of those names on a form control:

// ✗ Mounts fine, throws on the next render of the same parent
mx.form(mx.input({ name: '$' }))
mx.form(mx.button({ id: 'render' }))

// ✓ Any other name
mx.form(mx.input({ name: 'query' }))

PascalCase Produces a Leading Hyphen

The camelCase-to-kebab-case conversion uses /[A-Z]/g with no anchor. A leading capital produces a leading hyphen, which is an invalid custom element name:

// ✗ PascalCase → tag "-my-widget" (invalid)
mx.MyWidget()     // <-my-widget>
dom.DatePicker()  // <-date-picker>

// ✓ camelCase → tag "my-widget"
mx.myWidget()     // <my-widget>
dom.datePicker()  // <date-picker>

// ✓ Or use the string form directly
mx('my-widget')
dom('date-picker')

define() applies the same camelCase → kebab-case conversion as the call sites, so camelCase and kebab-case names always match up - but a leading capital breaks there too:

// ✓ Both stored as 'my-widget'; mx.myWidget() finds them
define('myWidget', { $() {...} });
define('my-widget', { $() {...} });

// ✗ Stored as '-my-widget' - invalid element name
define('MyWidget', { $() {...} });

Why camelCase, not PascalCase?

JavaScript's informal convention reserves PascalCase for things you call with new (constructors, classes) and camelCase for everything else (functions, methods, variables, fields). mx.myWidget is a function call, not a constructor invocation - it returns an array, not a fresh instance via new. So the call site reads as camelCase by convention.

If you want really to use PascalCase identifiers - say, you're mirroring a JSX-style component naming - declare a plain function instead: let MyWidget = (props, kids) => mx.div(...). Functions are callable identifiers; the casing convention is yours to break. define()-components map to DOM tags, and DOM tags are kebab-case, which is what camelCase converts to cleanly.

Case-Sensitive SVG Elements Get Kebab-Cased

The same camelCase → kebab-case conversion breaks the handful of SVG elements whose tag names are case-sensitive: linearGradient, radialGradient, clipPath, foreignObject, textPath, animateTransform and the fe* filter primitives. SVG ignores the kebab-cased element, so the subtree silently renders nothing:

// ✗ Becomes <clip-path> - not a real SVG element, silently inert
mx.svg(mx.clipPath({ id: 'c' }, mx.rect({ width: 10, height: 10 })))

// ✓ Create case-sensitive elements directly, include by identity
let clip = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath');
clip.$attrs({ id: 'c' }).render(mx.rect({ width: 10, height: 10 }));
root.render(mx.svg(clip));

Lowercase SVG tags (svg, path, circle, rect, g, defs, mask, use, ...) work normally.

For foreignObject specifically, half the trap is gone: since the 2026-07 audit fix, children rendered into a foreignObject host are created back in the HTML namespace, so fo.render(mx.div(...)) displays normally. (Before the fix they inherited the SVG namespace and rendered invisibly.) The foreignObject element itself is case-sensitive and still needs createElementNS, like everything else in this list.

Components Inside <svg> Are Plain SVG Elements

A component description nested in an svg host - mx.svg(..., mx.myWidget({ n: 1 })) - is created with createElementNS in the SVG namespace like any other tag under that host. The registry is never consulted: $ does not run, the children are ignored, and the props are written as plain attributes (<my-widget n="1">). Nothing throws.

This is not an engine choice to revisit: an unknown-tag element inside <svg> is non-rendering in every browser, in either namespace, so a component host cannot live in SVG content at all. Return the svg from the component instead, compose the SVG from plain descriptions or dom() nodes, or render into a foreignObject host, where components work normally:

// ✗ Inert: <my-widget> becomes an SVG-namespaced element, $ never runs
root.render(mx.svg({ width: 40 }, mx.myWidget({ n: 1 })));

// ✓ The component owns the svg
define('my-widget', { $({ n }) { return mx.svg({ width: 40 }, mx.circle({ r: n })); } });

// ✓ Or host HTML-namespace content in a foreignObject
let fo = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
fo.render(mx.myWidget({ n: 1 }));
root.render(mx.svg({ width: 40 }, fo));

One Form Per Key: name or .name, Never Both

For a reflected attribute, title and '.title' address the same DOM slot. Naming both in one description asks for two values: on first render the later key wins, and when the '.title' form is later dropped the stale b stays - the plain title is a hit in the element's attribute cache, so a is never re-asserted.

// ✗ Two keys, one slot
mx.div({ title: 'a', '.title': 'b' })   // title="b"
mx.div({ title: 'a' })                   // still title="b" - 'a' is never re-asserted

// ✓ Pick one form per key
mx.div({ title: 'a' })
mx.div({ '.title': 'b' })

Flipping the same key from one form to the other across renders is fine - { '.onclick': fn } followed by { onclick: null } detaches the handler correctly; the hazard is only both forms in the same description.

Dropping value on a <select> Deselects Everything

value is dual-tracked by render(), and dropping a dual-tracked key (or passing null) clears the property to ''. On a <select> with no option whose value is '', select.value = '' is the platform's own no-selection state: selectedIndex becomes -1 and nothing is selected. That is the faithful "no value described" state, not a reset to the first option:

define('picker', {
    $({ v = null }) {
        return mx.select({ value: v }, mx.option({ value: 'a' }, 'A'), mx.option({ value: 'b' }, 'B'));
    }
});
el.$({ v: 'b' });      // B selected
el.$({ v: null });     // selectedIndex -1, no option selected

That is the result with an unchanged option list. If the same render also inserts or removes options, the browser's selectedness reset lands on the first option (a here) - where a fresh <select> would land too.

Describe the default you want: value: 'a' or '.selectedIndex': 0. Give the select a placeholder option({ value: '' }) if a blank state is meaningful, and the drop lands on it cleanly. Or use '.value', which is not dual-tracked, if the user's choice should survive the drop.

Parent Re-Passing Props Overwrites $state

The component wrapper does Object.assign(this.$state, props) before your $ function runs. If a parent re-renders and passes the same key again, your locally updated state is overwritten:

define('counter', {
    $({ count = 0 }) {
        return [
            mx.button({ onclick: () => this.$({ count: count + 1 }) }, '+'),
            mx.span(count)
        ];
    }
});

// Scenario:
// 1. Parent: el.$({ count: 10 })  → $state.count = 10, rendered as 10
// 2. User clicks + 5 times        → $state.count = 15, rendered as 15
// 3. Parent re-renders same prop: el.$({ count: 10 })
//    → Object.assign overwrites $state.count back to 10
//    → user's clicks are lost

Usually this is what you want

A controlled component should accept parent-passed state. A form field whose value is owned by the parent should be overwritten when the parent re-passes. This is the declarative model doing its job.

If you need a value that survives parent re-passes, use a different key - take the prop as a seed and store local state separately:

define('counter', {
    $({ initial = 0 }) {
        this._count ??= initial;   // seeded once from the prop, never clobbered
        return [
            mx.button({ onclick: () => { this._count--; this.$({}); } }, '−'),
            mx.span(this._count),
            mx.button({ onclick: () => { this._count++; this.$({}); } }, '+')
        ];
    }
});

This pattern needs this.$({}) to trigger a re-render - which is normally a code smell. The cleaner answer is usually to let the parent own the state and pass it down; components that truly need local-state-immune-to-parent are surprisingly rare.

Destructured State Is a Render-Time Snapshot

Inside $(), destructured values are snapshots from that render pass. They do not update when $state changes behind the scenes:

// The ✗ pattern: count is captured at render time
define('counter', {
    $({ count = 0 }) {
        return [
            mx.button({ onclick: () => this.$({ count: count - 1 }) }, '−'),
            mx.span(count)
        ];
    }
});

Why single clicks usually work. On a single synchronous click, the handler fires, this.$() re-renders, and a brand-new handler (closing over the fresh value) replaces the old one in the DOM before the next click. The bug is invisible until you do something slightly more interesting.

Three ways the closure goes stale

1. Multiple state updates in one handler

// ✗ Both calls read count = 5. Result is 4, not 3.
onclick: () => {
    this.$({ count: count - 1 });
    this.$({ count: count - 1 });
}

2. Async gaps

// ✗ Timeout fires with the render-time value, not the current one
onclick: () => setTimeout(() => this.$({ count: count - 1 }), 100)

3. Child-to-parent callbacks

// ✗ Parent sends count: 10, child clicks +, but handler
// still closes over the old count from an earlier render
// when the parent re-renders without recreating the child
onclick: () => this.$({ count: count - step })

Solution 1: Live read from this.$state (safest default)

Read from the live object at execution time. The closure captures this (which never changes) rather than a scalar value:

// ✓ Always sees current state, even across async gaps
onclick: () => this.$({ count: this.$state.count - 1 })

For handlers that survive many renders, cache them with ??= so the element's attribute cache sees the same function and skips re-attaching after the first mount:

define('counter', {
    $() {
        let n = this.$state.count ??= 0;
        return [
            mx.button({
                onclick: this.dec ??= () => this.$({ count: this.$state.count - 1 })
            }, '−'),
            mx.span(n),
            mx.button({
                onclick: this.inc ??= () => this.$({ count: this.$state.count + 1 })
            }, '+')
        ];
    }
});

Solution 2: Inline function with local computation

If you prefer destructuring, you can still use it safely as long as the handler is recreated every render (no caching) and you do not read the closed-over value after an async gap:

define('counter', {
    $({ count = 0 }) {
        let me = this;
        return [
            mx.button({
                onclick() { me.$({ count: --count }); }
            }, '−'),
            mx.span(count),
            mx.button({
                onclick() { me.$({ count: ++count }); }
            }, '+')
        ];
    }
});

Why this works: the handler is recreated every render, so it always closes over the latest value. The parent-re-pass scenario is fine because a re-render creates a new closure with the fresh value.

Why this is less ideal: the renderer re-assigns onclick to the DOM node on every state change. V8 handles this fine in practice, but it is strictly more work than the cached-handler approach. Also, this inside a function() event handler is the DOM node (the button), not the component. You must capture the component in a local variable (let me = this) or use an arrow function.

Solution 3: Batch multiple mutations into one this.$() call

If you are mutating the same value several times before re-rendering, compute locally and call this.$() once:

// ✗ Two renders; second reads stale closure
onclick: () => {
    this.$({ count: count - 1 });
    this.$({ count: count - 1 });
}

// ✓ One render; local variable is authoritative for this event
onclick() {
    let c = count;
    c -= 2;
    me.$({ count: c });
}

This is faster (single render pass) and sidesteps the stale-read problem entirely for the duration of the handler.

Summary: which pattern to use when

  • Live read + cached handler - best for stateful components with many re-renders. Most efficient, always correct.
  • Destructured + inline handler - fine for simple components with minimal re-renders where re-attachment overhead is negligible. Avoid async gaps.
  • Batch updates - always do this when you need to change the same key multiple times before re-rendering.

The common mistake

Most developers write the first pattern (() => this.$({ count: count - 1 })) because it looks clean and works in the toy example. It only breaks later, when someone adds a debounce, a confirmation dialog, or a second increment. Start with the live-read pattern and you never have to debug it.

React-Style Attribute Names Don't Work

mx uses native DOM names. Three common React conventions fail silently:

ReactDOM / mxFailure mode
onClickonclickHandler never fires (property name mismatch)
classNameclasssetAttribute creates a nonstandard "className" attribute; CSS selectors don't match
htmlForforLabel-input association broken
tabIndextabindexIgnored by browser

Same rule for every attribute and event: lowercase, no camelCase.

Boolean Attributes Use HTML, Not JS, Truthiness

mx's attribute setter only treats three values as removal triggers: null, undefined, and literal false. Everything else - including 0, NaN, empty strings, and the string "false" - calls setAttribute. This is intentional, and it matches how HTML works, not how JS truthiness works.

// All of these set the attribute. The element ends up "disabled":
mx.button({ disabled: 0 })           // setAttribute('disabled', '0')
mx.button({ disabled: NaN })         // setAttribute('disabled', 'NaN')
mx.button({ disabled: '' })          // setAttribute('disabled', '')
mx.button({ disabled: 'no' })        // setAttribute('disabled', 'no')
mx.button({ disabled: 'false' })     // setAttribute('disabled', 'false')

// Only these remove the attribute:
mx.button({ disabled: false })       // removeAttribute('disabled')
mx.button({ disabled: null })        // removeAttribute('disabled')
mx.button({ disabled: undefined })   // removeAttribute('disabled')

Why?

HTML boolean attributes are presence-based, not value-based. <button disabled>, <button disabled="">, and <button disabled="false"> all render the same disabled button. The browser doesn't parse the value - the attribute either exists or it doesn't.

mx mirrors the platform. Treating 0 or "" as "remove" would silently corrupt non-boolean attributes that actually need those values: padding: 0, flex: 0, value: '', placeholder: '', tabindex: 0 are all real and meaningful. Only the JS nullish trio plus literal false is unambiguous as "the attribute is absent".

To toggle a boolean attribute by a JS condition

Coerce to a strict boolean before passing it through:

mx.button({ disabled: !!errorCount })       // ✓ 0 → false → removed
mx.button({ disabled: state === 'sending' }) // ✓ boolean output
mx.button({ disabled: missing || false })    // ✓ default to false
mx.button({ disabled: cond ? true : null })  // ✓ explicit two-state

Or use the literal true value, which mx serializes to the string "true" on every path (mx, dom, $attrs): disabled: truedisabled="true". The attribute is present, so boolean attributes activate; enumerated attributes like aria-expanded get the correct "true"/"false" value.

No Built-in class/style Helpers

Other frameworks accept rich values for class and style: arrays, objects, conditional maps. mx doesn't. Both attributes go through setAttribute, and setAttribute coerces with "" + value - so non-strings stringify in the obvious, usually-wrong way:

// Each of these stringifies in the obvious way - none of them do what you want
mx.div({ class: ['a', 'b'] })                  // class="a,b"     (one weird class named "a,b")
mx.div({ class: { foo: true, bar: false } })   // class="[object Object]"
mx.div({ style: { color: 'red' } })            // style="[object Object]" → invalid CSS, dropped silently

// What mx wants you to write:
mx.div({ class: 'a b' })
mx.div({ class: cond ? 'foo active' : 'foo' })
mx.div({ style: `color:${color};width:${w}px` })

Why no helpers?

mx is under 1KB brotli. A clsx-style class composer would add ~150B - meaningful at this scale. The bigger reason: components decide their own classes inside $. The pattern that motivates class={a:true,b:isOn} (per-render conditional class flipping at the call site) is rare in practice; most apps swap one or two states (active, disabled, loading) that destructure cleanly into a template literal.

If you really do need object-style class composition, four lines of plain JS does it. Keep it in your own utils:

// Drop into your own utils.js
let cls = (...args) => args.flat().filter(Boolean).map(a =>
    a.constructor === Object
        ? Object.entries(a).filter(([,v]) => v).map(([k]) => k).join(' ')
        : a
).join(' ');

mx.div({ class: cls('btn', size, { active, disabled }) });

The lesson generalizes: mx ships the smallest set of primitives that compose. Things you'd only use in 5% of components belong in your project, not in the framework.

Style: prefer .style inside $

If you're already inside a component, skip the attribute path entirely. Direct property writes are faster than setAttribute and accept structured input:

$({ color, hidden }) {
    this.style.color = color;             // direct property
    this.style.cssText = 'padding:8px';   // batch many at once
    if (hidden) this.style.display = 'none';
    return [...];
}

Null-Prototype Objects Aren't Attrs

mx detects attrs via attrs.__proto__ === Object.prototype. Objects created with Object.create(null) have no prototype, so the check fails and they fall to the child slot - and a null-prototype object has no toString for the DOM to stringify, so appending it throws:

let attrs = Object.create(null);
attrs.class = 'box';

// ✗ attrs is treated as a child, and appending it throws:
//    TypeError: Failed to execute 'append' on 'Element':
//               Cannot convert object to primitive value
mx.div(attrs, 'content')

// ✓ Plain object literals work
mx.div({ class: 'box' }, 'content')

Rare in practice, but worth knowing if you're building attrs programmatically. Object literals you write by hand always pass - including literals with an own constructor key, which were misclassified as children until the 2026-07 audit fix.

The silent version: parsed JSON with an own __proto__ key

The check reads the __proto__ property, and JSON.parse creates own data properties - including one named __proto__. Such an object still has Object.prototype as its real prototype, but the property read returns the own value, so mx's check fails. Nothing throws and nothing warns: the object lands in the child slot, every attribute is silently dropped, and it renders as the text [object Object].

let attrs = JSON.parse('{"__proto__": {}, "class": "box"}');

Object.getPrototypeOf(attrs) === Object.prototype   // true
attrs.__proto__ === Object.prototype                // false  ← the check mx makes

// ✗ Renders <div>[object Object]content</div> - class never applied
mx.div(attrs, 'content')

// ✓ Drop the key before using the object as attrs
delete attrs.__proto__;
mx.div(attrs, 'content')          // <div class="box">content</div>

Copying doesn't help: { ...attrs } re-creates the own __proto__ data property, and Object.assign({}, attrs) goes through the __proto__ setter - which reassigns the target's prototype and fails the check for a second reason. delete, or rebuilding key-by-key while skipping __proto__, are the fixes. Only a concern when attrs come from JSON you don't control.

Returning a DOM Node from $

The component wrapper dispatches the return value of $ through value._ ? render(value) : render(...value). A DOM node has no ._ property, so it falls into the spread branch. render(...node) fails because DOM nodes are not iterable:

// ✗ TypeError: HTMLElement is not iterable
define('media', {
    $({ url }) {
        return makeIframeFor(url);    // returns a real <iframe> element
    }
});

// ✓ Wrap in an array - render(...[node]) is fine
define('media', {
    $({ url }) {
        return [makeIframeFor(url)];
    }
});

// ✓ Or wrap in an mx description so ._ takes the single-arg branch
define('media', {
    $({ url }) {
        return mx.div(makeIframeFor(url));
    }
});

In practice this only matters when integrating third-party widgets - CodeMirror, charts, embeds - that hand you a real DOM element you want to mount as the component's sole child. Always wrap. The cost is one bracket pair.

Bare primitives take the same spread branch

The contract is: a description, an array, a node inside an array, or null/false/undefined for nothing. A bare string has no ._ but is iterable, so it spreads per code point into one text node per character - return 'hello' gives five text nodes (textContent still reads hello). A number or true is not iterable and throws; 0 and '' render nothing, so return count works for 0 and throws for 1.

// ✗ 5 text nodes                  // ✗ TypeError: not iterable
$() { return 'hello'; }             $({ count }) { return count; }

// ✓ Wrap primitives
$() { return ['hello']; }           $({ count }) { return [String(count)]; }
$() { return mx.span('hello'); }    $({ count }) { return mx.span(count); }

Why the wrapper works this way

Returning a single mx description (return mx.div(...)) is the common case, and the wrapper handles it without forcing you to write return [mx.div(...)] every time. The ._ check distinguishes "single description" from "array of children" without an Array.isArray call. DOM nodes weren't in the design space because the component itself is a DOM node - returning another one is the unusual case.

Never await the mx or dom Proxy Itself

mx and dom are Proxy objects whose get trap answers every key with a bound tag factory - there is no allow-list of real HTML tags. That includes then, which is the one property name the language treats as special: anything with a callable then is a thenable, and the promise machinery will call it and wait to be resolved. The factory just builds a <then> description and ignores the callbacks, so the promise never settles:

typeof mx.then          // "function"  ← mx looks like a promise

await mx                // ✗ hangs forever, no error
Promise.resolve(dom)    // ✗ never settles
await Promise.all([mx]) // ✗ hangs the whole batch

Only the bare proxy is affected. A description is a plain array, so await Promise.all([mx.div('a'), fetchThing()]) is completely fine - it is passing mx or dom themselves into promise-shaped code that hangs. In practice that means: don't return them from an async function, don't put them in a Promise.all array, and don't hand them to a generic helper that might await its argument.

Related: string coercion throws

The same unconditional trap means symbol keys reach the kebab-caser, which calls .replace on them. So String(mx) and `${mx}` throw tagAlias.replace is not a function, and [].concat(mx) silently returns []. Logging the proxy with console.log(mx) is safe; interpolating it into a string is not.

Both faces are fixable with a guarded get trap, but the guard costs more bytes than the hazard is worth at this size - so it is documented rather than fixed. You will not hit either one by writing ordinary mx code.

The Tag-Name Cache Is Permanent

mx memoizes its camelCase-to-kebab-case conversion in a plain module-level object:

let tagCacheObject = {},
    toTag = tagAlias => tagCacheObject[tagAlias] ||= tagAlias.replace(/[A-Z]/g, '-$&').toLowerCase();

Every unique tag name you ever pass to mx.* or dom.* lives in this object until the page closes. There is no eviction. This is by design.

Why this is fine

A real app uses some bounded number of tag names: div, span, button, input, plus your custom components. Maybe 60 unique strings, total. The cache occupies a few hundred bytes for the lifetime of the tab. The savings come from skipping the regex on every call - a meaningful win at 60fps.

This is just how JS works: any object reachable from a closure (or the global scope) is retained until that closure is unreachable. The cache is reachable from the mx Proxy, which is global, which lives until the page unloads. The same is true of every cache, registry, or memo your app builds.

When it would matter

If you generate tag names dynamically from unbounded input, the cache will grow without bound:

// ✗ One new entry per id, forever
items.forEach(item => mx(`row-${item.id}`, ...));

// ✓ Use a fixed tag, distinguish by attribute
items.forEach(item => mx('a-row', { 'data-id': item.id }, ...));

This is rare - most apps use a fixed set of tag names - but it's the kind of leak that's invisible until your tab has been open for a week. The general lesson: any cache keyed on user data without an eviction policy is a memory leak waiting for the right input.

Namespaced SVG Attributes

mx creates SVG elements via createElementNS (mx.svg is always created in the SVG namespace; mx.path, mx.circle etc. inherit it only when nested inside an svg host, since the namespace is taken from the host element - a top-level mx.path or any dom.path/dom.circle lands in the HTML namespace and renders invisibly). But it sets attributes via plain setAttribute, not setAttributeNS. For attributes that require a namespace prefix - xlink:href, xml:space, xml:lang - the attribute lands in the wrong namespace and the browser silently ignores it.

// ✗ Older SVG that uses xlink: this no-ops
mx.use({ 'xlink:href': '#icon-star' })

// ✓ Modern SVG (SVG 2, supported in every browser since ~2017)
mx.use({ href: '#icon-star' })

Why no support?

SVG 2 deprecated the xlink: namespace in 2017. Every major browser supports unprefixed href on SVG elements: Chrome 49+, Firefox 51+, Safari 12+, Edge 79+. If you're not targeting IE11 or pre-2018 Safari, you don't need xlink:.

mx omits the namespace path because the byte cost (~80B compressed for a parallel setAttributeNS branch) buys compatibility with software that no longer ships security patches. Worth checking whether your real constraint is browser support or copy-pasted-from-a-2014-tutorial markup. In most cases the answer is the markup, and the fix is updating the markup, not the framework.

If you really do need it

Drop into the underlying DOM API directly inside $:

define('legacy-svg-icon', {
    $({ iconId }) {
        let use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
        use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#' + iconId);
        return mx.svg(use);
    }
});

The two-line escape hatch is always available. mx doesn't hide the platform - it just declines to ship a wrapper for an attribute path the platform itself has retired.

Don't Drive Logic Off Rendered Text

Reading a node's textContent back to make a decision couples your logic to display copy. The moment someone rewrites the label, pads it with whitespace, or localizes the UI, the comparison silently stops matching:

// ✗ Breaks when 'Active' becomes 'active', 'Enabled', or 'Aktiv'
if (row.querySelector('.status').textContent === 'Active') {
    enable();
}

// ✓ Read from state, or a data attribute that never gets translated
if (row.dataset.status === 'active') {
    enable();
}

Rendered text is for humans; keep the machine-readable truth in your state object or a data-* attribute. The display string should be derived from state, never be the source of it.

this.$({}) Is a Code Smell

Calling this.$({}) with an empty object merges nothing into $state and then re-invokes $ with unchanged state. If you find yourself writing this, it almost always means the component is relying on this._x mutations as side effects and using $({}) to flush a render:

// ✗ Hidden data flow - what actually changed?
$() {
    this._items.push(newItem);    // mutation nobody sees
    this.$({});                    // "please re-render"
}

// ✓ Make the change part of state
$({ items = [] }) {
    return mx.button({ onclick: () =>
        this.$({ items: [...items, newItem] })
    }, 'Add');
}

If the re-render is triggered by a child callback, have the child call onChange/onSelect/etc. and let the parent re-render with meaningful state. See $state vs this._property for the full rule.