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. The complete working example is
examples/06-extensions/01-component-system.
Register a plugin
Register Tiramisu once before starting the Lustre application:
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:
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, andFloat;Vec2(Int),Vec2(Float), andVec3(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:
pub fn value(data: Data) -> extension.Value(msg) {
extension.value(definitions().kind, data)
}
Expose property helpers for normal composition:
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:
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:
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_updatefor a valid data change;on_unmountfor guaranteed cleanup;on_tickinBeforeRenderorAfterRender;on_eventfor a named DOM event decoded into a typed value; andon_object_changefor 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 rootObject3D; andextension.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:
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:
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:
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:
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:
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); andextension.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:
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:
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 ornull;instance: the named instance ornull; andreason: one ofinvalid_data,unknown_component,invalid_scope,invalid_entity_id,duplicate_entity_id,slot_owned, ormultiple_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
BeforeRenderfor changes that must appear in the current frame. - Use
AfterRenderonly for work intentionally performed after drawing. - Release external resources in
on_unmountoron_system_stop. - Do not retain an
Entitybeyond its incarnation when an id lookup throughSystemContextwould be safer.