# Module and ModuleHost

Own non-React systems with selected-state sync, typed events, ticking, and complete teardown.



`Module` describes an owner. `ModuleHost` mounts its tree and coordinates the
capabilities each module declares. There is no built-in renderer, store, or frame
loop; supply those at the application boundary. The runtime and snapshot APIs are
part of the core `@joycostudio/xyz` entrypoint.

Use named ESM imports to let your bundler remove unused exports. Importing a core
utility such as `Disposer` or `Derived` does not retain modules, the preview bridge,
React, Tweakpane, or Three.js. A used `ModuleHost` retains its snapshot and activity
methods; JavaScript bundlers generally cannot remove individual class methods.
Activity is opt-in at runtime and disabled in production. The preview bridge can
be removed independently when it is not imported.

## Usage [#usage]

```ts
import { Module, ModuleHost } from '@joycostudio/xyz'
import { createStore } from 'zustand/vanilla'

const state = createStore(() => ({ count: 0, ignored: false }))

class Counter extends Module<{ increment: number }> {
  advance() {
    this.emit('increment', 1)
  }
}

class Application extends Module {
  readonly counter = this.attach(new Counter())
  private readonly count = this.select(state, (value) => value.count)

  mount() {
    this.listen(this.counter, 'increment', (amount) => {
      state.setState((previous) => ({ count: previous.count + amount }))
    })
  }

  sync() {
    console.log('Current count:', this.count.value)
  }
}

const host = new ModuleHost({
  ticker: { add: () => undefined }, // No frame loop needed for this example.
  defaultTickPriority: 0,
})
const application = host.mount(new Application()) // Logs 0.
application.counter.advance() // Logs 1.
state.setState({ ignored: true }) // No sync: the selection did not change.
host.dispose()
```

Zustand is used only by this example. Any store satisfying `ModuleStore<State>`
works without an adapter:

```ts
interface ModuleStore<State> {
  getState(): State
  subscribe(listener: () => void): () => void
}
```

Subscriptions must synchronously notify after state changes. Selections are
snapshots, so return immutable values or supply an equality function suited to the
selected value.

## Ownership and lifecycle [#ownership-and-lifecycle]

Declare only the methods a module needs:

| Method                                           | Called by the host                                                        |
| ------------------------------------------------ | ------------------------------------------------------------------------- |
| `mount(): void`                                  | Once, owner before children. May attach newly discovered children.        |
| `sync(): void`                                   | After the tree mounts, then when a selection changes.                     |
| `tick(dt: number, time: number): void`           | Every scheduled frame, owner before children; both arguments are seconds. |
| `debug(): DebugFolder \| readonly DebugFolder[]` | Lazily through the optional debug manager.                                |
| `warmup(): void \| (() => void)`                 | When `host.warmup()` traverses the tree. Returns optional restoration.    |
| `dispose(): void`                                | During retirement, children before their owner.                           |
| `disposeResources(): void`                       | When deferred resources are released.                                     |

These hooks are synchronous. A host can be registered with XYZ's asynchronous
`Warmup` orchestration, but module warmup hooks themselves must return synchronously.

### Module [#module]

`new ModuleSubclass({ tickPriority? })` optionally chooses a root's ticker priority.
Attached children inherit their root's priority; declaring a child priority fails
mounting. `children` returns a fresh array of the owned modules.

Protected helpers for subclasses:

| Helper                                        | Behavior                                                                                                                    |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `attach(child)`                               | Own a new child and return it. Allowed during construction or `mount()`; cycles, reuse, and disposed children are rejected. |
| `select(store, selector, equals = Object.is)` | Return a read-only `Selection<T>` with `.value`. Declare before mounting.                                                   |
| `emit(event, payload)`                        | Synchronously notify listeners with the payload declared by `Module<Events>`.                                               |
| `listen(emitter, event, listener)`            | Subscribe and own the returned unsubscribe callback. The returned callback can also stop listening early.                   |
| `lifetime`                                    | A `Disposer` for timers, DOM listeners, and other owner-local cleanup.                                                      |

Public `on(event, listener)` returns an unsubscribe callback for external
consumers. The consumer owns that callback; disposing the emitter also clears all
its listeners. A disposed emitter rejects new subscriptions.
Both `on()` and `listen()` return idempotent cleanup callbacks.

The host shares one subscription per store. All affected selections refresh before
syncing, and dirty modules sync in mount order. Writes made inside `sync()` join the
same flush, so children see the latest selected values. Avoid unconditional state
writes from `sync()` that would keep changing its own selection forever.

If a `sync()` hook throws, the host still attempts the remaining queued syncs in
mount order, including children of the failed module. It then rethrows a single
error, or an `AggregateError` for multiple failures. Sync hooks are not
transactional: earlier work is not rolled back, and a child must not assume its
owner's hook succeeded. A failed hook is not retried within the same flush, even
if a nested write requeues it; a later notification that changes its selection can
retry it. An initial-sync failure still unwinds the newly mounted tree.

### ModuleHost [#modulehost]

```ts
new ModuleHost({ ticker, defaultTickPriority, debug? })
```

`ticker.add(callback, { priority })` registers one callback per ticking root and
returns an unsubscribe callback, or `undefined` when the scheduler does not expose
one. The scheduler invokes `callback(time, delta, frame)` with time and delta in
milliseconds. Roots without `tick()` anywhere in their tree register no callback.
Return an unsubscribe callback whenever possible so retirement releases the
scheduler registration.

`debug` accepts XYZ's `DebugManager` or any object with the same `register()`
contract. Children nest under their owner's first debug folder; an owner without
`debug()` is transparent. Debug registrations remount from fresh definitions when
debug is toggled.

| Method or property   | Behavior                                                                                                                           |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `mount(instance)`    | Mount an independent root and return it. Failed setup unwinds the complete tree and preserves the setup error.                     |
| `unmount(instance)`  | Dispose one root, including resources, and remove it.                                                                              |
| `roots`              | Fresh array of currently mounted roots.                                                                                            |
| `warmup()`           | Visit mounted owners before children and return an idempotent reverse-order restoration callback. Failure restores completed work. |
| `retire()`           | Permanently stop behavior and detach every root while retaining deferred resources.                                                |
| `disposeResources()` | Release resources collected by `retire()`, once. Call after rendering has finished using them.                                     |
| `dispose()`          | Retire and release all resources immediately.                                                                                      |

Retirement removes the ticker, internal subscriptions, and debug registration,
then disposes children, the owner's `lifetime`, and the owner's `dispose()` hook.
Cleanup continues after failures. One cleanup error is rethrown; multiple errors
become an `AggregateError`. Hosts and modules are single-use; construct a new tree
after retirement.

Retirement blocks store-driven syncs as soon as it starts, including writes from
cleanup hooks. Unmounting a root or retiring the host during a mount hook cancels
the remaining setup; disposed modules cannot be mounted or scheduled again.

Use two-phase teardown when in-flight rendering still owns GPU resources:

```ts
host.retire()
try {
  await waitForRenderingToFinish() // Supplied by your renderer integration.
} finally {
  host.disposeResources()
}
```

Ownership is independent of scene parenting. Reparenting a rendered object does
not change which module retires its behavior or releases its resources.

For snapshots and activity observation, see [Module inspection](./inspection).

## Porting an existing local module framework [#porting-an-existing-local-module-framework]

Replace imports from `@/lib/module` with `@joycostudio/xyz`. Existing
Zustand selections, ticker adapters, typed events, debug folders, and synchronous
warmup hooks retain their contracts. The runtime reuses XYZ's existing `Disposer`
and debug schema rather than application-local helpers.
