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
# 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
endA 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
endThe 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:
| Method | Returns |
|---|---|
peers | one per tab: seat, member_id, metadata, state, joined_at |
members | grouped by person: id, metadata, seats, state (the newest tab's) |
where(**state) | the peers whose state matches |
count / empty? | tabs (members.size for people) |
me | the 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
endIt 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:.
This tab, and styling#
The roster is rendered once for every viewer, so the server cannot mark "you". After every paint the client marks:
| Attribute | On |
|---|---|
data-reactive-presence-me | this tab's reactive_presence_peer element |
data-reactive-presence-same-member | the 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 } }
}
endA 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.