phlex-reactive 0.14.4 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +211 -4
  4. data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
  5. data/app/javascript/phlex/reactive/core.js +5 -5
  6. data/app/javascript/phlex/reactive/core.min.js +3 -3
  7. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  8. data/app/javascript/phlex/reactive/early.js +36 -9
  9. data/app/javascript/phlex/reactive/early.min.js +2 -2
  10. data/app/javascript/phlex/reactive/early.min.js.map +3 -3
  11. data/app/javascript/phlex/reactive/features/bindings.js +125 -140
  12. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  13. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  14. data/app/javascript/phlex/reactive/features/form.js +63 -41
  15. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  16. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  17. data/app/javascript/phlex/reactive/features/presence.js +205 -0
  18. data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
  19. data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
  20. data/app/javascript/phlex/reactive/inspect.js +5 -1
  21. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  22. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  23. data/app/javascript/phlex/reactive/reactive_controller.js +3 -1
  24. data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
  25. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
  26. data/app/javascript/phlex/reactive/runtime.js +310 -14
  27. data/lib/phlex/reactive/component/dsl.rb +5 -0
  28. data/lib/phlex/reactive/component/helpers.rb +94 -8
  29. data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
  30. data/lib/phlex/reactive/component/registry.rb +3 -1
  31. data/lib/phlex/reactive/component.rb +1 -0
  32. data/lib/phlex/reactive/defer.rb +5 -0
  33. data/lib/phlex/reactive/engine.rb +9 -4
  34. data/lib/phlex/reactive/js.rb +44 -0
  35. data/lib/phlex/reactive/presence/component_methods.rb +130 -0
  36. data/lib/phlex/reactive/presence/roster.rb +90 -0
  37. data/lib/phlex/reactive/presence/transport.rb +154 -0
  38. data/lib/phlex/reactive/presence.rb +162 -0
  39. data/lib/phlex/reactive/presence_render_job.rb +26 -0
  40. data/lib/phlex/reactive/shortcut.rb +118 -0
  41. data/lib/phlex/reactive/streamable.rb +12 -3
  42. data/lib/phlex/reactive/version.rb +1 -1
  43. data/lib/phlex/reactive.rb +10 -0
  44. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e80c9d7f2587b324d3bd1658fbe09f93dd45d8fb9ed2dd300f302ac9577c3bc
4
- data.tar.gz: cfd7d489f859f5c591e8d6a391d9c04b2b4b1906ee9d0067ced14539f8f3d2b3
3
+ metadata.gz: 3b268544642595b7c8912db0dcbee175ed751fb73290d1bfcfec0c4c9627583b
4
+ data.tar.gz: 459a72f7b048c8db6010213e252d09c552eefb3296eb838bc36973d9ded2a48d
5
5
  SHA512:
6
- metadata.gz: 79b0d752c36664dc374c69944d127556732864b3a8582ff68ae29d76ca70ad18289d60a375d99671697f91e5609f27e6cc4b7718240dc9fc632ac2425ecae671
7
- data.tar.gz: 0d19985aa77283b419e815e35691c626f4646356fc04282f5441b4f434b2b15c6a052ade40b1bde8442bead4eb3b3f1900a15cb937dfe9132a9af2b0b16cb57b
6
+ metadata.gz: dfcf217a1d1dd7fae8a79c5a4d146409a5528a13a0a7f6cadfa757ba2728c740e90fb3e95ed6957740b4219c9ea7ac5ad0bf3c5c719442671662a1f803b6a6e8
7
+ data.tar.gz: 6636ad3074cd8cba009c507730d901c38afc343b228e0780c4cf50b7404a6b0f1c8f61e75c3b5edeeb5f0fa1a56f5966ea15ed53a8db5ee4987fe320fde85dc5
data/CHANGELOG.md CHANGED
@@ -8,6 +8,74 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Modal dialogs — `js.show_modal` / `js.close_dialog` and
12
+ `data-reactive-modal` (#365).** `js.show_modal("#picker")` calls
13
+ `HTMLDialogElement#showModal()`, so an `on_client` dialog gets the browser's
14
+ top layer, focus trap, `::backdrop`, Escape and focus restored to the opener;
15
+ `js.close_dialog("#picker")` calls `close()`. A dialog already open
16
+ non-modally is reopened as a modal; a non-dialog target is skipped. A
17
+ `dialog(open: true, data: { reactive_modal: true })` the server renders (a
18
+ full page, a reply, a broadcast) is made modal at connect, after a morph and
19
+ after a stream that brings a dialog; a morph that renders it without `open`
20
+ closes it with `close()`, so the page behind is interactive again. Both ops
21
+ are actor-only, refused by `broadcast_to(js:)` like `focus`. Client: +499 B
22
+ gzipped in the default bundle, +425 B in the split core. Docs: "Modal
23
+ dialogs" on the client-ops example.
24
+
25
+ - **`shortcut: "delete"` / `"backspace"` (#366).** `on(:remove_selected,
26
+ shortcut: "delete")`, `"backspace"` and `"mod+backspace"` are accepted, on
27
+ `on(...)` and `on_client(:keydown, ...)`. Stimulus's default key map lacks
28
+ these keys (a `keydown.delete` filter throws "unknown key filter" at
29
+ keypress), so such a shortcut renders a bare `keydown@window` descriptor and
30
+ the client matches the chord itself, with the usual rules: prevented, this
31
+ platform's `mod`, skipped while typing unless `in_editable: true`.
32
+ `phlex/reactive/early` queues and prevents the chord before connect and leaves
33
+ every other key alone (+134 B gzipped). `once:` raises on these keys. Existing
34
+ chords render unchanged.
35
+
36
+ - **Presence — `reactive_presence`, `presence`, `js.presence` (#358).** A
37
+ roster component declares the presence stream it shows
38
+ (`reactive_presence :viewers, state: { selected: :integer }, ttl: 45`) and
39
+ reads it with `presence`: one peer per browser tab (`seat`, `member_id`,
40
+ `metadata`, `state`), grouped by person with `members`, filtered with
41
+ `where(selected: id)`. Tabs join on SSE connect, leave on disconnect and
42
+ expire after `ttl:` seconds of silence; each change re-renders the roster for
43
+ every viewer (one coalesced morph). Each tab's declared state is set with
44
+ `js.presence(**state)` (through the generated `presence_set` action, typed,
45
+ default-deny, and exempt from `verify_authorized` unless the component defines
46
+ `authorize_presence_state!`) or `presence.set(**state)` from an action. The
47
+ client marks this tab's element `data-reactive-presence-me` and the same
48
+ person's other elements `data-reactive-presence-same-member`, and fires
49
+ `reactive:presence` with the seats that joined and left. A new `presence`
50
+ feature module (1.3 KB gzipped): the default client bundles it (+992 B), the
51
+ split client fetches it only for a page with a presence root (+100 B in the
52
+ core). Needs pgbus 0.18.1 or later; without it a presence root raises
53
+ `Phlex::Reactive::ConfigurationError` at render. Create the table with
54
+ `rails g pgbus:add_presence` (or upgrade it with
55
+ `rails g pgbus:add_presence_seats`), return each viewer's identity from
56
+ `config.streams_authorize` (with none, pgbus joins the connection to no
57
+ roster), and run an ActiveJob adapter (joins, leaves and expiries render in
58
+ `PresenceRenderJob`). A closed tab leaves within about a second.
59
+ `ttl:` must be above pgbus's `streams_heartbeat_interval` (15 s by default). `js.presence` is refused on a broadcast, like `focus`. `reactive_presence_stream_name_for(record)` on the class gives a record's stream name, for a per-record check in `streams_authorize`. Docs: "Presence".
60
+
61
+ - **`shortcut:` on `on()` and `on_client(:keydown, …)` — page-level keyboard
62
+ shortcuts (#357).** `on(:undo, shortcut: "mod+z")` binds a window keydown
63
+ where `mod` is ⌘ on Apple platforms and Ctrl elsewhere (rendered as a meta
64
+ twin and a ctrl twin; the client runs its platform's, the other is a no-op).
65
+ The shortcut does nothing while focus is in an `input`, `textarea`, `select`
66
+ or anything `contenteditable`, so native text undo keeps working
67
+ (`shortcut: { keys: "esc", in_editable: true }` opts in), and a matching
68
+ chord is `preventDefault`-ed before any `confirm:` dialog, also when pressed
69
+ before the controller connects (`phlex/reactive/early`). `shortcut:` with
70
+ `event:`, `window:` or `outside:`, a key outside Stimulus's default key map,
71
+ or an `on_client` event other than `:keydown` raises `ArgumentError` at
72
+ render. The inspector shows `keydown@window shortcut=mod+z`. `early.min.js`
73
+ grows 195 B gzipped (1,491 B); the default client 177 B.
74
+ **Compatibility:** `shortcut` is now a reserved keyword on `on(...)` and no
75
+ longer passes through as an action param. `event: "keydown.meta+z", window:
76
+ true` without `shortcut:` behaves exactly as before (never prevented, no
77
+ editable guard).
78
+
11
79
  - **Sortable lists — `reactive_sortable`, `reactive_sortable_source`,
12
80
  `reactive_sortable_item` (#350).** Rows drag to reorder a list with a mouse,
13
81
  a finger (a 200 ms press) or the keyboard (Space picks up, arrows move,
@@ -58,6 +126,33 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
58
126
 
59
127
  ### Fixed
60
128
 
129
+ - **A stream that renders into a connected root wires what its content needs
130
+ (#356).** A root read its feature markers only at connect and after a morph
131
+ of itself, so a `reactive_show` target, select-all/count group, filter,
132
+ nested list, dirty field or paste trigger that an `append`, `prepend`,
133
+ `update`, `before`, `after` or child `replace` brought into it stayed dead
134
+ until the root reconnected. Now, once such a stream has rendered, the root
135
+ re-runs the seeds of the features it has (a new show target takes the
136
+ field's current value) and connects any feature its new content needs, in
137
+ both clients; bindings and form also wire a binding whose gate reads true
138
+ only then. As after a morph, this covers a root with an action token or a
139
+ lazy shell; a tokenless, client-only root does not re-read. On the split
140
+ client the stream's render waits for the module the content needs, as it
141
+ already did for an incoming root, so the content lands wired; the wait is
142
+ capped at 1 s, after which Turbo renders and the root connects the module
143
+ when it arrives. A draft
144
+ restore no longer marks fields `data-reactive-dirty` on a root that never
145
+ opted into dirty tracking (one that only gates a paste trigger).
146
+ - **An editable is editable in an engine without `contenteditable="plaintext-only"`,
147
+ however it reaches the page (#355).** Such an engine reads the server's
148
+ `plaintext-only` as invalid, so the element cannot even be focused. The
149
+ bindings feature switched it to `"true"` only when it connected, or after a
150
+ morph of the root, which left an editable that a stream appended, a frame
151
+ loaded or an unfocused morph rewrote uneditable. The runtime now does the
152
+ switch with one observer, installed only in such an engine (elsewhere the
153
+ probe is all that runs). Bundle: default +157 B gzip, split core +241 B,
154
+ `features/bindings` -138 B.
155
+
61
156
  - **A `checked:`-only `reactive_show_targets` entry hides at zero boxes
62
157
  (#348).** `"#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } }` was left
63
158
  visible once the owning root held no box of the group (the last ticked row
data/README.md CHANGED
@@ -118,7 +118,7 @@ instead, import `phlex/reactive/early` eagerly (the engine pins it with
118
118
  import "phlex/reactive/early"
119
119
  ```
120
120
 
121
- It gzips to under 1.3 KB and has no Stimulus import. A trigger (`on(...)` or
121
+ It gzips to under 1.5 KB and has no Stimulus import. A trigger (`on(...)` or
122
122
  `on_client(...)`) that fires before the controller connects — a click while a
123
123
  lazily loaded controller is still downloading, a custom event dispatched as soon
124
124
  as the page is interactive — is queued, its native default is stopped exactly
@@ -162,6 +162,11 @@ Good to know:
162
162
  root until it disconnects — keep its trigger element stable across replies
163
163
  (a reply that morphs the root but swaps that element leaves the hotkey
164
164
  dead until the next disconnect), or bind the hotkey without `once:`.
165
+ - A `shortcut:` (below) is the exception to "never prevented": the chord the
166
+ page claims is prevented before connect too (so `mod+s` cannot open the
167
+ browser's save dialog in the first moments), and a chord pressed inside a
168
+ field the user is typing in, or the other platform's `mod` twin, is neither
169
+ queued nor prevented.
165
170
  - Not captured, so still lost before connect: `outside:` triggers (an outside
166
171
  click before connect has nothing to close, and recording it would wake a
167
172
  dormant dropdown on every click); key filters beyond Stimulus's default key
@@ -679,7 +684,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
679
684
  | `busy_on(:save)` | Mark any element so it carries `data-reactive-busy` **only while `save` is in flight** — a spinner styled with pure CSS, zero Ruby. See [Loading states](#declarative-loading-states-loading--disable_with). |
680
685
  | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
681
686
  | `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers, plus `detail:` (run only when `event.detail` carries the listed values). See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
682
- | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:` and `expanded:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names; `toggle_attr` also flips between two values), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form, optionally through a `submitter:`), `paste_into` (read the clipboard into a field, gesture-gated), `check_group` (tick or untick a checkbox group, then re-sync its bindings), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. |
687
+ | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:` and `expanded:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names; `toggle_attr` also flips between two values), `focus`/`focus_first`, `show_modal`/`close_dialog` (a real modal `<dialog>`), `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form, optionally through a `submitter:`), `paste_into` (read the clipboard into a field, gesture-gated), `check_group` (tick or untick a checkbox group, then re-sync its bindings), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. |
683
688
  | `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. |
684
689
  | `reactive_editable(:name, multiline: false, placeholder: nil, on_enter: nil, on_escape: :revert, spellcheck: true)` | A plain-text **editable element** as a field (issue #351): `h3(**reactive_editable(:title)) { @card.title }`. Its text travels under the scope-aware `name`: with `multiline: true` its line breaks are sent as `\n`; a single-line field (the default) folds them to spaces; a morph keeps the focused element's text and caret (attributes still morph); paste is plain text; Enter runs `on_enter:` (a single-line field swallows it); Escape reverts. `reactive_dirty` and `reactive_persist` treat it like an input. See the [Editable text](https://phlex-reactive.zoolutions.llc/docs/editable-text) page. |
685
690
  | `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
@@ -936,6 +941,54 @@ div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 500
936
941
  These four (like `debounce:`/`confirm:`/`listnav:`) are **reserved keyword
937
942
  names** on `on(...)` — no longer usable as free action params.
938
943
 
944
+ **Page-level shortcuts — `shortcut:`.** Undo, redo, save and a command palette
945
+ bind with one keyword on `on(...)` or `on_client(:keydown, …)`:
946
+
947
+ ```ruby
948
+ div(**mix(reactive_root, on_client(:keydown, js.toggle("#palette"), shortcut: "mod+k"))) do
949
+ span(**on(:undo, shortcut: "mod+z")) # one reactive action per element
950
+ span(**on(:redo, shortcut: "mod+shift+z"))
951
+ textarea(name: "notes")
952
+ end
953
+ ```
954
+
955
+ A shortcut is a window `keydown` binding with three rules a plain
956
+ `event: "keydown.meta+z", window: true` does not have:
957
+
958
+ - **`mod` is ⌘ on Apple platforms and Ctrl elsewhere.** The helper renders a
959
+ meta twin and a ctrl twin; the client runs the one for its platform (from
960
+ `navigator.userAgentData.platform`, else `navigator.platform`), and the other
961
+ is a complete no-op, so Ctrl+Z on a Mac stays the browser's. A chord without
962
+ `mod` (`"esc"`, `"alt+n"`) renders one descriptor.
963
+ - **Typing is left alone.** With focus in an `input`, `textarea`, `select` or
964
+ anything `contenteditable` (including `reactive_editable`), the shortcut does
965
+ nothing and the browser's own handling (text undo) runs. Opt in with the Hash
966
+ form: `shortcut: { keys: "esc", in_editable: true }`.
967
+ - **The chord is prevented.** A matching shortcut calls `preventDefault`
968
+ synchronously, before any `confirm:` dialog, so `mod+s` does not also open
969
+ the save dialog. (Other window bindings are still never prevented.)
970
+
971
+ `shortcut:` decides the event and the window binding: passing it with
972
+ `event:`, `window:` or `outside:` raises `ArgumentError`, as does a key outside
973
+ Stimulus's default key map (a letter, a digit, `enter`, `tab`, `esc`, `space`,
974
+ the arrows, `home`, `end`, `page_up`, `page_down`; bind a custom `keyMappings`
975
+ key with `event:` and `window:`) other than `delete` and `backspace`. On
976
+ `on_client` the event must be bare `:keydown` or `"keydown"`.
977
+
978
+ `delete` and `backspace` are matched by the library rather than Stimulus (its
979
+ default key map lacks them, and a `keydown.delete` filter makes Stimulus throw
980
+ at keypress): the shortcut binds a bare `keydown@window` and the client matches
981
+ the chord, with every rule above — `on(:remove_selected, shortcut: "delete")`
982
+ never fires while typing in a field. They cannot take `once:` (the first key
983
+ pressed of any kind would spend the bare binding).
984
+ `shortcut` is now a **reserved keyword** on `on(...)`.
985
+
986
+ `once: true` on a shortcut unbinds the Stimulus descriptor after its first
987
+ firing, and a press inside a field counts: the guard leaves the chord to the
988
+ browser, but the `:once` listener is already spent. Keep `once:` off a
989
+ shortcut that can be pressed while the user types, or pair it with
990
+ `in_editable: true`.
991
+
939
992
  ### Optimistic visual hints (`optimistic:`)
940
993
 
941
994
  Every reactive action waits a full round trip for its visual change — and it's
@@ -1212,6 +1265,15 @@ button(**on_client(:click, js
1212
1265
  - **`focus(to)`** focuses the first match; **`focus_first(to)`** focuses the first
1213
1266
  focusable descendant of the match (e.g. the first menuitem inside an opened
1214
1267
  menu).
1268
+ - **`show_modal(to)`** / **`close_dialog(to)`** (#365) call
1269
+ `HTMLDialogElement#showModal()` / `#close()`: the top layer, a focus trap,
1270
+ `::backdrop`, Escape to close and focus back on the opener — all the
1271
+ browser's own. A dialog already open non-modally is reopened as a modal; a
1272
+ target that is not a `<dialog>` is skipped. A dialog the server renders open
1273
+ with `data: { reactive_modal: true }` (a full page, a reply, a broadcast) is
1274
+ made modal as soon as the client sees it, and closed with `close()` when a
1275
+ morph renders it without `open`. **Actor-only like focus**: refused in
1276
+ `broadcast_to(js:)` — render the dialog open or closed, with the marker, instead.
1215
1277
  - **`text(to, value)`** sets the target's `textContent` (stringified; `nil`
1216
1278
  clears) — **XSS-safe by construction**, never `innerHTML`, strictly less
1217
1279
  powerful than `set_attr`. With `global: true` it is the **cross-root text
@@ -1405,6 +1467,11 @@ free as an ordinary action-param name (`on(:switch, key: "pgbus")` still passes
1405
1467
  > trigger to its own element (the field saves on Enter; a Cancel button — or the
1406
1468
  > field's own blur — handles Escape), as above.
1407
1469
 
1470
+ A page-level chord (undo on ⌘Z/Ctrl+Z, save on `mod+s`) is a `shortcut:`
1471
+ rather than `event:` with `window: true`: it resolves `mod` per platform,
1472
+ leaves fields the user is typing in alone and prevents the chord (see
1473
+ *Page-level shortcuts* above).
1474
+
1408
1475
  ### Value-conditional visibility (`reactive_show`)
1409
1476
 
1410
1477
  `on_client` covers the *unconditional* client-only interactions; the last gap
@@ -1698,6 +1765,146 @@ list; a reply that morphs the list puts the rows in the server's order.
1698
1765
 
1699
1766
  Full guide: [Sortable lists](https://phlex-reactive.zoolutions.llc/docs/sortable-lists).
1700
1767
 
1768
+ ### Presence (`reactive_presence`)
1769
+
1770
+ Who is viewing a stream, one entry per browser tab, each tab with a small
1771
+ declared state (the item it has selected), on a roster component that
1772
+ re-renders itself in every viewer's tab when a tab joins, leaves, goes silent
1773
+ or changes its state. No controller code, no Stimulus controller.
1774
+
1775
+ ```ruby
1776
+ class Boards::Viewers < Phlex::HTML
1777
+ include Phlex::Reactive::Component
1778
+
1779
+ reactive_record :board
1780
+ reactive_presence :viewers, state: { selected: :integer }, ttl: 45
1781
+
1782
+ def initialize(board:) = @board = board
1783
+
1784
+ def view_template
1785
+ div(**reactive_root) do
1786
+ presence.members.each do |member| # one per person
1787
+ span(**reactive_presence_member(member)) { member.metadata["name"] }
1788
+ end
1789
+ @board.cards.each do |card|
1790
+ li(**on_client(:click, js.presence(selected: card.id))) do
1791
+ plain card.title
1792
+ presence.where(selected: card.id).each do |peer| # who is on this card
1793
+ span(**reactive_presence_peer(peer), class: "dot")
1794
+ end
1795
+ end
1796
+ end
1797
+ end
1798
+ end
1799
+ end
1800
+ ```
1801
+
1802
+ - **The stream** is `[record, :presence, name]` (a record-less roster passes
1803
+ `key: ["lobby"]`), named `"<16-char digest of the record (or the key: value)>:presence:<name>"`,
1804
+ so any model name and id fit pgbus's stream-name budget (41 chars with the
1805
+ default queue prefix). That leaves 15 characters for the presence name; a
1806
+ longer one raises `Phlex::Reactive::ConfigurationError` at render. The root
1807
+ subscribes to it by itself: the client mints a
1808
+ `<pgbus-stream-source>` from the root's signed src, so the page must load the
1809
+ pgbus client element (`import "pgbus/stream_source_element"`, which any
1810
+ `pgbus_stream_from` on the page already does).
1811
+ - **`presence`** is the roster as this render reads it: `peers` (one per tab:
1812
+ `seat`, `member_id`, `metadata`, `state`, `joined_at`), `members` (grouped by
1813
+ person: `id`, `metadata`, `seats`, `state`), `where(**state)`, `count`,
1814
+ `empty?`, and in an action `me` and `set(**state)`.
1815
+ - **State** is declared like action params; undeclared keys are dropped.
1816
+ `js.presence(selected: 7)` sends it from the browser through the generated
1817
+ `presence_set` action; `presence.set(selected: 7)` writes it from an action of
1818
+ your own. Either way only this tab's row changes and the other viewers get a
1819
+ morph. The generated action replies `reply.morph` for the actor; an action of
1820
+ your own must return `reply.morph` itself.
1821
+ - **A state-backed roster** (`reactive_state`): its state is stored on each
1822
+ seat row in `pgbus_presence_members` (in the note the re-render reads), and
1823
+ the roster renders as one piece of markup under one id for every viewer. So
1824
+ its `reactive_state` must be the same for every viewer; per-viewer state
1825
+ belongs in presence state (`state:`).
1826
+ - **Authorization**: `presence_set` skips `verify_authorized`. The signed token
1827
+ proves the actor rendered this root, and the write is typed, ephemeral, and
1828
+ lands only on the actor's own row, keyed by its SSE connection id, which
1829
+ only that tab knows. To check the value (the card must belong to the board),
1830
+ define `authorize_presence_state!(**state)` and raise; it runs first. That hook
1831
+ guards only the generated `presence_set`: an action of your own that calls
1832
+ `presence.set` authorizes its state itself. The client also sends `presence_set` with no state on every connect (the
1833
+ announce): that writes no value, so the hook is not called for it.
1834
+ - **`presence.me`** is the acting tab, and during an action it stays the actor
1835
+ while the roster renders for the other viewers too. Never branch the shared
1836
+ output on it; the client's `-me` marks are per tab. (Likewise
1837
+ `reactive_connection_id` is the first `<pgbus-stream-source>` on the page, so
1838
+ one loaded later, ahead of it in the DOM, can change it; presence writes use
1839
+ their own connection and are unaffected.)
1840
+ - **"Me"**: the roster renders once for everyone, so the client marks this
1841
+ tab's element `data-reactive-presence-me` and the same person's other
1842
+ elements `data-reactive-presence-same-member` after every morph. Style them.
1843
+ Both need this tab's `reactive_presence_peer` element on the roster: a roster
1844
+ of members only has none, so nothing is marked.
1845
+ - **Events**: `reactive:presence` fires on the root with
1846
+ `{ joined: [seat…], left: [seat…], me: seat }` when a morph changes who is
1847
+ there; a replayed frame that changes nothing fires nothing. The root names
1848
+ the seats its render saw (`data-reactive-presence-roster`), so this works for
1849
+ a roster that renders people, not tabs.
1850
+ - **Leaving**: a closed tab leaves within about a second (pgbus watches each
1851
+ SSE socket for the browser closing it). A tab that crashes or loses its
1852
+ network, so nothing closes the socket cleanly, leaves on the next failed
1853
+ heartbeat write (`streams_heartbeat_interval`, 15 s by default) or expires
1854
+ after `ttl:` seconds. A reconnect keeps the tab's seat id and re-sends its
1855
+ state, but pgbus drops the row on the disconnect, so unless the reconnect
1856
+ lands within one coalesce window (150 ms) peers see that seat leave and
1857
+ join again (`reactive:presence` reports both). A row that expires while its
1858
+ socket stays open (the tab missed its beats) is re-joined by the heartbeat
1859
+ without its state, until the tab's next `js.presence`. `ttl:` must be above the
1860
+ transport's heartbeat interval, or a live tab would expire between two
1861
+ beats: `reactive_presence` raises otherwise.
1862
+ - **Transport**: needs pgbus 0.18.1 or later (presence keyed by SSE
1863
+ connection, `pgbus:connected` only after the tab's seat joined,
1864
+ `config.streams_authorize`). Create the table with
1865
+ `rails g pgbus:add_presence`, or upgrade an older one with
1866
+ `rails g pgbus:add_presence_seats`. `ttl:` (45 by default) is written into
1867
+ pgbus's `streams_presence_ttl` under the stream's pattern. Action Cable has
1868
+ no connection identity: rendering a presence root without it raises
1869
+ `Phlex::Reactive::ConfigurationError`. Every other component is unaffected.
1870
+ - **Identity**: pgbus takes each tab's person from what
1871
+ `config.streams_authorize` returns for the stream. `nil` or `false` answers
1872
+ 403; `true` authorizes with no identity, and pgbus joins such a connection
1873
+ to no roster (the tab then warns in the console that it never appeared).
1874
+ Per-record authorization already happens at render: the signed src is
1875
+ minted only into a roster root your app chose to render for this user.
1876
+ `streams_authorize` is the revocation layer (the signed name proves pgbus
1877
+ minted it, not that this viewer may still read it: a removed member keeps a
1878
+ working name until the secret rotates). For a strict per-record check at
1879
+ connect time, compare `stream_name` with the presence key of each record the
1880
+ user may view (or give a record-less roster a `key:` with a prefix you can
1881
+ parse):
1882
+
1883
+ ```ruby
1884
+ # config/initializers/pgbus.rb
1885
+ Pgbus.configure do |config|
1886
+ config.streams_authorize = ->(env, stream_name) {
1887
+ user = env["warden"]&.user
1888
+ next unless user&.active?
1889
+ next unless user.may_stream?(stream_name) # keep your app's own check for every stream
1890
+
1891
+ if stream_name.end_with?(":presence:viewers")
1892
+ next unless user.boards.any? { Boards::Viewers.reactive_presence_stream_name_for(it) == stream_name }
1893
+ end
1894
+ { id: user.id, metadata: { name: user.name } }
1895
+ }
1896
+ end
1897
+ ```
1898
+
1899
+ A presence stream's name is a digest (`"<16 hex>:presence:<name>"`), so the
1900
+ record cannot be read back from it: `reactive_presence_stream_name_for`
1901
+ computes it from the record. A User works too (pgbus reads its `#id`); shape
1902
+ the member from it with
1903
+ `config.streams_presence_member = ->(user) { { id: user.id, metadata: { name: user.name } } }`.
1904
+ - **Jobs**: a join, a leave and an expiry re-render the roster in
1905
+ `Phlex::Reactive::PresenceRenderJob` (on `defer_job_queue`), so the app needs
1906
+ an ActiveJob adapter that runs jobs. A state change renders in the action.
1907
+
1701
1908
  ### Client-only drafts (`reactive_persist`)
1702
1909
 
1703
1910
  "Don't make me start over": a public application form, a multi-step wizard,
@@ -2858,8 +3065,8 @@ Notifications::Badge.broadcast_to(user, :alerts,
2858
3065
  ```
2859
3066
 
2860
3067
  `broadcast_to` with `js:` **refuses the actor-only ops** (`focus`/`focus_first`/
2861
- `submit`/`paste_into`/`persist_state`/`persist_clear`/`check_group` raise
2862
- `ArgumentError`): broadcasting focus would steal it in every subscriber's tab, a
3068
+ `show_modal`/`close_dialog`/`submit`/`paste_into`/`persist_state`/`persist_clear`/`check_group` raise
3069
+ `ArgumentError`): broadcasting focus (or a modal) would steal it in every subscriber's tab, a
2863
3070
  broadcast submit would force-submit every subscriber's form, and a broadcast
2864
3071
  clipboard read, draft write or selection change would be hostile — these
2865
3072
  belong to the actor's own reply or gesture. Everything else is a fair broadcast
@@ -347,24 +347,34 @@ module Phlex
347
347
  # Phlex::Reactive::Response survives to response_streams.
348
348
  def run_action(component, action_def, coerced)
349
349
  Phlex::Reactive.with_connection_id(request.headers["X-Pgbus-Connection"]) do
350
- with_around_actions(component, action_def, coerced) do
351
- transaction_wrapper do
352
- # verify_authorized (issue #168): open the tracking window, run the
353
- # action, then verify INSIDE the transaction so an unverified action
354
- # rolls its mutation back (fail-closed). with_tracking is a bare
355
- # yield's-worth of overhead; verify! is a no-op when the feature is
356
- # off / the action is skipped / something marked. The interceptor
357
- # (create_action's instrument!) marks the window on a real
358
- # authorization call.
359
- Phlex::Reactive::Authorization.with_tracking do
360
- result = if coerced.any?
361
- component.public_send(action_def.name, **coerced)
362
- else
363
- component.public_send(action_def.name)
364
- end
365
- Phlex::Reactive::Authorization.verify!(component.class, action_def)
366
- result
367
- end
350
+ # Issue #358: a presence root also sends its presence connection, the
351
+ # row a presence write lands on.
352
+ Phlex::Reactive::Presence.with_connection_id(request.headers[Phlex::Reactive::Presence::HEADER]) do
353
+ run_action_wrapped(component, action_def, coerced)
354
+ end
355
+ end
356
+ end
357
+
358
+ # The action inside the around_action stack, the transaction and the
359
+ # verify_authorized window (see run_action).
360
+ def run_action_wrapped(component, action_def, coerced)
361
+ with_around_actions(component, action_def, coerced) do
362
+ transaction_wrapper do
363
+ # verify_authorized (issue #168): open the tracking window, run the
364
+ # action, then verify INSIDE the transaction so an unverified action
365
+ # rolls its mutation back (fail-closed). with_tracking is a bare
366
+ # yield's-worth of overhead; verify! is a no-op when the feature is
367
+ # off / the action is skipped / something marked. The interceptor
368
+ # (create_action's instrument!) marks the window on a real
369
+ # authorization call.
370
+ Phlex::Reactive::Authorization.with_tracking do
371
+ result = if coerced.any?
372
+ component.public_send(action_def.name, **coerced)
373
+ else
374
+ component.public_send(action_def.name)
375
+ end
376
+ Phlex::Reactive::Authorization.verify!(component.class, action_def)
377
+ result
368
378
  end
369
379
  end
370
380
  end
@@ -27,6 +27,7 @@ registerReactiveFeatureLoader("form", () => import("phlex/reactive/features/form
27
27
  registerReactiveFeatureLoader("bindings", () => import("phlex/reactive/features/bindings"))
28
28
  registerReactiveFeatureLoader("compute", () => import("phlex/reactive/features/compute"), waitingForEdit)
29
29
  registerReactiveFeatureLoader("sortable", () => import("phlex/reactive/features/sortable"))
30
+ registerReactiveFeatureLoader("presence", () => import("phlex/reactive/features/presence"))
30
31
  registerReactiveFeatureLoader("effects", () => import("phlex/reactive/features/effects"))
31
32
  registerReactiveFeatureLoader("hints", () => import("phlex/reactive/features/hints"))
32
33
  registerReactiveFeatureLoader("devtools", () => import("phlex/reactive/features/devtools"))
@@ -146,7 +147,9 @@ onReactiveStreamWithoutEffects((event, loadedEffects) => {
146
147
 
147
148
  // The reactive roots a stream is about to put on the page: the roots in its
148
149
  // template (the template's own root element included), and the content
149
- // itself when it brings an editable.
150
+ // itself — read as part of the root already on the page it renders into,
151
+ // which re-reads its markers once the render has run (issue #356; an editable
152
+ // it brings, #351). Held for the module, the new content lands wired.
150
153
  function incomingRoots(streamEl) {
151
154
  const content = streamEl?.querySelector?.("template")?.content
152
155
  if (!content) return []
@@ -154,10 +157,7 @@ function incomingRoots(streamEl) {
154
157
  for (const child of content.children ?? []) {
155
158
  if (child.matches?.('[data-controller~="reactive"]') && !roots.includes(child)) roots.unshift(child)
156
159
  }
157
- // An editable appended into a root already on the page (issue #351): that
158
- // root loads the bindings module when the editable is first focused, so
159
- // start the import now — the module is then here before anything is typed.
160
- if (content.querySelector?.("[data-reactive-editable]")) roots.push(content)
160
+ roots.push(content)
161
161
  return roots
162
162
  }
163
163