Skip to content

Architecture

What runs when, from a signal write to the DOM.

This page is for contributors, library authors and anyone chasing a timing problem. How Signy works is the short version. File names refer to src/ in the signy repo.

Layers

Each layer only uses the ones above it.

LayerEntry pointFolderWhat it holds
Reactivitysigny/corereactivity/Signals, computeds, effects, owners, the scheduler
DOM and viewssigny/webdom/, views/html, h(), bindings, events, control flow
Componentssigny/webcomponent/, context/Component, lifecycle, context, Shadow DOM
Applicationsigny/appapp/application(), plugins, app level values, config
Router and storesigny/router, signy/storerouter/, reactivity/store.tsBuilt on the layers above
Dev featuresallcore/features.ts, log/Hooks for signy-dev, log ids

Signals and computeds

A Signal (reactivity/signal/state.ts) is a class close to TC39 Signal.State: a value, a set of subscribers and a version number that goes up on every change. Reading .value while a computation runs adds that computation to the subscribers. One object per signal, no closures, which keeps memory low for lists of thousands of rows.

A Computed (reactivity/signal/computed.ts) pulls. A write marks it stale, and the next read checks the versions of its sources and recomputes only when one moved. It caches errors like values and detects cycles. A computed nobody watches does not stay subscribed to its sources, so it can be garbage collected.

The scheduler

Writes are synchronous: the value changes and computeds are marked at once, so the next line reads fresh values. The rest is queued (reactivity/signal/scheduler.ts):

WorkCreated byFirst runRe-runs
User effecteffect()MicrotaskEffect queue, microtask
Render effectTemplate bindings, createRenderEffect()Right away, so templates return finished DOMDOM task queue, microtask
Sync effectcreateSyncEffect()Right awayRight after the write, or at the end of a batch()

A click handler that writes two signals runs like this:

click handler
  a.value = 1        value changes, computeds marked, effects queued once
  b.value = 2        value changes, the same effects are already queued
handler returns
microtask
  effects run once, reading a = 1 and b = 2
  bindings re-run, the DOM changes once
browser paints
  • Each effect runs at most once per flush, however many of its sources changed.
  • flushAll() drains notifications, effects and DOM tasks in a loop until nothing is left. flushSync() only delivers notifications.
  • An effect that keeps triggering itself is stopped after 100 rounds, with an error in the log.
  • An effect that throws is logged, or handed to the nearest ErrorBoundary, and the flush goes on.
  • Transitions use the same microtask, so they run before the paint too. See Transitions.

Ownership

Every effect belongs to the owner that was current when it was created: a component, a root from createRoot(), a control flow branch or a list row (reactivity/effect.ts). Owners form a tree, with the children of each owner in a linked list, so removing one takes constant time.

  • Disposing an owner disposes its children, runs their cleanups in reverse order and unsubscribes them from their sources.
  • Before an effect re-runs, it disposes what its previous run created. That is why a binding that returns markup rebuilds it, and why onCleanup() runs before each re-run.
  • An effect created with no owner, at the top of a module, lives until you dispose it.
  • An error thrown by an effect goes up the tree to the nearest error handler. ErrorBoundary installs one.

Templates

html (views/html.ts) parses a template with htm the first time it runs and builds its static elements once. Each later call clones them and fills in the values, so a list row costs one cloneNode(). A template with a component tag, a spread, or a tag or xmlns from a value goes through h() (views/hyperscript.ts) instead.

  • A plain value becomes a text node or an attribute, set once.
  • A function becomes a render effect bound to one text node or one attribute.
  • Control flow blocks sit between comment markers, so they can swap their nodes without a wrapper element.

Events

For 22 common bubbling events, onClick and its kind store the handler on the element, and one listener per event type on the document finds it by walking event.composedPath() (views/events.ts). That path crosses open shadow roots, and a closed shadow root gets a listener of its own. Every other event, and every on: attribute, gets a listener on the element.

A consequence: an element that is not in the document receives no delegated events, since the document never sees them.

Control flow and lists

  • Show and Switch render each branch in its own root and dispose it when another branch wins. The children function runs untracked.
  • For keeps one root per row, keyed by keyBy, key or the item. On change it diffs the keys (views/reconcile.ts): appends, prepends, single removals and swaps take a fast path, and a big reorder moves the rows outside the longest run that is already in order.
  • Index keeps one row per position and pushes the new item into that row's signal.

Components

For each component, Signy (component/component.ts):

  1. creates the instance, which runs the field initializers,
  2. links it to its parent and its app, so inject() can walk up,
  3. runs setup(), then render(), inside the component as owner,
  4. queues its onMount callbacks, and runs the queue once the DOM is in place, children before parents.

Unmounting runs onUnmount, then the onMount cleanups, then unmounts the children and disposes the owner. inject() walks the parents and then falls back to the app's values.

Application and plugins

application() (app/application.ts) holds the root component, the app level values and a registry of installed plugins. use() calls the plugin's install() and keeps the cleanup it returns. unmount() unmounts the root, runs the cleanups in reverse order and clears the values.

Router

The router (router/) is a plugin. It provides itself and its signals to the app, and Outlet reads them.

  • History. One adapter per mode: browser history, hash or memory (history.ts).
  • Matching. Each path is compiled to a regular expression with a score. Static segments score highest, wildcards lowest, and the best score wins (route.ts).
  • Navigation. Resolve the target, save the scroll position, run beforeEach guards and the route's guards, follow a redirect, run the loader of the deepest route, update the history, set the route signals, scroll, then run afterEach (router.ts). A newer navigation aborts an older one at any step.
  • Rendering. Each Outlet knows its depth and renders the matched route of that depth.

Store

A store (reactivity/store.ts) wraps each object in one proxy, cached so the same object always gives the same proxy. A read creates a node for that key of that object, which works like a signal. A write notifies the node of the written key only, which is why parents do not re-run. Object.keys() and in read a separate node for the key list.

Known limit: a node stays as long as its object, even after its key is deleted. A store used as a map of ids grows with every id that was read.

Dev features

The framework calls features.emit at component mounts, signal writes, effect runs and the like (core/features.ts). With no tools installed, a single check skips it. signy-dev installs the profiler, the inspector and the event logger as features. Logs and errors carry ids, and signy-dev registers the sentences for them, so production bundles do not ship the texts.

Builds

  • The npm build is ES2022 modules, split into shared chunks, so signy, signy/web and signy/router share one runtime and the same signals.
  • It starts with const __SIGNY_DEV__ = process.env.NODE_ENV !== 'production', so your bundler decides whether the dev checks stay.
  • The global builds bundle everything into one file each, with ES2017 and ES5 variants of the full build. See Installation.