Every Instantiable runs the same four ordered phases. Understanding them is mostly
understanding one rule:
bootdeclares what you need and defers;initwires behaviour once what you declared has loaded.
Everything else — parallelism, events, idempotency — exists to make that rule safe regardless of what order components come up in.
The four phases
| Phase | When | What belongs here |
|---|---|---|
boot* |
On construction | Read the element, queue sub-component assets (bootXComponents), register deferred listeners. Assume nothing else is loaded. |
afterBoot* |
Once every boot* has resolved |
Drain the now-complete asset queue (afterBootLoadComponents). |
init* |
Once loaded — via the booted event or a controller |
Wire real interactions, call autoInitializeComponents(). Idempotent. |
destroy* |
Reserved | Collected symmetrically; no live teardown use yet. |
Methods are collected by prefix
You don't call these phases by hand, and you don't register methods into them. The framework walks the entire flattened prototype — every method a class owns plus everything Traits stamped onto it — and runs every method whose name starts with the phase prefix. The contract is entirely in the name:
class Intro extends Instantiable {
bootImageComponents() { /* queues assets — runs in the boot phase */ }
initWithFade() { /* wires behaviour — runs in the init phase */ }
}
Name a method bootImageComponents or initWithFade and it joins the host's
lifecycle automatically — even from a trait file the host class never mentions.
afterBoot*, init*, and destroy* are collected the same way, each phase draining
before the next begins.
Same-phase methods run in parallel, not in sequence — the .sort() fixes
collection order, not execution order. Never make one init* method depend on
another init* having finished. If you need ordering, do it explicitly inside a
single method (that is what a controller's init() is for — see
Sub-components & HasX).
Why boot and afterBoot are separate
boot* methods only queue work — each bootXComponents adds an asset to a shared
processor but doesn't load it. afterBoot* drains that queue. Splitting them means a
boot* method never has to care whether the method that drains the queue ran before
or after it: the queue is guaranteed complete before anything reads it. You get this
split for free — the HasX traits contribute the boot*/afterBoot* pair; your class
just names its nested components.
The lifecycle events
Each phase ends by firing a pair of CustomEvents on the element — a generic
module.* and a moniker-scoped variant (e.g. intro.*) — each carrying the
instance in event.detail.instance. You listen for them; the framework fires them:
created— fired bymake, the instant the instance exists.booted— fired after the boot phase resolves.initialized— fired after the init phase resolves.
These are the theme's public seam. External scripts listen for intro.initialized
(or the generic module.initialized) to get a fully-wired instance — see
The Globals registry.
init is idempotent
init() guards itself. It can be triggered by the host's own booted event (via the
AutoInitializes trait) and called again by a controller ordering its children — the
second call is a no-op. That guarantee is what lets autoInitializeComponents() safely
re-init a subtree after a controller has already hand-initialized part of it. In
practice this frees you from tracking who initialized what: call init() whenever you
need an instance ready, and trust that the real wiring runs exactly once.
Why it works this way. Nested components can be reached by two paths — the
automatic booted → init chain, and a parent controller that wants to order its
children explicitly. Without the guard, the two paths would double-wire. With it,
either path (or both) leaves the instance initialized exactly once.
Related
- Instantiable — the base class these phases run on.
- Traits — how a trait contributes a
boot*/init*method. - Sub-components &
HasX—bootXComponentsand controller-driven init.