Skip to content
Back to skills

Expressive Dom

ASecurity

Expressive MVC's DOM renderer (@expressive/dom) - renders Model/Component classes straight to browser DOM without React. Use when building or debugging an @expressive/dom app - render/unmount, style() and Style, FC render scopes, native events, code splitting and boundaries, transitions, portals, Vite hot reload. Core Model API lives in the expressive-mvc skill.

  • 103 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 9, 2026
developmenttypescriptgoshellreactnodeexpressdebuggingapi

Works with

  • terminal
  • cli
  • api

Security analysis

A100/100

Scanned November 12, 2026

npx -y skills add gabeklein/expressive-mvc --skill expressive-dom --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Expressive Dom?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Expressive Dom
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/gabeklein-expressive-dom/badge)](https://www.skillsdirectory.com/skills/gabeklein-expressive-dom)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: expressive-dom
description: Expressive MVC's DOM renderer (@expressive/dom) - renders Model/Component classes straight to browser DOM without React. Use when building or debugging an @expressive/dom app - render/unmount, style() and Style, FC render scopes, native events, code splitting and boundaries, transitions, portals, Vite hot reload. Core Model API lives in the expressive-mvc skill.
---

# DOM renderer

`@expressive/dom` renders MVC components directly to browser DOM. It has no React or Preact dependency.

## Status

A tested browser renderer, pre-1.0 - not an LTS contract. Use it for applications prepared to track minor releases. The documented behavior is intentional; package shape and extension seams may change before stabilization. Renderer internals are private—import only the package root and its JSX runtime entries.

```jsonc
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@expressive/dom"
  }
}
```

```tsx
import { Model, Component, pending } from '@expressive/mvc';
import { Portal, render } from '@expressive/dom';
```

## Render

`render(node, container)` replaces an existing root in the container and returns an idempotent unmount function.

```tsx
const unmount = render(<App />, document.getElementById('app')!);
unmount();
```

Classic JSX (`"jsx": "react"`, or a toolchain without an automatic runtime) uses `createElement(type, props, ...children)` and `Fragment` from the package root - set `jsxFactory: "createElement"` and `jsxFragmentFactory: "Fragment"`, or `import * as React from '@expressive/dom'` under tool defaults (the root also exports the `JSX` namespace type). Attribute typing matches the automatic runtime. `Fragment` also serves keyed fragments: `<Fragment key={id}>…</Fragment>`.

Supported output: intrinsic HTML/SVG elements, fragments, strings/numbers/bigints, FCs, `Component` classes and instances, `has.List` / `has.Pool`, `map.Managed`, portals, arrays, and empty boolean/null/undefined values. Keys preserve DOM ranges across reorder.

Events are native `addEventListener` listeners (`onClick`, `onKeyDown`, `onClickCapture`) with native event objects and propagation. There is no synthetic event layer: `onChange` on a text field fires on commit - use `onInput` per keystroke. Names are native event names; `onDoubleClick` is also accepted for `onDblClick`. `event.currentTarget` is typed as the element; `event.target` is not, since it may be a descendant.

`value` and `checked` apply after children and other props and are compared with the live element on every render, so `<select value>`, range bounds, and bound inputs follow state. As in React, a controlled `value` or `checked` is restored after the event its handler listens to (`onInput` → `input`, `onChange` → `change`; with no handler, `change` for checkboxes, radios and selects, `input` otherwise), once any resulting render has landed. A handler that rejects input - or no handler at all - keeps the field on state; accepted input is left untouched, caret included. Both are properties only: no `value`/`checked` attribute and no textarea text, so `form.reset()` clears bound fields rather than restoring the last render, and `[value=…]` selectors do not match. `className`, `style`, `dangerouslySetInnerHTML`, callback/object refs, DOM properties, `data-*`, and `aria-*` are supported. `data-*`, `aria-*`, `draggable`, `spellcheck` and `contenteditable` write booleans as `"true"`/`"false"` - pass `undefined` to omit one for presence selectors. `autofocus` (or `autoFocus`) focuses the element when it is inserted into the document (browsers honor the attribute only at page load).

## Styles

`style` recursively flattens arrays. Falsy entries are ignored, strings become DOM class tokens, and objects merge left-to-right into inline declarations. The inline result follows the browser cascade and normally overrides class rules. Use `className` for an external browser-only class; it is prepended to classes from `style`.

```tsx
<button
  className="external-widget"
  style={[
    'button',
    active && 'active',
    [compact && 'compact', { width }]
  ]}
/>
```

Classes use React's `className` - the DOM property name. The attribute spelling `class` is not accepted, and is ignored through untyped spreads. A string in `style` is a class token, not CSS declaration text. Treat arrays and objects as immutable render values—replace them when their contents change.

### Component appearance rules

Register immutable maps with `style(Component, map)`. A `_name` entry is a **rule** - a static block. Bare keys are declarations, or **macro** calls where the pack owns the name.

```tsx
// style.ts
import { Css } from '@expressive/dom';

class App extends Css {
  gutter(value: number) {
    return { paddingInline: value };
  }
}

export const style = App.use();
```

```tsx
import { style } from './style';

function Button({ active }: { active: boolean }) {
  return <button _active={active}>Save</button>;
}

style(Button, {
  padding: '8px',
  _active: { fontWeight: 700 },
  _raised: { gutter: 4, boxShadow: '0 1px 2px black' }
});
```

- A rule applies when `_name` is truthy on an element, or when the element's host tag is `name`. `0` and `''` do not activate - a rule is on or off, while a macro still treats `0` as a value. Each applied rule is one class named after its source, such as `Button_active`. Nested inside a rule body, `_name` opens a descendant scope.
- A rule named for a tag (`_label`, `_small`, `_time`) also styles every such element the component renders - pick another name for a flag-only rule.
- Bare keys at the top of a map form a **base rule** applied to each of the component's host roots - `padding` above styles every root `Button` renders, through fragments.
- A map never reaches inside a child component, placed as an element or an instance. Rules, base declarations, tag matches and descendant scopes apply only to elements the component's own JSX writes - a `_rule` on a child component element styles that child's root, never its internals. JSX resolves in the scope that wrote it: `children` passed to a wrapper keep the author's rules - including rules nested in a `_rule` on the wrapper element - and the wrapper's rules do not reach them; its base declarations still apply where they are its roots. Only the class chain inherits - `Derived` sees `Base`'s rules.
- A map styles `render()` output only, not subcomponents (`expressive-mvc/component.md#subcomponents`). `_` plus a capital letter addresses one by key: its body is that subcomponent's own map - base declarations for its roots, rules and tag matches for its output - resolved through the owner's class wherever it renders. A `_Title` body belongs to the class whose prototype defines `Title()` - a subclass inheriting `Title` gets the definer's body, one overriding it starts unstyled with only its own. `style(Fancy, { _Title })` throws unless `Fancy` defines `Title()`; to restyle an inherited piece, override it, or put a rule on it from the owner (`<this.Title _title />`, styled `_title`). A PascalCase field holding a function is not a subcomponent - it styles itself with `style(fn, …)` and reads its owner with `Card.get()`. `_Title` nested in a rule, or as an attribute on any element, throws. To share rules, pass one map to both: `style(Card, shared, { _Title: shared })`.

```tsx
style(Card, {
  _active: { color: 'red' },        // render() only
  _Title: {
    fontWeight: 700,                // Title's roots
    _active: { color: 'blue' }      // Title's output; class Card_Title_active
  }
});
```
- Component names never match; minifiers rename them. A caller styles a child through `_rule` on the component element, which travels by door. A class Component declaring a matching unmanaged `_` field also receives the value (`expressive-mvc/state/state.md#unmanaged-instance-data`). The value is not reactive, and writing it does not restyle - style is resolved in the caller's render. A component styles its own output through `_rule` in its own render; anything driving behavior belongs in a managed prop.
- `$name` is a condition or variable named on the pack ([Conditions](#conditions), [Variables](#variables)); an unknown one throws, as does one written as a value string (`color: '$brand'`) - values use tokens ([Variables](#variables)).
- `false`, `null`, and `undefined` omit an entry; `0` is a value. `_` attributes are style-only and never reach the DOM.
- Under the dev JSX runtime, a `_name` attribute that no rule matches warns once with the nearest rule name. It counts the writing component's rules, the receiving component's own, and a class Component's `_` field; a `_name` a component received and spreads on is checked where it was written. A rule supplied conditionally (`App.use(wide && rules)`) warns while absent - declare it empty (`_cell: {}`) where it is always present.

Macros are methods of a `Style` subclass, a **pack**; `App.use()` returns the `style` resolving them, one per class. Plain `style` from `@expressive/dom` has none. Apps extend `Css` (or their own subclass of it); `Style` is the base for a new host's pack. A getter, a `_` or `$` method, or a field other than a `$name` string throws. Every method is a macro - TypeScript's `private` included, since it only hides the name from types. Keep utilities in `#private` methods or at module level; methods bind to one instance, so a macro may call `this.#helper()`. Statics are never macros. A component's maps resolve against the pack that styled it wherever it renders, so one component takes one pack - a second throws. `style(X)` with no map joins the pack; subclasses and subcomponents inherit their owner's. A macro's parameter may be an object - a bare key is never a scope, so `transform: { translateY: -2 }` reaches a `transform(value: Transform)` macro whole, typed by its interface, and a layer replaces it whole. A function inside a map is a value, not a definition - rules are static, so call a plain function in place.

A key resolves at its topmost definition, the subclass first; a macro returning its own name falls to the next definition down (or calls `super`), then through any `default(value, key)` method, then to the host. `default` is not a map key. An override must stay assignable to the method it overrides - annotate the base's return as `object` when shapes differ. A macro returning its own name as `true` with no named definition below throws. The host joins arrays with spaces, rejects a function, object or `true` (`No macro handles "gutter"`), and never adds units - a macro owns them, so write `padding: '8px'`, not `padding: 8`.

**Pack rules.** Maps passed to `use()` hold rules for every component that `style` styles - element defaults (`_button`), app roles (`_muted`), condition blocks (`$dark: { _button: … }`):

```ts
export const style = App.use(base, {
  _button: { padding: '8px', color: text },
  _muted: { color: muted }
});
```

They apply only to elements written by a component styled through it, below that component's base declarations and rules; a component's own `_button` merges with the pack's. A declaration outside a rule throws, except a variable default ([Variables](#variables)). Each `use()` with maps is a separate scope - one class can serve an app shell and an embeddable widget - while `use()` without maps is shared per class. Children an app passes into a library component keep the app's rules. A component rendering host elements calls `style()`, even with no map - the pack is never inferred from context.

Repeated `style()` calls add layers and return the component unchanged. One call may take several maps - `style(Meta, assetMeta, wide && compact, { whiteSpace: 'normal' })` layers each in order and skips falsy entries. Layers merge rule by rule at every depth - conditions and nested `_` scopes included; spreading (`{ ...assetMeta, _name: … }`) replaces `assetMeta._name` instead. A rule, condition or subcomponent body may be an array, merged the same way, falsy entries skipped - a shared map with nothing to add needs none (`_tabs: tabs`). Prefer it to spreads for overriding a shared map - a spread replaces each nested level, so every level must re-spread its shared counterpart, and a level later added to the shared map is dropped:

```tsx
_bar: [tabs, { [wide]: { _button: { height: '30px' } } }]
// not: { ...tabs, [wide]: { ...tabs[wide], _button: { ...tabs[wide]._button, height: '30px' } } }
```

Within a scope, base declarations come first, then rules by base class, derived class, and registration order. A rule first named inside a descendant scope (`_list: { _item: … }`) sorts after every rule of the enclosing scope, wherever it is written; a top-level rule meant to override it belongs in the same scope. A component's registrations close at *its* first render - a lazily loaded module may still call `style()` later for a component that has not rendered.

Inline style always beats classes. Among classes, a caller's rule beats the callee's: a `_rule` on a component element travels as a token through `style`, and each component `style` prop crossed - forwarded or handed on explicitly - adds a door. More doors wins, independent of render or emission order. Blocks emitted past the first door carry a `-dN` suffix. Generated classes are not a selector contract - code and tests find elements by `data-*`, `id` or role.

### Conditions

A key starting with `:` (state) or `@` (environment) holds a map that applies while the condition holds - at the top of a map (the component's roots), inside a rule, or nested in another condition. A `$name` field on the pack names one; `$name` keys resolve to it, and a subclass overrides it like a macro:

```tsx
import { Css } from '@expressive/dom';

class App extends Css {
  $mobile = '@media (max-width: 900px)';
  $open = ':is([open])';
}

export const style = App.use();

style(Select, {
  ':hover': { opacity: 1 },
  _control: {
    border: '1px solid var(--border)',
    ':hover, :focus-within': { borderColor: 'var(--accent)' }
  },
  _grid: { gridTemplateColumns: '1fr 1fr' },
  $mobile: { padding: '4px', _grid: { gridTemplateColumns: '1fr' } }
});
```

- Each condition is its own block, sorted right after the block it modifies. It adds no specificity, so doors and inline style win over it as over any rule.
- A `,` in a `:` key lists alternatives (`:is(...)` stays whole); an `@` key is never split.
- `':is([data-mode="dark"] *)'` holds while an ancestor or `<html>` matches - the zero-specificity form of `[data-mode="dark"] .thing`.
- A pseudo-element (`'::placeholder'`, `':hover::after'`) styles that part of the element and cannot hold a `_rule`.
- `_rule` inside an `@` condition applies that rule under it, at the top level or as a descendant scope.
- `_rule` inside a `:` condition styles descendants while the enclosing element holds the state - `_thumb: { ':hover': { _delete: { opacity: 1 } } }` shows `_delete` while its thumb is hovered; at the top of a map, the state is the component root's. The element holding the state gets a `-group` class. Groups chain and combine with `@`.
- Macros expand inside a condition; one producing a class there throws.
- CSS names are the vocabulary; each host decides what a condition means. Maps meant to move between hosts should use named conditions (`$mobile` above).
- Layers and subclasses merge a condition's body key by key, like a rule.

A condition's `$name` field starts with `@` or `:`. The `style.Condition` type (`` `:${string}` | `@${string}` ``) types a condition held elsewhere.

### Variables

A `$name` field that is not a condition is a CSS variable. Its value is either a default - the variable is named from the key (`$inkLight` → `--ink-light`) - or a `--name` to use as is, with values supplied elsewhere. Declare `$` fields `readonly` so their literals type the keys:

```ts
class App extends Css {
  readonly $ink = '#222';
  readonly $gap = '8px';
  readonly $brand = '--acme-brand';
  readonly $mobile = '@media (max-width: 900px)';
  readonly $dark = ':is([data-mode="dark"], [data-mode="dark"] *)';
}

export const style = App.use(($) => ({
  $dark: { $ink: '#eee' },
  $mobile: { $gap: '4px' },
  _a: { color: $.brand }
}));

style(Card, ($) => ({ border: ['1px', 'solid', $.ink], padding: $.gap }));
style(Callout, { $ink: 'crimson' });
```

- A default is emitted once at `:where(:root)`. Values are CSS text - write `'8px'`, not `8`.
- As a key, a variable declares itself where it is written. In `use()` rules, at the top or under conditions only, it is a default at `:where(:root)`; in a component map or rule, it overrides for that element and its subtree, every consumer below included.
- A map given to `style()` or `use()` may be a function of the pack's tokens, called once at that call - not per render, and without props. Each token is `'var(--ink)'`, keyed without `$` - a plain string, for templates, arrays and macro arguments. A condition has none.
- Tokens exist only inside those maps. A value picked at render time is a plain string - `` style={{ color: `var(--status-${kind})` }} ``.
- `:root` values under a `:` condition apply while `<html>` matches - a state meant for both, like `$dark` above, lists the element itself (`[data-mode="dark"], [data-mode="dark"] *`).
- Names are global CSS. An app may use bare names; a library uses `--prefix-name` fields so apps and plain CSS can set them without collisions.
- A theme may live in a CSS file instead: `--name` fields with no defaults give typed keys and tokens, and the stylesheet supplies values and modes.
- A variable only one component uses needs no field - write `'--today': 'red'` and `'var(--today)'`.

### The web pack

`Css` is the web pack shipped with the renderer - units for numbers, milliseconds for durations and a `size` shorthand. Apps extend it, or use it as is:

```ts
import { Css } from '@expressive/dom';

export const style = Css.use();
```

A number gets `px` only where the CSSOM rejects a bare one - probed once per property, so `zIndex: 3`, `lineHeight: 1.5` and `fontWeight: 700` stay unitless while `paddingTop: 5` and `marginBlockEnd: 2` do not. Every entry of a sequence is sized, so `margin: [1, 2]` is `1px 2px`. Write other units as strings (`'0.85rem'`). `transitionDuration`, `transitionDelay`, `animationDuration` and `animationDelay` are macros taking numbers as milliseconds, `0` included - an array joins with commas. `transition` takes an object keyed by property - `{ opacity: 200, transform: { duration: 300, easing: 'ease-out', delay: 50 } }` is `opacity 200ms, transform 300ms ease-out 50ms`; a string passes through, and a layer replaces the object whole. `rotate` and `offsetRotate` take degrees. A property where px is wrong gets a macro with its own unit, never a bare number. Maps accept a number only for a property where one is valid CSS - `color: 5` is a type error. Strings only where a number's role is ambiguous: `animation` (a time or a count), `font` (a weight or a size), `counterReset` (the count would get px). `flex` and `columns` take one number, not a sequence - write `flex: '1 1 200px'`. happy-dom accepts bare numbers browsers reject, so unit behavior is verified by the cascade probe, not unit tests. `size` sets width and height; for an axis use the logical properties - `marginInline`, `paddingBlock`.

Without the pack the terminal never guesses: a rule needs `padding: '8px'`, and a bare number reaches CSS unchanged - so a map for a pack with no `default` accepts numbers only where a bare one is valid, plus `0`.

### Typing

Maps are typed by the pack: a bare key must be a CSS property or a method of the pack, `_name` opens a rule, a `:` or `@` key holds a condition's map, and anything else is rejected - `colr` reports *Did you mean 'color'?* rather than throwing at the terminal. A bare key never takes an object, which is what separates `margin: '0 8px'` from `_margin: { ... }`.

A macro's first parameter types its value; an optional one also takes `true` (`row: true` calls it with none), and a macro with none is a flag taking only `true`. The pack's `style` is `style.Apply<App>`, and `style.Map<App>` types a shared map for it. Plain `style.Map` calls no macros - CSS properties, rules and `--variables` work as they are.

```ts
class App extends Css {
  row(gap?: string) { return { display: 'flex', alignItems: 'center', gap }; }
  edge(side: 'Top' | 'Left') { return { [`border${side}`]: '1px solid' }; }
}

export const style = App.use();
export const toolbar: style.Map<App> = { row: '4px', edge: 'Top' };
```

Property keys follow lib.dom's `CSSStyleDeclaration`, which omits some vendor properties. Add a pack method returning one under its own name (`WebkitTapHighlightColor(value: string) { return { WebkitTapHighlightColor: value }; }`) - the key falls through to the host as a declaration.

`className` is element-only. `style` on a component forwards through component and transparent boundaries to its host root; fragment output applies it to every host root. Reading `style` during render - destructuring, a spread, `this.props.style`, or a declared `style` field on a Component - takes ownership and suppresses forwarding for that render. Forwarded style overrides the root's own, and the outermost caller wins; a component wanting the last word consumes `style` and places it first:

```tsx
function Field({ style }: { style?: JSX.IntrinsicElements['div']['style'] }) {
  return <label><input style={['field', style]} /></label>;
}

<Field style={['invalid', { color: 'red' }]} />;
```

A component reading `style` receives a frozen object, or `undefined` when nothing was passed. Its own keys are the caller's inline declarations, flattened in written order; classes travel hidden with it. Override with a spread (`{ ...style, color: 'blue' }`), drop a key with rest (`const { width, ...rest } = style`), or merge two with `{ ...a, ...b }` - classes survive all three. Combine style values with arrays (`[a, b, 'local']`); spread to edit declarations. Do not branch on its shape, and do not clone it - `structuredClone` and JSON drop the classes.

### Conventions

Opinionated, applied by default:

- `style(X, …)` sits directly below `X`, never gathered at the end of a file. Shared maps go above their first use.
- One style module defines the app's pack - a `Css` subclass holding its macros - with its named conditions (`$mobile`) and variables (`$ink`), and exports its `style`, `$` and shared maps typed `style.Map<App>`. Components import them from it.
- A declaration group repeated in two or three places becomes a macro named for its job (`row`, `line`, `ellipsis`), not its CSS. What varies is its argument; an optional one makes it a flag (`row: true`). Fold repeats as you find them.
- Shared style: a macro groups declarations; app-wide rules - element defaults (`_button`), app roles (`_muted`) - go in `use()`; a shared map for one kind of thing is layered as an extra `style()` argument. Share a component before sharing a map.
- Values under `Css`: bare numbers for px, strings for any other unit or mixed value (`'0.2rem 0.45rem'`, `'0 auto'`, `calc()`, `var()`). A structured value that needs typing or units gets a macro taking an object.
- App elements are styled with `_rule` attributes; `className` is for classes defined outside `style()`. A hover or media hook class in app CSS belongs in a map as a condition.

## MVC render scopes

FCs have no renderer-owned state cells or effects. Treat them as pre-hooks stateless components whose MVC reads declare a dependency snapshot:

```tsx
function Total() {
  const { total, checkout } = Cart.get();
  return <button onClick={checkout}>{total}</button>;
}
```

`Model.get()` works in an FC, `Component.render()`, or a PascalCase subcomponent; outside render it reads the root context, untracked. Only values read through the returned tracking proxy invalidate that scope; same-value writes and unrelated fields do not render it. The lookup follows context - a provider replaced, arriving after mount or leaving renders the scope. Once the provider leaves, `get(false)` returns `undefined` and a required `get()` throws.

An FC owns nothing - same as React. Owned state is a class field or `<Component for={Type}>` above the FC; per-render arguments are attributes assigning fields - `expressive-mvc/component.md#props`.

Only a `Component` renders - as an element or placed inline (`{page}`). A plain `Model` is data: `<Session />` or `{session}` throws; extend `Component` to render, or provide it with `<Component for={Session} name="x">`. A view of a Model owns it as a field. A PascalCase method on any Model rendered as an element (`<this.Label />`, `<session.Badge />`, or passed on and rendered elsewhere) is a subcomponent tracking its owner; called directly it stays a plain method. A PascalCase function field is plain data and renders as an ordinary FC. Host-read members on a Component are `render`, `fallback`, `catch` and `key`; a field or method by one of these names opts into that behavior.

`<Component for>` (`expressive-mvc/state/context.md#providing-with-component`), implicit Component context, and context through portals use MVC `Context`; no renderer context API is exposed.

## Code splitting, boundaries, transitions

A function component returning a promise renders what it resolves to - a module's default export or a component itself (function or Component class): `const Settings = () => import('./Settings')`. Its first render suspends on the load; later renders and placements reuse the result without calling it again. Attributes pass to, and are typed from, the loaded component. It takes no parameters - props never change what loads. Declare it at module scope - one created per render loads every render. There is no `lazy`. A failed load rejects to the nearest `catch`; the next render after recovery loads again. A Component supplies a suspense boundary unless `fallback = false`; `fallback` and `catch` are members or attributes - `<Page fallback={<Spinner />} catch={(error, page) => …} />`. An attribute takes precedence over the member and still passes through to a field of that name. Function components never own a boundary; `fallback` on them is an ordinary prop. A placed instance (`{page}`) uses its members. `<Component fallback={…}>` adds one explicitly, with or without `for`. With no boundary above, a suspension throws from `render()` - give the root one: `fallback` on the top Component an FC `App` mounts (`<Layout fallback={null}>`), or `render(<App fallback={null} />, el)` when `App` is itself a Component. A suspension or caught error anywhere below replaces the whole boundary with one fallback; its content stays mounted off-document, keeps updating, and reveals at once when every waiting scope renders.

Suspending destroys nothing. Instances below a boundary - including one that suspends itself - keep their state and retry in place, so an instance may own its loading and error state and suspend in its own `render`. This holds on first mount and in a transition: new content a `pending()` update reveals waits off the page with the instances it created - Components, owned children - while the current screen holds, then commits with it. Each is created once, its effects run after it reaches the page, and it is destroyed only if a later render drops its position. A boundary for plain content is a bare Component:

```tsx
<Component fallback={<Spinner />}>
  <Report />
</Component>;
```

`catch(error, instance)` - member or attribute - handles render failures and retries after it completes (lifecycle failures go to `Model.on({ catch })` instead); until then the boundary holds its fallback, even if state written inside `catch()` would render cleanly. A rejected `catch()` escalates to the next boundary, which holds likewise. Handled errors are not logged. A boundary nested in a hidden one keeps its own state - an inner fallback still showing when the outer hides is still showing when it reveals.

```tsx
const Settings = () => import('./Settings');

class App extends Component {
  fallback = <p>Loading…</p>;

  render() {
    return <Settings />;
  }
}
```

MVC `pending(work)` runs `work` inline and defers subscriber DOM work. If the replacement suspends, the committed range remains until it can complete; an urgent suspension shows its fallback. Its promise resolves after the replacement commits or the affected scope unmounts. Scopes one transition updates commit together: if one suspends in its own render, or below a scope that currently renders nothing, none commit until it resolves - a route swap holds the outgoing page, and a guarded child does not render past its suspended guard. A suspension found deeper, inside content a scope already shows, holds that scope's range: what the transition adds there - the suspending part and new siblings beside it - waits off the page, and the content it replaces stays. Content it updates in place, in that scope or another, shows its update meanwhile. Transitions over separate scopes commit independently; a newer one on the same scope supersedes a held one.

`catch` retries once after it completes; a render that fails again keeps the fallback until state it read changes. A portal inside a hidden boundary is hidden with it; one first mounted while the boundary is hidden appears immediately.

```tsx
await pending(() => {
  router.page = 'settings';
});
```

## Portals

`<Portal into={element}>` renders its children into another `Element` or `DocumentFragment` while retaining logical MVC context, ownership and boundaries. `into` also takes a selector (`"#modals"`, `".layer"`), resolved once at mount and again only when the string changes; it throws unless exactly one element matches. Resolve anything else to an element yourself.

```tsx
return <Portal into={document.body}><Dialog /></Portal>;
```

## Hot reload (Vite)

```ts
// vite.config.ts
import expressive from '@expressive/dom/vite';

export default defineConfig({ plugins: [expressive()] });
```

Editing a `Model` or `Component` class patches it in place - instances keep their values and re-render with the new methods, getters, `render`, subcomponents and `style()` maps (`expressive-mvc/state/lifecycle.md#hot-patching`). A field, constructor or `new()` change reloads the page, as does any edit to a module declaring a class with `#` private members. Dev server only. A class must be top-level `class X` or `let X = class`; `export default X;` exports a snapshot, so prefer `export default class X` or `export { X as default }`.

A top-level function component (`function View()`, `const View = () => …`, PascalCase) re-renders in place, in a module importing `@expressive/dom` - any `.tsx` on its JSX runtime. Components on another runtime, such as React email templates in server code, are left alone. An anonymous `export default` is not refreshed - the module reloads.

## Boundaries

The renderer is browser-only: no native target, SSR, or hydration. It has no general hook API, memo wrapper, synthetic events, or devtools ownership. Build-time expressive-jsx extraction is not included.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

View all in development →

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.