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

red

green

blue

```ruby
# 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

```ruby
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:

| 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:

```ruby
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:

```ruby
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:

| 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:

```ruby
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):

```ruby
# 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

```ruby
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.