Guide

Presence

Declare `reactive_presence :viewers` on a component and read `presence` in its view: who has the page open, one peer per browser tab, grouped by person, each with the state you declared. A tab joining, leaving, going silent or changing its state re-renders the roster in every tab.

Try it#

Open this page in a second tab, then pick a colour in either one. Your own dot is filled; the other tab's is an outline. Close the second tab and its dot goes away.

0 tabs have this page open

app/reactive_components/presence_demo_component.rb
# frozen_string_literal: true

# The Presence page's live demo (issue #358): who has this page open, one dot
# per tab, and which colour each tab picked. Open it in two tabs: each sees
# the other join, pick and leave. Record-less, so its stream is a fixed key.
class PresenceDemoComponent < Phlex::HTML
  include Phlex::Reactive::Component

  class Denied < StandardError
  end

  COLORS = %w[red green blue].freeze

  reactive_presence :viewers, key: %w[docs_presence], state: { color: :string }, ttl: 30

  def id = 'presence-demo'

  def authorize_presence_state!(color: nil)
    raise Denied, 'unknown colour' if color && COLORS.exclude?(color)
  end

  def view_template
    div(**reactive_root(class: 'flex flex-col gap-4')) do
      p(class: 'text-sm') { "#{presence.count} #{presence.one? ? 'tab has' : 'tabs have'} this page open" }
      div(class: 'flex gap-2') do
        COLORS.each { color_button(it) }
      end
    end
  end

  private

  def color_button(color)
    button(**mix(on_client(:click, js.presence(color:)), class: 'btn btn-sm gap-2')) do
      plain color
      presence.where(color:).each { dot(it) } # rubocop:disable Rails/FindEach -- a roster, not a relation
    end
  end

  # This tab's dot is filled (data-reactive-presence-me), the others outlined.
  def dot(peer)
    span(**mix(reactive_presence_peer(peer),
               class: 'size-3 rounded-full border border-current data-[reactive-presence-me]:bg-current'))
  end
end

A roster#

class Boards::Viewers < Phlex::HTML
  include Phlex::Reactive::Component

  reactive_record :board
  reactive_presence :viewers, state: { selected: :integer }, ttl: 45

  def initialize(board:) = @board = board

  def view_template
    div(**reactive_root) do
      presence.members.each do |member|
        span(**reactive_presence_member(member)) { member.metadata["name"] }
      end
    end
  end
end

The stream is [board, :presence, :viewers]. The root subscribes to it by itself: the client mints a hidden <pgbus-stream-source presence> for it, which sends the tab's seat, and a tab joins when that connects. A record-less roster names its stream with key: ["lobby"]. The stream's name is "<16-char digest of the record (or the key:)>:presence:<name>", so any model fits pgbus's stream-name budget (41 chars with the default queue prefix); that leaves 15 characters for the presence name, and a longer one raises Phlex::Reactive::ConfigurationError.

presence is the roster as this render reads it:

MethodReturns
peersone per tab: seat, member_id, metadata, state, joined_at
membersgrouped by person: id, metadata, seats, state (the newest tab's)
where(**state)the peers whose state matches
count / empty?tabs (members.size for people)
methe acting tab's peer, in an action
set(**state)in an action: write the acting tab's state

metadata is what the transport's identity extractor returns for the connection (pgbus's streams_presence_member): a name, an avatar URL. Two presence roots in one tab are one peer.

Per-tab state#

state: declares what each tab may carry, typed like action params; undeclared keys are dropped. Set it from the browser with the js.presence op:

li(**on_client(:click, js.presence(selected: card.id))) do
  plain card.title
  presence.where(selected: card.id).each do |peer|
    span(**reactive_presence_peer(peer), class: "dot")
  end
end

It goes through the generated presence_set action — the signed token, the typed params, the request queue. Only this tab's row changes; the other tabs get a morph and this tab gets reply.morph. From an action of your own, presence.set(selected: id) does the same, but the action returns reply.morph itself. A tab that reconnects sends its state again.

A state-backed roster (reactive_state) stores that state on each seat row in pgbus_presence_members, and renders as one piece of markup under one id for every viewer: its reactive_state must be the same for every viewer. Per-viewer state belongs in state:.

Authorization#

presence_set is exempt from verify_authorized. The token proves the actor rendered this root; the write is typed, ephemeral, and lands only on the actor's own row, keyed by the tab's SSE connection id, which no other tab knows. When the value needs a check, define authorize_presence_state! and raise. It guards only the generated presence_set; an action of your own that calls presence.set authorizes its state itself:

def authorize_presence_state!(selected: nil)
  raise NotAllowed unless selected.nil? || @board.cards.exists?(selected)
end

Register the error in Phlex::Reactive.authorization_errors and a refused write is a 403 that changes nothing.

The client also sends presence_set with no state each time the tab connects (the announce, which puts the tab on the roster). It writes no value, so authorize_presence_state! is not called for it — its keywords can be required.

presence.me is the acting tab, and it stays the actor while the roster renders for the other viewers during that action. Don't branch the shared output on it; style "you" with the client's data-reactive-presence-me instead.

This tab, and styling#

The roster is rendered once for every viewer, so the server cannot mark "you". After every paint the client marks:

AttributeOn
data-reactive-presence-methis tab's reactive_presence_peer element
data-reactive-presence-same-memberthe same person's other elements (their other tabs, their avatar)

reactive_presence_peer(peer) and reactive_presence_member(member) render the attributes the client reads (-peer, -member, -seats). Combine them with your own attributes through mix. Both marks need this tab's reactive_presence_peer element: a roster of members only has none, so nothing is marked.

Events#

After a morph that changes who is there, the root fires reactive:presence with { joined: [seat…], left: [seat…], me: seat }. It is computed from the page, so a replayed frame that changes nothing fires nothing. The root names the seats its render saw (data-reactive-presence-roster), so a roster that shows people rather than tabs reports them too:

div(**mix(reactive_root, on_client("reactive:presence", js.add_class(:root, "pulse"))))

Leaving and expiry#

A closed tab leaves within about a second: pgbus watches each SSE socket for the browser closing it. A tab that crashes or goes silent (a laptop asleep, a lost network), so nothing closes its socket cleanly, leaves on the next failed heartbeat write (streams_heartbeat_interval, 15 s by default) or expires after ttl: seconds (45 by default; it is written into pgbus's streams_presence_ttl at boot, under this stream's pattern); the transport's heartbeat keeps a live tab fresh. Each join, leave and expiry renders the roster once, in a job on defer_job_queue, from the table as it is then, so a page reload (a leave and a join within milliseconds) is one morph with the tab still there.

A reconnect keeps the tab's seat id and re-sends its state, but the transport drops the row on the disconnect: unless the reconnect lands within one coalesce window (150 ms), the other tabs see that seat leave and join again, and reactive:presence reports both. A row that expires while its socket stays open (the tab missed its beats) is re-joined by the heartbeat without its state, until the tab's next js.presence.

ttl: must be above the transport's heartbeat interval (streams_heartbeat_interval, 15 s by default) — at or below it a live tab would expire between two beats, so reactive_presence raises. Two missed beats (the default 45) is a good floor.

Transport#

Presence needs pgbus 0.18.1 or later (presence keyed by SSE connection, pgbus:connected only after the tab's seat joined, config.streams_authorize), its table created (rails g pgbus:add_presence) or upgraded (rails g pgbus:add_presence_seats, which rails pgbus:update queues), and an ActiveJob adapter that runs jobs: each join, leave and expiry renders in a job. At boot the gem registers the presence stream pattern and its change hook; without that capability it does nothing at boot, and rendering a presence root raises Phlex::Reactive::ConfigurationError. Action Cable has no connection identity, so there is no fallback. The page must load pgbus's client element (import "pgbus/stream_source_element"; any pgbus_stream_from on the page already does).

pgbus takes each tab's person from what config.streams_authorize returns for the stream. nil or false answers 403; true authorizes with no identity, and pgbus joins such a connection to no roster (the tab warns in the console that it never appeared on its roster). Per-record authorization already happens at render: the signed src is minted only into a roster root your app chose to render for this user. streams_authorize is the revocation layer (the signed name proves pgbus minted it, not that this viewer may still read it: a removed member keeps a working name until the secret rotates). For a strict per-record check at connect time, compare stream_name with the presence key of each record the user may view (or give a record-less roster a key: with a prefix you can parse):

# config/initializers/pgbus.rb
Pgbus.configure do |config|
  config.streams_authorize = ->(env, stream_name) {
    user = env["warden"]&.user
    next unless user&.active?
    next unless user.may_stream?(stream_name) # keep your app's own check for every stream

    if stream_name.end_with?(":presence:viewers")
      next unless user.boards.any? { Boards::Viewers.reactive_presence_stream_name_for(it) == stream_name }
    end
    { id: user.id, metadata: { name: user.name } }
  }
end

A presence stream's name is a digest, so the record cannot be read back from it: reactive_presence_stream_name_for computes it from the record. config.streams_presence_member = ->(user) { { id: user.id, metadata: { name: user.name } } } shapes the member from a User. This site has no sign-in: each visitor gets a random id in the session, and the hook returns it.

reactive_connection_id (for exclude: on your own broadcasts) is the first <pgbus-stream-source> on the page, so a source loaded later but placed ahead of it changes it. Presence writes send their own connection and are unaffected.

Reference#

reactive_presence(name, state: {}, ttl: 45, key: nil)
presence                         # the roster
reactive_presence_peer(peer)     # data-reactive-presence-peer / -member
reactive_presence_member(member) # data-reactive-presence-member / -seats
js.presence(**state)             # client op (refused on a broadcast)

reactive_presence raises an ArgumentError for a name that is not a lowercase identifier, a ttl: that is not a positive Integer above the heartbeat interval, the reserved state key _rp, or a dormant component (a dormant root never connects). The client is the presence feature module (1.3 KB gzipped); the split client loads it only for a page with a presence root.