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.
| Layer | Entry point | Folder | What it holds |
|---|---|---|---|
| Reactivity | signy/core | reactivity/ | Signals, computeds, effects, owners, the scheduler |
| DOM and views | signy/web | dom/, views/ | html, h(), bindings, events, control flow |
| Components | signy/web | component/, context/ | Component, lifecycle, context, Shadow DOM |
| Application | signy/app | app/ | application(), plugins, app level values, config |
| Router and store | signy/router, signy/store | router/, reactivity/store.ts | Built on the layers above |
| Dev features | all | core/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):
| Work | Created by | First run | Re-runs |
|---|---|---|---|
| User effect | effect() | Microtask | Effect queue, microtask |
| Render effect | Template bindings, createRenderEffect() | Right away, so templates return finished DOM | DOM task queue, microtask |
| Sync effect | createSyncEffect() | Right away | Right 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.
ErrorBoundaryinstalls 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
ShowandSwitchrender each branch in its own root and dispose it when another branch wins. The children function runs untracked.Forkeeps one root per row, keyed bykeyBy,keyor 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.Indexkeeps one row per position and pushes the new item into that row's signal.
Components
For each component, Signy (component/component.ts):
- creates the instance, which runs the field initializers,
- links it to its parent and its app, so
inject()can walk up, - runs
setup(), thenrender(), inside the component as owner, - queues its
onMountcallbacks, 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
beforeEachguards and the route's guards, follow a redirect, run the loader of the deepest route, update the history, set the route signals, scroll, then runafterEach(router.ts). A newer navigation aborts an older one at any step. - Rendering. Each
Outletknows 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/webandsigny/routershare 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.