phlex-reactive 0.14.4 → 0.15.0

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 +70 -0
  3. data/README.md +191 -1
  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 +28 -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 +175 -14
  27. data/lib/phlex/reactive/component/dsl.rb +5 -0
  28. data/lib/phlex/reactive/component/helpers.rb +79 -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 +24 -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 +105 -0
  41. data/lib/phlex/reactive/streamable.rb +7 -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: eeebc2783f571e3e3fcc0847318ebef76050954e0e56a99f627e8e5654467133
4
+ data.tar.gz: 974cc0de7e79babb8834bf41a10001e69a087bda3daf6a87122cae7654e128cb
5
5
  SHA512:
6
- metadata.gz: 79b0d752c36664dc374c69944d127556732864b3a8582ff68ae29d76ca70ad18289d60a375d99671697f91e5609f27e6cc4b7718240dc9fc632ac2425ecae671
7
- data.tar.gz: 0d19985aa77283b419e815e35691c626f4646356fc04282f5441b4f434b2b15c6a052ade40b1bde8442bead4eb3b3f1900a15cb937dfe9132a9af2b0b16cb57b
6
+ metadata.gz: f95657cd231a4e711d6300fae52cbefacb42f34d5803f98073366710855a7c5906e795844800c55e8a6ee2b089224809bacb739c0c90f1a89c97c23851c2c7e7
7
+ data.tar.gz: 0b47dacc177e41d086e045ec842879358c68d4ec298615c1fcb6e23fc8cdb8b53ce5db96fa4fe2424b1fdaa381870fd29ce6f50b1ac70b7375790590e1eae793
data/CHANGELOG.md CHANGED
@@ -8,6 +8,49 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Presence — `reactive_presence`, `presence`, `js.presence` (#358).** A
12
+ roster component declares the presence stream it shows
13
+ (`reactive_presence :viewers, state: { selected: :integer }, ttl: 45`) and
14
+ reads it with `presence`: one peer per browser tab (`seat`, `member_id`,
15
+ `metadata`, `state`), grouped by person with `members`, filtered with
16
+ `where(selected: id)`. Tabs join on SSE connect, leave on disconnect and
17
+ expire after `ttl:` seconds of silence; each change re-renders the roster for
18
+ every viewer (one coalesced morph). Each tab's declared state is set with
19
+ `js.presence(**state)` (through the generated `presence_set` action, typed,
20
+ default-deny, and exempt from `verify_authorized` unless the component defines
21
+ `authorize_presence_state!`) or `presence.set(**state)` from an action. The
22
+ client marks this tab's element `data-reactive-presence-me` and the same
23
+ person's other elements `data-reactive-presence-same-member`, and fires
24
+ `reactive:presence` with the seats that joined and left. A new `presence`
25
+ feature module (1.3 KB gzipped): the default client bundles it (+992 B), the
26
+ split client fetches it only for a page with a presence root (+100 B in the
27
+ core). Needs pgbus 0.18.1 or later; without it a presence root raises
28
+ `Phlex::Reactive::ConfigurationError` at render. Create the table with
29
+ `rails g pgbus:add_presence` (or upgrade it with
30
+ `rails g pgbus:add_presence_seats`), return each viewer's identity from
31
+ `config.streams_authorize` (with none, pgbus joins the connection to no
32
+ roster), and run an ActiveJob adapter (joins, leaves and expiries render in
33
+ `PresenceRenderJob`). A closed tab leaves within about a second.
34
+ `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".
35
+
36
+ - **`shortcut:` on `on()` and `on_client(:keydown, …)` — page-level keyboard
37
+ shortcuts (#357).** `on(:undo, shortcut: "mod+z")` binds a window keydown
38
+ where `mod` is ⌘ on Apple platforms and Ctrl elsewhere (rendered as a meta
39
+ twin and a ctrl twin; the client runs its platform's, the other is a no-op).
40
+ The shortcut does nothing while focus is in an `input`, `textarea`, `select`
41
+ or anything `contenteditable`, so native text undo keeps working
42
+ (`shortcut: { keys: "esc", in_editable: true }` opts in), and a matching
43
+ chord is `preventDefault`-ed before any `confirm:` dialog, also when pressed
44
+ before the controller connects (`phlex/reactive/early`). `shortcut:` with
45
+ `event:`, `window:` or `outside:`, a key outside Stimulus's default key map,
46
+ or an `on_client` event other than `:keydown` raises `ArgumentError` at
47
+ render. The inspector shows `keydown@window shortcut=mod+z`. `early.min.js`
48
+ grows 195 B gzipped (1,491 B); the default client 177 B.
49
+ **Compatibility:** `shortcut` is now a reserved keyword on `on(...)` and no
50
+ longer passes through as an action param. `event: "keydown.meta+z", window:
51
+ true` without `shortcut:` behaves exactly as before (never prevented, no
52
+ editable guard).
53
+
11
54
  - **Sortable lists — `reactive_sortable`, `reactive_sortable_source`,
12
55
  `reactive_sortable_item` (#350).** Rows drag to reorder a list with a mouse,
13
56
  a finger (a 200 ms press) or the keyboard (Space picks up, arrows move,
@@ -58,6 +101,33 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
58
101
 
59
102
  ### Fixed
60
103
 
104
+ - **A stream that renders into a connected root wires what its content needs
105
+ (#356).** A root read its feature markers only at connect and after a morph
106
+ of itself, so a `reactive_show` target, select-all/count group, filter,
107
+ nested list, dirty field or paste trigger that an `append`, `prepend`,
108
+ `update`, `before`, `after` or child `replace` brought into it stayed dead
109
+ until the root reconnected. Now, once such a stream has rendered, the root
110
+ re-runs the seeds of the features it has (a new show target takes the
111
+ field's current value) and connects any feature its new content needs, in
112
+ both clients; bindings and form also wire a binding whose gate reads true
113
+ only then. As after a morph, this covers a root with an action token or a
114
+ lazy shell; a tokenless, client-only root does not re-read. On the split
115
+ client the stream's render waits for the module the content needs, as it
116
+ already did for an incoming root, so the content lands wired; the wait is
117
+ capped at 1 s, after which Turbo renders and the root connects the module
118
+ when it arrives. A draft
119
+ restore no longer marks fields `data-reactive-dirty` on a root that never
120
+ opted into dirty tracking (one that only gates a paste trigger).
121
+ - **An editable is editable in an engine without `contenteditable="plaintext-only"`,
122
+ however it reaches the page (#355).** Such an engine reads the server's
123
+ `plaintext-only` as invalid, so the element cannot even be focused. The
124
+ bindings feature switched it to `"true"` only when it connected, or after a
125
+ morph of the root, which left an editable that a stream appended, a frame
126
+ loaded or an unfocused morph rewrote uneditable. The runtime now does the
127
+ switch with one observer, installed only in such an engine (elsewhere the
128
+ probe is all that runs). Bundle: default +157 B gzip, split core +241 B,
129
+ `features/bindings` -138 B.
130
+
61
131
  - **A `checked:`-only `reactive_show_targets` entry hides at zero boxes
62
132
  (#348).** `"#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } }` was left
63
133
  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
@@ -936,6 +941,46 @@ 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:`). On `on_client` the event must be `:keydown`.
976
+ `shortcut` is now a **reserved keyword** on `on(...)`.
977
+
978
+ `once: true` on a shortcut unbinds the Stimulus descriptor after its first
979
+ firing, and a press inside a field counts: the guard leaves the chord to the
980
+ browser, but the `:once` listener is already spent. Keep `once:` off a
981
+ shortcut that can be pressed while the user types, or pair it with
982
+ `in_editable: true`.
983
+
939
984
  ### Optimistic visual hints (`optimistic:`)
940
985
 
941
986
  Every reactive action waits a full round trip for its visual change — and it's
@@ -1405,6 +1450,11 @@ free as an ordinary action-param name (`on(:switch, key: "pgbus")` still passes
1405
1450
  > trigger to its own element (the field saves on Enter; a Cancel button — or the
1406
1451
  > field's own blur — handles Escape), as above.
1407
1452
 
1453
+ A page-level chord (undo on ⌘Z/Ctrl+Z, save on `mod+s`) is a `shortcut:`
1454
+ rather than `event:` with `window: true`: it resolves `mod` per platform,
1455
+ leaves fields the user is typing in alone and prevents the chord (see
1456
+ *Page-level shortcuts* above).
1457
+
1408
1458
  ### Value-conditional visibility (`reactive_show`)
1409
1459
 
1410
1460
  `on_client` covers the *unconditional* client-only interactions; the last gap
@@ -1698,6 +1748,146 @@ list; a reply that morphs the list puts the rows in the server's order.
1698
1748
 
1699
1749
  Full guide: [Sortable lists](https://phlex-reactive.zoolutions.llc/docs/sortable-lists).
1700
1750
 
1751
+ ### Presence (`reactive_presence`)
1752
+
1753
+ Who is viewing a stream, one entry per browser tab, each tab with a small
1754
+ declared state (the item it has selected), on a roster component that
1755
+ re-renders itself in every viewer's tab when a tab joins, leaves, goes silent
1756
+ or changes its state. No controller code, no Stimulus controller.
1757
+
1758
+ ```ruby
1759
+ class Boards::Viewers < Phlex::HTML
1760
+ include Phlex::Reactive::Component
1761
+
1762
+ reactive_record :board
1763
+ reactive_presence :viewers, state: { selected: :integer }, ttl: 45
1764
+
1765
+ def initialize(board:) = @board = board
1766
+
1767
+ def view_template
1768
+ div(**reactive_root) do
1769
+ presence.members.each do |member| # one per person
1770
+ span(**reactive_presence_member(member)) { member.metadata["name"] }
1771
+ end
1772
+ @board.cards.each do |card|
1773
+ li(**on_client(:click, js.presence(selected: card.id))) do
1774
+ plain card.title
1775
+ presence.where(selected: card.id).each do |peer| # who is on this card
1776
+ span(**reactive_presence_peer(peer), class: "dot")
1777
+ end
1778
+ end
1779
+ end
1780
+ end
1781
+ end
1782
+ end
1783
+ ```
1784
+
1785
+ - **The stream** is `[record, :presence, name]` (a record-less roster passes
1786
+ `key: ["lobby"]`), named `"<16-char digest of the record (or the key: value)>:presence:<name>"`,
1787
+ so any model name and id fit pgbus's stream-name budget (41 chars with the
1788
+ default queue prefix). That leaves 15 characters for the presence name; a
1789
+ longer one raises `Phlex::Reactive::ConfigurationError` at render. The root
1790
+ subscribes to it by itself: the client mints a
1791
+ `<pgbus-stream-source>` from the root's signed src, so the page must load the
1792
+ pgbus client element (`import "pgbus/stream_source_element"`, which any
1793
+ `pgbus_stream_from` on the page already does).
1794
+ - **`presence`** is the roster as this render reads it: `peers` (one per tab:
1795
+ `seat`, `member_id`, `metadata`, `state`, `joined_at`), `members` (grouped by
1796
+ person: `id`, `metadata`, `seats`, `state`), `where(**state)`, `count`,
1797
+ `empty?`, and in an action `me` and `set(**state)`.
1798
+ - **State** is declared like action params; undeclared keys are dropped.
1799
+ `js.presence(selected: 7)` sends it from the browser through the generated
1800
+ `presence_set` action; `presence.set(selected: 7)` writes it from an action of
1801
+ your own. Either way only this tab's row changes and the other viewers get a
1802
+ morph. The generated action replies `reply.morph` for the actor; an action of
1803
+ your own must return `reply.morph` itself.
1804
+ - **A state-backed roster** (`reactive_state`): its state is stored on each
1805
+ seat row in `pgbus_presence_members` (in the note the re-render reads), and
1806
+ the roster renders as one piece of markup under one id for every viewer. So
1807
+ its `reactive_state` must be the same for every viewer; per-viewer state
1808
+ belongs in presence state (`state:`).
1809
+ - **Authorization**: `presence_set` skips `verify_authorized`. The signed token
1810
+ proves the actor rendered this root, and the write is typed, ephemeral, and
1811
+ lands only on the actor's own row, keyed by its SSE connection id, which
1812
+ only that tab knows. To check the value (the card must belong to the board),
1813
+ define `authorize_presence_state!(**state)` and raise; it runs first. That hook
1814
+ guards only the generated `presence_set`: an action of your own that calls
1815
+ `presence.set` authorizes its state itself. The client also sends `presence_set` with no state on every connect (the
1816
+ announce): that writes no value, so the hook is not called for it.
1817
+ - **`presence.me`** is the acting tab, and during an action it stays the actor
1818
+ while the roster renders for the other viewers too. Never branch the shared
1819
+ output on it; the client's `-me` marks are per tab. (Likewise
1820
+ `reactive_connection_id` is the first `<pgbus-stream-source>` on the page, so
1821
+ one loaded later, ahead of it in the DOM, can change it; presence writes use
1822
+ their own connection and are unaffected.)
1823
+ - **"Me"**: the roster renders once for everyone, so the client marks this
1824
+ tab's element `data-reactive-presence-me` and the same person's other
1825
+ elements `data-reactive-presence-same-member` after every morph. Style them.
1826
+ Both need this tab's `reactive_presence_peer` element on the roster: a roster
1827
+ of members only has none, so nothing is marked.
1828
+ - **Events**: `reactive:presence` fires on the root with
1829
+ `{ joined: [seat…], left: [seat…], me: seat }` when a morph changes who is
1830
+ there; a replayed frame that changes nothing fires nothing. The root names
1831
+ the seats its render saw (`data-reactive-presence-roster`), so this works for
1832
+ a roster that renders people, not tabs.
1833
+ - **Leaving**: a closed tab leaves within about a second (pgbus watches each
1834
+ SSE socket for the browser closing it). A tab that crashes or loses its
1835
+ network, so nothing closes the socket cleanly, leaves on the next failed
1836
+ heartbeat write (`streams_heartbeat_interval`, 15 s by default) or expires
1837
+ after `ttl:` seconds. A reconnect keeps the tab's seat id and re-sends its
1838
+ state, but pgbus drops the row on the disconnect, so unless the reconnect
1839
+ lands within one coalesce window (150 ms) peers see that seat leave and
1840
+ join again (`reactive:presence` reports both). A row that expires while its
1841
+ socket stays open (the tab missed its beats) is re-joined by the heartbeat
1842
+ without its state, until the tab's next `js.presence`. `ttl:` must be above the
1843
+ transport's heartbeat interval, or a live tab would expire between two
1844
+ beats: `reactive_presence` raises otherwise.
1845
+ - **Transport**: needs pgbus 0.18.1 or later (presence keyed by SSE
1846
+ connection, `pgbus:connected` only after the tab's seat joined,
1847
+ `config.streams_authorize`). Create the table with
1848
+ `rails g pgbus:add_presence`, or upgrade an older one with
1849
+ `rails g pgbus:add_presence_seats`. `ttl:` (45 by default) is written into
1850
+ pgbus's `streams_presence_ttl` under the stream's pattern. Action Cable has
1851
+ no connection identity: rendering a presence root without it raises
1852
+ `Phlex::Reactive::ConfigurationError`. Every other component is unaffected.
1853
+ - **Identity**: pgbus takes each tab's person from what
1854
+ `config.streams_authorize` returns for the stream. `nil` or `false` answers
1855
+ 403; `true` authorizes with no identity, and pgbus joins such a connection
1856
+ to no roster (the tab then warns in the console that it never appeared).
1857
+ Per-record authorization already happens at render: the signed src is
1858
+ minted only into a roster root your app chose to render for this user.
1859
+ `streams_authorize` is the revocation layer (the signed name proves pgbus
1860
+ minted it, not that this viewer may still read it: a removed member keeps a
1861
+ working name until the secret rotates). For a strict per-record check at
1862
+ connect time, compare `stream_name` with the presence key of each record the
1863
+ user may view (or give a record-less roster a `key:` with a prefix you can
1864
+ parse):
1865
+
1866
+ ```ruby
1867
+ # config/initializers/pgbus.rb
1868
+ Pgbus.configure do |config|
1869
+ config.streams_authorize = ->(env, stream_name) {
1870
+ user = env["warden"]&.user
1871
+ next unless user&.active?
1872
+ next unless user.may_stream?(stream_name) # keep your app's own check for every stream
1873
+
1874
+ if stream_name.end_with?(":presence:viewers")
1875
+ next unless user.boards.any? { Boards::Viewers.reactive_presence_stream_name_for(it) == stream_name }
1876
+ end
1877
+ { id: user.id, metadata: { name: user.name } }
1878
+ }
1879
+ end
1880
+ ```
1881
+
1882
+ A presence stream's name is a digest (`"<16 hex>:presence:<name>"`), so the
1883
+ record cannot be read back from it: `reactive_presence_stream_name_for`
1884
+ computes it from the record. A User works too (pgbus reads its `#id`); shape
1885
+ the member from it with
1886
+ `config.streams_presence_member = ->(user) { { id: user.id, metadata: { name: user.name } } }`.
1887
+ - **Jobs**: a join, a leave and an expiry re-render the roster in
1888
+ `Phlex::Reactive::PresenceRenderJob` (on `defer_job_queue`), so the app needs
1889
+ an ActiveJob adapter that runs jobs. A state change renders in the action.
1890
+
1701
1891
  ### Client-only drafts (`reactive_persist`)
1702
1892
 
1703
1893
  "Don't make me start over": a public application form, a multi-step wizard,
@@ -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