# Writing Extensions Tiramisu extensions are typed plugins made from components and renderer-scoped systems. Components own entity-local data and lifecycle. Systems coordinate components across one renderer. Extensions no longer receive raw DOM attributes or the renderer runtime. They define a schema, receive decoded Gleam values, and return commands describing runtime work. For the runtime model behind this API, see [How Tiramisu Works](how-tiramisu-works.md). The complete working example is [`examples/06-extensions/01-component-system`](../examples/06-extensions/01-component-system). ## Register a plugin Register Tiramisu once before starting the Lustre application: ```gleam pub fn main() -> Nil { let assert Ok(_) = tiramisu.register_with([spin.plugin()]) let app = lustre.application(init, update, view) let assert Ok(_) = lustre.start(app, "#app", Nil) Nil } ``` `tiramisu.register()` registers only Tiramisu's built-in plugin. `tiramisu.register_with(plugins)` registers the built-ins first, followed by user plugins in list order. Both functions are no-ops on Erlang so the same view code can be server-rendered; plugins become active after browser hydration. Registration returns `Result(Nil, extension.RegisterError)`. It rejects: - duplicate plugin, component, or system names; - duplicate fields within one component schema; - invalid names; and - Lustre custom-element registration failures. Plugin, component, system, and field names use lowercase words separated by hyphens, such as `physics-body` or `angular-speed`. ## Define typed component data A component kind combines a name, a scope, and a typed schema. A schema has a complete default value and a list of fields: ```gleam import tiramisu/dev/extension pub type Data { Data(speed: Float) } type Definitions { Definitions( kind: extension.Kind(Data), speed: extension.Property(Data, Float), ) } fn definitions() -> Definitions { let #(speed_field, speed_property) = extension.field( name: "speed", codec: extension.float_codec(), get: fn(data: Data) { data.speed }, set: fn(_data: Data, speed: Float) { Data(speed:) }, ) Definitions( kind: extension.kind( "spin", extension.schema(Data(speed: 1.0), [speed_field]), ), speed: speed_property, ) } ``` Use `extension.kind` for components placed on ordinary entities. Use `extension.scene_kind` only for components placed on the scene root. Built-in codecs cover: - `String`, `Bool`, `Int`, and `Float`; - `Vec2(Int)`, `Vec2(Float)`, and `Vec3(Float)`; - lists; and - optional values. Use `extension.codec(encode:, decode:)` for a custom domain type. Missing fields retain their values from the schema default. Invalid initial data mounts the default and emits a `tiramisu:component-error`; an invalid later update keeps the previous valid data. Keep `Kind` and `Property` values together as shown above. The same kind must be used by the view helpers, component builder, and any subscribing systems. ## Expose declarative view helpers Expose a full-value helper when callers commonly set every field: ```gleam pub fn value(data: Data) -> extension.Value(msg) { extension.value(definitions().kind, data) } ``` Expose property helpers for normal composition: ```gleam pub fn speed(value: Float) -> extension.Value(msg) { let definitions = definitions() extension.property(definitions.kind, definitions.speed, value) } ``` Use the helper in any Tiramisu entity: ```gleam tiramisu.primitive( "cube", [ primitive.box(vec3.Vec3(x: 1.0, y: 1.0, z: 1.0)), material.color(0x38BDF8), spin.speed(2.0), ], [], ) ``` Values for one component are folded in list order; later values win. Tiramisu serializes the merged typed values into the entity's `data-tiramisu-components` attribute. Ordinary Lustre attributes can still be included by wrapping them with `extension.html_attribute`. ## Build a component A component owns one state value per mounted component instance. Start with `extension.component`, add only the lifecycle hooks you need, then call `extension.build_component`: ```gleam import gleam/time/duration import gleam/time/timestamp import savoiardi/object fn component() -> extension.Component { extension.component( kind: definitions().kind, mount: fn(_context, _data) { #(Nil, []) }, ) |> extension.on_update(fn(_context, state, _previous, _current) { #(state, []) }) |> extension.on_tick(extension.BeforeRender, tick) |> extension.on_unmount(fn(_context, _state, _data) { [] }) |> extension.build_component } fn tick( context: extension.Context, state: Nil, data: Data, delta: duration.Duration, _now: timestamp.Timestamp, ) -> #(Nil, List(extension.Command)) { let entity = extension.entity(context) let _ = object.rotate_y( extension.root(entity), data.speed *. duration.to_seconds(delta), ) #(state, []) } ``` Available component hooks are: - `on_update` for a valid data change; - `on_unmount` for guaranteed cleanup; - `on_tick` in `BeforeRender` or `AfterRender`; - `on_event` for a named DOM event decoded into a typed value; and - `on_object_change` for changes to a named object slot. A `Context` provides the current opaque `Entity` and, for a named component, its `InstanceKey`. Use: - `extension.entity(context)`; - `extension.component_instance(context)`; - `extension.entity_id(entity)`; - `extension.root(entity)` for the entity's stable root `Object3D`; and - `extension.object(entity, slot)` for a component-owned named object. The root remains stable for an entity incarnation. Replace loaded models or other changeable objects through slots rather than replacing the root. ## Return commands for managed effects Lifecycle callbacks return `List(extension.Command)`. Commands let the runtime preserve ownership and reject work aimed at stale entities. The public commands are: - `extension.set_object(entity, slot, object)`; - `extension.remove_object(entity, slot)`; - `extension.emit(entity, name, detail)`; - `extension.task(key:, start:, success:, failure:)`; - `extension.resource_task(key:, start:, success:, failure:, dispose_stale:)`; - `extension.cancel_task(key:)`. Object slots have one component owner. Replacing a slot detaches and disposes the previous object. Removing or unmounting the owning component disposes all of its slots. If another component attempts to take the same slot, Tiramisu rejects the command, disposes the rejected object, and emits a component error. Use `on_object_change` when one component depends on an object produced by another component, for example when a material component must reapply itself after a mesh replaces its `mesh` slot. Use `emit` instead of manually dispatching DOM events: ```gleam extension.emit( entity, "spin:mounted", json.object([#("id", json.string(extension.entity_id(entity)))]), ) ``` A view can handle that event by exposing a wrapped Lustre attribute: ```gleam pub fn on_mounted(message: msg) -> extension.Value(msg) { extension.html_attribute( event.on("spin:mounted", decode.success(message)), ) } ``` ## Start asynchronous work `task` starts work lazily after its command is accepted by a connected browser runtime: ```gleam extension.task( key: "metadata", start: fn() { load_metadata() }, success: fn(metadata) { [metadata_loaded(entity, metadata)] }, failure: fn(reason) { [extension.emit(entity, "metadata:error", encode_error(reason))] }, ) ``` Keys are local to the command origin: component, named instance, or system. A new task with the same key supersedes the older task from that origin. Different components and different named instances may use the same key independently. `cancel_task` invalidates the current settlement for that key. Use `resource_task` when a successful value needs disposal if it becomes stale before acceptance: ```gleam extension.resource_task( key: "model", start: fn() { load_model() }, success: fn(loaded) { [extension.set_object(entity, "mesh", loaded.object)] }, failure: fn(reason) { [extension.emit(entity, "model:error", encode_error(reason))] }, dispose_stale: fn(loaded) { loaded.dispose() }, ) ``` Unmounting a component, removing its entity, or disconnecting its renderer invalidates pending settlements. `resource_task` calls `dispose_stale` only for a rejected successful result. Once accepted, ownership must transfer to an object slot or another managed owner exactly once. Cancellation invalidates settlement; it does not promise to abort the underlying JavaScript `Promise`. ## Build a renderer-scoped system A system has one state value per renderer. It can subscribe to typed component mounts, updates, and unmounts, then do coordinated work on a tick: ```gleam fn system() -> extension.System { extension.system(name: "spin", start: fn(_context) { #([], []) }) |> extension.on_component_mount( definitions().kind, fn(_context, entries, entity, instance, data) { #([#(entity, instance, data.speed), ..entries], []) }, ) |> extension.on_component_update( definitions().kind, fn(_context, entries, entity, instance, _previous, current) { let entries = update_entry(entries, entity, instance, current.speed) #(entries, []) }, ) |> extension.on_component_unmount( definitions().kind, fn(_context, entries, entity, instance, _data) { #(remove_entry(entries, entity, instance), []) }, ) |> extension.on_system_tick(extension.BeforeRender, tick_system) |> extension.on_system_stop(fn(_context, _entries) { [] }) |> extension.build_system } ``` Subscription callbacks receive decoded data and the mounted instance identity. The identity is `Error(Nil)` for the unnamed component and `Ok(key)` for a named component. Compare entities with `extension.entity_id` rather than retaining DOM nodes. A `SystemContext` provides: - `extension.system_scene(context)`; - `extension.system_renderer(context)`; and - `extension.find_entity(context, id)`. Systems are isolated per renderer: state, subscriptions, entities, scene, renderer, tasks, and teardown never cross renderer hosts. Prefer a component tick for isolated local behavior. Use a system when behavior coordinates many entities, requires a renderer-wide index, or arbitrates a shared renderer capability. ## Named component instances Use named instances when one entity needs the same component kind more than once: ```gleam let left = extension.instance_key("left") let right = extension.instance_key("right") [ spin.speed(1.0) |> extension.named(left), spin.speed(-1.0) |> extension.named(right), ] ``` The runtime identity is `(component name, instance key)`. Unnamed and named instances are independent, as are two different keys. Do not encode instance names into component names or field names. ## Plugin assembly Build the plugin from the erased component and system values: ```gleam pub fn plugin() -> extension.Plugin { extension.plugin( name: "spin-demo", components: [component()], systems: [system()], ) } ``` Although each component and system has its own concrete data and state types, the builders erase those types behind validated closures. This lets one plugin contain heterogeneous components and systems without exposing `Dynamic` to extension authors. ## Runtime errors Runtime validation failures bubble from the affected entity as a `tiramisu:component-error` event. Its detail contains: - `component`: the component name or `null`; - `instance`: the named instance or `null`; and - `reason`: one of `invalid_data`, `unknown_component`, `invalid_scope`, `invalid_entity_id`, `duplicate_entity_id`, `slot_owned`, or `multiple_active_cameras`. Treat these events as authoring or integration errors. They are not substitutes for domain failures returned by your async work. ## Design guidance - Keep schemas at the boundary and use decoded domain types internally. - Make the schema default a complete, valid value. - Expose small property helpers rather than asking application code to build records for common changes. - Return commands for owned objects, events, and async work. - Keep mutable Three.js objects behind the entity root or named slots. - Use `BeforeRender` for changes that must appear in the current frame. - Use `AfterRender` only for work intentionally performed after drawing. - Release external resources in `on_unmount` or `on_system_stop`. - Do not retain an `Entity` beyond its incarnation when an id lookup through `SystemContext` would be safer.