Skip to content

Handlers & commands

One rule decides where a handler runs

A handler whose body contains any await is a server command. Otherwise it is local and compiles to client JavaScript.

The transport is inferred from the code's own effect shape — there is no decorator or registry to remember — and it composes with the seam: since only server handlers may await, and database facades are async, DB work cannot land in a client handler.

Local handlers — compiled to the island

def validate(draft):
    global hint
    n = len(draft.strip())
    if n == 0:
        hint = ""
    elif n > 50:
        hint = "max 50 chars"
    else:
        hint = f"{n}/50"

compiles to (real output):

function __l_validate(draft) {
  let n = ((draft).trim()).length;
  if ((n === 0)) {
    S.set("hint", "");
  } else if ((n > 50)) {
    S.set("hint", "max 50 chars");
  } else {
    S.set("hint", `${n}/50`);
  }
}

Branching, early returns, f-strings, string methods, set/list mutation — the accepted surface is the dialect. A local handler runs with zero network; the demo inspectors label these compiled JS (client, zero network).

Event actions

An @event value is a small action program — ;-separated assignments and calls:

<button @click="open_id = t.id; open_msg(t.id)">Open</button>
<form @submit="create"></form>
<input :bind="draft" @input="validate">
<div @escape="dialog_open = False"></div>
  • Events: @click, @dblclick, @submit (preventDefaults), @input, @escape. Inside a :each row, only @click/@submit are allowed.
  • name = expr assigns a local signal; fn(args) invokes a handler — a compiled call for a local one, a command dispatch for a server one.
  • reset("name", ...) is the one built-in.

Server commands

For a server handler, the compiler emits only the dispatch; the body runs server-side, exec'd with the facade injected:

  • Positional call args become path segments: toggle(t.id)POST …/cmd/toggle/{id} (the id read from the row's data-id at click time).
  • Remaining handler params bind from the request: a @submit sends its form's fields; local-signal params ride along automatically (add(draft) sends the draft signal). A Local[set] serializes as a JSON array.
  • The response is the re-rendered region plus an optional signal patch — the full round trip is in the wire protocol.

Pushing values back

Server handlers get two injected callables:

async def create(new_title):
    await issues.create(new_title)
    reset("new_title", "dialog_open")     # back to declared defaults
    signal("toast", "Issue created")      # explicit value into a client signal

Both ride the command response's X-Siting-Signals header and are applied before the region re-binds.

Optimistic updates

Lead a server call with local assignments and the compiler pairs them:

<button @click="selected_id = t.id; open_msg(t.id)"></button>

The leading assignments are threaded into the dispatch as an apply step — run after a snapshot, rolled back automatically if the command fails. Optimistic UI arrives as a compiler guarantee.

In-flight behavior

While a command is outstanding, the invoking control is disabled and the region carries aria-busy (no double-POSTs); a non-2xx response or network error logs, rolls back the optimistic apply, and never swaps an error body into the page.

Reading the derived surface

Every mount knows its own routing. The demo inspectors print it per handler — e.g. add → POST /todos/cmd/add (sends: draft) vs validate → __l_validate() in island.js — which is also exactly what EmittedApp.routes returns if you compile programmatically.