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:

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:

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:

A Context provides the current opaque Entity and, for a named component, its InstanceKey. Use:

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:

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:

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:

Treat these events as authoring or integration errors. They are not substitutes for domain failures returned by your async work.

Design guidance

Search Document