Skip to content

LiveView

Ori Pekelman edited this page May 22, 2026 · 1 revision

Tep::LiveView

Phoenix.LiveView-shape server-rendered stateful UI over WebSocket. Subclass Tep::LiveView, override render / handle_event / optionally handle_presence_diff; the framework handles the WS lifecycle + client-side bootstrap.

Battery 4 in docs/BATTERIES-DESIGN.md.

Hello, counter

require 'sinatra'

set :scheduler, :scheduled   # WS requires the scheduled server

class CounterView < Tep::LiveView
  def initialize
    super
    @count = 0
  end

  def topic
    "counter:global"
  end

  def render
    "<div id='tep-live-root'>" +
      "Count: " + @count.to_s +
      " <button data-event='inc'>+</button>" +
      " <button data-event='dec'>-</button>" +
    "</div>"
  end

  def handle_event(event, payload, req)
    if event == "inc"
      @count += 1
    elsif event == "dec"
      @count -= 1
    end
    broadcast_render
    0
  end
end

# Shared instance — all viewers see the same counter:
COUNTER = CounterView.new

get '/counter' do
  COUNTER.mount(req)
  Tep::LiveView.render_page(COUNTER.render, "/counter_live")
end

websocket "/counter_live" do |ws|
  on_open do |evt|
    Tep::Broadcast.subscribe_ws(COUNTER.topic, ws.fd)
    ws.text(COUNTER.render)
  end
  on_message do |evt|
    COUNTER.dispatch_event_json(evt.data, req)
    # broadcast_render inside handle_event already sent the new
    # HTML to every WS subscribed to COUNTER.topic.
  end
  on_close do |evt|
    Tep::Broadcast.unsubscribe_fd(ws.fd)
  end
end

That's a working LiveView. Open /counter in two browsers, click + in one, both update.

Lifecycle

  1. Initial GET → server renders the view + ships the bootstrap HTML page (render_page).
  2. Client opens WS to the supplied path. on_open sends the initial render.
  3. Client click on [data-event="X"] → bootstrap JS sends {"event":"X","payload":"<data-payload>"} over the WS.
  4. Server on_messagedispatch_event_json parses + calls handle_event → view mutates state → broadcast_render publishes the new HTML to everyone subscribed.
  5. Client receives the new HTML; bootstrap replaces #tep-live-root's outerHTML with it. Done.

Public surface

# Subclass these:
def mount(req)                          # seed @ivars from req
def render                              # build HTML
def handle_event(event, payload, req)   # mutate state
def topic                               # broadcast binding (default "")
def handle_presence_diff(diff_json)     # react to Tep::Presence events

# Inherited imeths apps call:
view.dispatch_event_json(json, req)     # bridge from WS message
view.apply_presence_diff_json(json)     # bridge from presence diff stream
view.broadcast_render                   # publish self.render to self.topic

# Cmeths:
Tep::LiveView.render_page(content, ws_path)   # initial-page wrapper

Client-side bootstrap

Tep::LiveView.render_page emits ~10 lines of inline JS that:

  1. Open a WebSocket to ws_path.
  2. On each incoming text frame: parse as HTML, replace #tep-live-root's outerHTML.
  3. Intercept clicks on any [data-event] element, send {"event": <data-event>, "payload": <data-payload or "">} over the WS.

That's all v1 ships. No morphdom, no form submission, no key bindings. "Click + re-render" is enough to demonstrate the pattern. Morphdom-style client-side diff lands later if wire size becomes meaningful.

Topic binding

view.topic returns the broadcast topic this view binds to. Default "" (no binding). Subclasses override:

def topic
  "counter:" + @id.to_s
end

When non-empty, broadcast_render publishes self.render to self.topic via Tep::Broadcast. All WSes subscribed to that topic (including the originating client) receive the new HTML.

For per-instance state (a separate counter per :id), each WS connection needs its own view instance — apps write the manual wiring as shown above.

Presence diff binding

view.handle_presence_diff(diff_json) is the callback for Tep::Presence events on the view's topic. Subclasses override; default is no-op.

Apps wire the diff stream into their on_message block, OR (when matz/spinel#641 lands) the framework auto-installs a per-WS background fiber that subscribes to Tep::Presence.diff_topic(topic) and dispatches to apply_presence_diff_json.

The diff is flat JSON with kind / principal / ekind / agent_id / state / note / until_ts fields — same shape Tep::Presence emits.

What's NOT in this battery (yet)

  • Tep.live "/path", CounterView auto-routing. Apps wire two routes manually. Auto-wire needs translator changes or a spinel mechanism to bridge runtime class instantiation without closure captures.
  • Server-side handle_broadcast(msg) intercept that triggers re-render on broadcast receipt. Needs a per-WS background fiber, gated on matz/spinel#641.
  • Diff-on-client via morphdom. v1 ships full outerHTML replacement. Future chunk.
  • Form submissions / multi-arg events. v1 carries one payload string. Apps stringify their own JSON for richer payloads.

Constraints worth knowing

  • Spinel: dispatch_event_json is an imeth. Class methods taking a Tep::LiveView param widen to sp_RbVal (poly) when called across subclasses, and spinel doesn't auto-box concrete subclass pointers into the poly slot at the call site. The imeth dispatches through the subclass instance's typed slot, sidestepping the box.
  • Topic-less views are valid — just a single-viewer LiveView with render sent on on_message. The framework doesn't require broadcast.
  • Each WS needs its own view instance if the LiveView has per-user state. Apps construct fresh instances in their on_open block. Shared-state LiveViews (a global counter, a chatroom) use a single shared instance (see the counter example above).

Clone this wiki locally