howdy/ui/island

Islands: Lustre apps rendered on the server, then run in the browser.

An island is an ordinary Lustre app in a module in a directory called islands, such as src/<app>/islands/. It exposes a function returning an Island:

// src/my_app/islands/counter.gleam
import gleam/dynamic/decode
import gleam/json
import howdy/ui/island

pub fn island() {
  island.simple(init:, update:, view:, flags: decode.int, encode: json.int)
}

A page mounts it by passing that function, with the island’s flags:

page.body([island.mount(counter.island, with: 5)])

The server renders the view for init(5) straight into the page, so it shows at once and works without JavaScript as far as plain HTML can. In the browser the same app takes over the rendered elements.

The app must also mount ui.islands(), which serves the browser code. In development it builds the islands on demand, so gleam run and gleam dev both serve code that matches the source.

A release has no compiler, so its islands are bundled as it is exported. In place of gleam export erlang-shipment, run:

gleam run -m howdy/ui export

It bundles the islands into priv/howdy_islands, then exports. For a build that must run gleam export erlang-shipment itself, run gleam run -m howdy/ui islands just before it.

The bundle records what it was built from: for every package with code in it, the version and the compiled modules involved, Erlang FFI included. A release whose code differs refuses the bundle and logs what to run, rather than serving islands that no longer match the server. A release cannot see JavaScript sources, so an edit to only a JavaScript FFI file in the app between bundling and exporting goes unnoticed; the single export command leaves no gap for one.

Packages ship islands the same way: any package that depends on howdy_ui can have an islands directory, and an app mounts those islands like its own, with nothing to register.

Island modules and the app modules they import are compiled for JavaScript, so they can only use code that runs in a browser.

howdy/ui components work in islands. A class gets the same name on the server and in the browser, so the rendered page keeps its styles when the browser takes over, and CSS for classes first used in the browser is added to the page as they appear. Components whose state the browser keeps, such as tabs and dialogs, keep it across the island’s re-renders, as they do in live views. Ids passed to components must still be unique in the page, so an island mounted twice needs ids built from its flags.

Islands also work inside live views. The live view renders the island once and leaves it to the browser: the island’s events never reach the server, and the live view’s re-renders leave the island’s state alone. If the live view renders a different island in its place, or the same island with different flags, the new one starts afresh.

An island removed from the page stops: its update and view no longer run, so effects waiting to send it a message, such as a timeout, end there. Effects that keep running, such as intervals and event listeners, need starting with subscription or every, which stop them too; Lustre gives other effects no way to stop. If a removed island still receives messages seconds later, the browser console says which. An island put back in the page starts again from its flags.

Types

A Lustre app that can be rendered on the server and run in the browser, with the codecs that carry its flags between the two.

pub opaque type Island(flags, model, msg)

Values

pub fn application(
  init init: fn(flags) -> #(model, effect.Effect(msg)),
  update update: fn(model, msg) -> #(model, effect.Effect(msg)),
  view view: fn(model) -> element.Element(msg),
  flags decoder: decode.Decoder(flags),
  encode encode: fn(flags) -> json.Json,
) -> Island(flags, model, msg)

An island with effects, like lustre.application. Effects from init run in the browser only; the server renders the initial model.

pub fn every(
  milliseconds: Int,
  message: msg,
) -> effect.Effect(msg)

Send the island message every milliseconds, for as long as it runs.

pub fn mount(
  island: fn() -> Island(flags, model, msg),
  with flags: flags,
) -> element.Element(a)

Render the island for flags and mark it for the browser to start.

Pass the island’s function itself, such as counter.island, rather than calling it: the browser loads the module that function lives in.

pub fn simple(
  init init: fn(flags) -> model,
  update update: fn(model, msg) -> model,
  view view: fn(model) -> element.Element(msg),
  flags decoder: decode.Decoder(flags),
  encode encode: fn(flags) -> json.Json,
) -> Island(flags, model, msg)

An island without effects, like lustre.simple.

pub fn subscription(
  start: fn(fn(msg) -> Nil) -> fn() -> Nil,
) -> effect.Effect(msg)

An effect that keeps running, such as a timer or an event listener, and stops when the island does. start begins it, given the function that sends the island a message, and returns the function that ends it.

island.subscription(fn(dispatch) {
  let id = set_interval(1000, fn() { dispatch(Tick) })
  fn() { clear_interval(id) }
})

An island stops when it is removed from the page, such as by a live view re-rendering without it. Lustre gives effects no way to clean up, so an interval or listener started by a plain effect.from would keep running, and keep the removed island in memory.

✨ Search Document