Skip to content

Internals

This page explains how the component system works under the hood. You don't need this to use the component system — see Getting Started for the user-facing documentation.

Architecture overview

Server                                       Browser
──────                                       ──────
1. Template invokes {@component .../}        1. start() finds [data-component]
2. componentHelper loads .dust SFC           2. Fetch /__component/component
3. evalDefinition() runs <script>            3. evalDefinition() rebuilds class
4. computeDerived() runs getters             4. Hydrate props from JSON island
5. Template renders to HTML                  5. Create reactive Proxies
6. Props serialized via devalue              6. Bind events (on:*)
7. Wrapped in <div data-component> + island  7. Re-render with morphdom on mutation
       ↓ HTML over the wire ↓

Reactive state (StateProxy)

The component system uses JavaScript Proxies to provide deep, automatic reactivity — similar to Vue 3.

When you access this.state, you're interacting with a StateProxy:

  • Deep wrapping: every nested object and array is automatically wrapped in a Proxy
  • WeakMap caching: proxied objects are cached to avoid double-wrapping
  • Array mutation interception: push, pop, splice, sort, reverse, shift, unshift are all intercepted
  • Automatic render trigger: any mutation calls component._triggerRender()

Render batching

Multiple state mutations in the same synchronous block are batched into a single render via requestAnimationFrame:

js
this.state.loading = true;
this.state.error = null;
this.state.items = [];
// → Only ONE render happens (next animation frame)

Computed values

Getters defined in the component are recomputed once per render cycle:

  1. Before rendering, _computeGettersAsDerived() evaluates every getter and stores the result in _derivedValues
  2. A getter that reads another getter triggers it on demand; a _computedThisCycle set ensures each getter runs at most once per cycle
  3. The template reads the precomputed values

There is no cross-render memoization — getters re-run on every render. This keeps the model simple and is negligible for typical derivations (formatting, small computations). If a getter is genuinely expensive over large data, hoist or guard the work yourself.

Render cycle

[state mutation]

requestAnimationFrame (batching)

compute all getters

merge context: { ...props, ...state, ...derived }

dust template renders to HTML string

morphdom reconciles the new HTML into the live DOM

child components & file inputs preserved in-place (onBeforeElUpdated → false)

sync data-props for child components

sync delegated event listeners

mount newly added child components

afterRender()

Event delegation (EventDelegator)

Inline on:* handlers are dispatched by delegation rather than bound per element:

  • One listener per event type lives on the component root, attached once and kept across renders (morphdom never replaces the root node)
  • On an event, the delegator walks up from event.target and invokes every owned element declaring data-on-<type> (or matching a manual events selector)
  • "Owned" means the element's nearest [data-component] ancestor is this root — events originating in a child component are handled by the child's own delegator
  • The set of event types is collected cheaply from the rendered HTML each render, so handlers that appear conditionally still get a listener

Form binding (FormHandler)

When props.form exists, FormHandler:

  1. Attaches one input and one change listener to the component root (once — also delegated)
  2. Routes each event to the field's matching kind: input for text fields, change for checkboxes, radios and selects
  3. Updates component.state.form[fieldName] on each change
  4. Skips file inputs and fields inside child data-component elements
  5. Form state is shared across all components via a module-level singleton (FormHandler.getSharedForm())

Serialization (SerializeUtils)

The server-side serializer:

  • Uses a WeakMap for deduplication (same Model instance serialized only once)
  • Detects circular references and skips them
  • Calls .serialize() on Model instances, .getValues() on Form objects
  • Encodes the result with devalue for XSS safety

Initialization flow: props, state, and form

SSR (server, {@component} helper)

{@component "foo" x=1 y=2 /} in a Dust template

1. IgoDust.getCompiledComponent('foo.dust')
   - Splits the file: <script> source + compiled template

2. evalDefinition(scriptSrc)
   - new Function('return (' + scriptSrc + ')')() → bare object literal

3. Merge props:
   - mergedProps = { ...definition.props, ...callerParams }

4. Seed state:
   - state = { ...definition.state }
   - if mergedProps.form → state.form = mergedProps.form

5. computeDerived(definition, mergedProps, state)
   - Evaluate every prototype getter on a context object
   - Getters can call other getters (defined on ctx with descriptors)
   - Errors are caught and skipped (DOM-accessing getters fail silently)

6. templateFn({ ...mergedProps, ...state, ...derived })

7. devalue.stringify(mergedProps) → serializable string

8. <div data-component-key="..." data-component="..."><script type="application/json" data-igo-props>...</script>html</div>

Key points:

  • No Proxy on server — props and state are plain objects
  • No init() is called — no template loading, no FormHandler
  • Getters that access the DOM will throw and are caught/skipped

CSR (browser, hydration)

mountElement(el)

1. ComponentLoader.load(name):
   - fetch /__component/component?name=<name>
   - evalDefinition(scriptSrc) → bare object
   - buildClass(name, def, templateSource) → IgoComponent subclass

2. new ComponentClass(element):
   - this._state = { ...definition.state }
   - this._props = { ...definition.props, ...JSON.parse(element.dataset.props) }
   - if props.form → this._state.form = props.form

3. Create reactive proxies:
   - this.props = StateProxy(this._props)        ← reactive, mutations trigger render
   - this.state = StateProxy(this._state)        ← reactive, mutations trigger render

4. init() (async):
   - Load Dust template via /__component/templates (or use the one bundled with the definition)
   - If props.form: create FormHandler
     → FormHandler.initForm() replaces _state.form with the shared singleton (module-level _sharedForm)
   - First render()

Key points:

  • Both this.props and this.state are reactive Proxies — mutating either triggers _triggerRender()
  • State mutations before init() completes don't trigger renders (_isInitialized is false)

Template loading

In the browser, when a component initializes:

  1. It requests the Dust template source from GET /__component/templates?file=<name>
  2. The server returns the compiled template source as JSON
  3. The source is compiled into an AsyncFunction and cached
  4. Subsequent renders use the cached function

Child component isolation

During parent re-render:

  • morphdom would normally descend into child component elements; the onBeforeElUpdated hook returns false for any [data-component], leaving its DOM and state untouched
  • Before skipping, the child's data-props attribute is refreshed from the new markup
  • After the parent render, child data-props are re-evaluated and children re-render if their props changed

Released under the ISC license.