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
@@ -127,6 +127,10 @@ module Phlex
127
127
  (on_complete = self.class.reactive_on_complete_attr)
128
128
  data[:reactive_on_complete] = on_complete
129
129
  end
130
+ # Presence (issue #358): the signed src of the roster's presence
131
+ # stream, from which the client mints its subscription. Only a class
132
+ # that declares reactive_presence has the method.
133
+ data.merge!(reactive_presence_data) if respond_to?(:reactive_presence_data)
130
134
  { data: }
131
135
  end
132
136
 
@@ -280,6 +284,17 @@ module Phlex
280
284
  # (trailing-edge) — passing both raises ArgumentError.
281
285
  # div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 250)))
282
286
  #
287
+ # `shortcut:` (issue #357, also RESERVED) binds a page-level keyboard
288
+ # chord: a window keydown whose `mod` is meta on Apple platforms and
289
+ # ctrl elsewhere (rendered as a meta twin and a ctrl twin; the client
290
+ # runs the one for its platform). Unlike a plain window binding, the
291
+ # client prevents the chord's native default, and it skips the chord
292
+ # while focus is in an input, textarea, select or contenteditable
293
+ # (native text undo keeps working) unless the Hash form opts in. It
294
+ # decides event:, window: and outside:, so passing one raises.
295
+ # span(**on(:undo, shortcut: "mod+z"))
296
+ # span(**on(:close, shortcut: { keys: "esc", in_editable: true }))
297
+ #
283
298
  # `optimistic:` (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of
284
299
  # COSMETIC hints the client applies the instant the trigger fires and
285
300
  # REVERTS if the round trip fails, so a click/toggle gives instant feedback
@@ -324,9 +339,9 @@ module Phlex
324
339
  # busy_on for scoped indicators.
325
340
  # button(**on(:save, busy: "Saving…")) { "Save" }
326
341
  # button(**on(:destroy, confirm: "Sure?", busy: { add_class: "opacity-50" })) { "Delete" }
327
- def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil,
342
+ def on(action_name, event: nil, debounce: nil, throttle: nil, confirm: nil,
328
343
  window: false, once: false, outside: false, optimistic: nil, busy: nil,
329
- **params)
344
+ shortcut: nil, **params)
330
345
  reject_removed_on_kwargs!(action_name, params)
331
346
  # A typo'd or forgotten action renders fine and only surfaces as an
332
347
  # opaque 403 at CLICK time (the endpoint's default-deny). Under
@@ -362,8 +377,11 @@ module Phlex
362
377
  "exclusive (debounce is trailing-edge, throttle is leading-edge); pick one"
363
378
  end
364
379
 
365
- window_bound = window || outside
366
- action = "#{event}#{"@window" if window_bound}->reactive#dispatch#{":once" if once}"
380
+ # Guarded here, not inside: building the caller label allocates (on() is a hot path).
381
+ shortcut = shortcut_for!("on(#{action_name.inspect})", shortcut, event:, window:, outside:) unless shortcut.nil?
382
+ event ||= "click"
383
+ window_bound = window || outside || !shortcut.nil?
384
+ action = trigger_descriptors(event, shortcut, window_bound, "dispatch", once)
367
385
  attrs = {
368
386
  data: {
369
387
  action:,
@@ -384,6 +402,9 @@ module Phlex
384
402
  # The client decides preventDefault behavior from event.params (never by
385
403
  # sniffing the descriptor), so EVERY window binding flags the param.
386
404
  attrs[:data][:reactive_window_param] = "true" if window_bound
405
+ # Issue #357: the client reads the shortcut to pick the twin for its
406
+ # platform, skip editable targets and prevent the chord.
407
+ attrs[:data][:reactive_shortcut_param] = shortcut_param_attr(shortcut) if shortcut
387
408
  # Force type="button" for click triggers so a bare button inside a <form>
388
409
  # can't submit it — EXCEPT when checked: :keep is declared: that hint's
389
410
  # whole point is to let a click-bound checkbox/radio flip natively, and a
@@ -406,7 +427,9 @@ module Phlex
406
427
  # modifiers (#80): outside-click-to-close a dropdown is
407
428
  # div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true)))
408
429
  # Window-bound triggers are never preventDefault-ed by the client and skip
409
- # the forced type="button".
430
+ # the forced type="button". `shortcut:` (issue #357) works as on()'s, on
431
+ # a bare :keydown only:
432
+ # div(**mix(reactive_root, on_client(:keydown, js.toggle("#palette"), shortcut: "mod+k")))
410
433
  #
411
434
  # Issue #271: each call emits ONE self-describing binding record —
412
435
  # data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]}'
@@ -437,7 +460,8 @@ module Phlex
437
460
  # Validation is loud: only a non-empty Phlex::Reactive::JS chain is
438
461
  # accepted — a dead trigger should fail at render, not no-op in the
439
462
  # browser.
440
- def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil)
463
+ def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
464
+ shortcut: nil)
441
465
  unless ops.is_a?(Phlex::Reactive::JS)
442
466
  raise ArgumentError,
443
467
  "on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
@@ -454,11 +478,15 @@ module Phlex
454
478
  "submit event this trigger is bound to. Bind the submit op to another event " \
455
479
  "(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
456
480
  end
457
- window_bound = window || outside
481
+ shortcut = client_shortcut!(event, shortcut, window:, outside:)
482
+ window_bound = window || outside || !shortcut.nil?
458
483
  record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
484
+ # The record's `on` stays the bare keydown: the client reads a key
485
+ # filter from `on`, and `mod` is not a key.
486
+ record["shortcut"] = shortcut.param if shortcut
459
487
  attrs = {
460
488
  data: {
461
- action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
489
+ action: trigger_descriptors(event, shortcut, window_bound, "runOps", once),
462
490
  # Space-free (issue #271): compact JSON has no structural
463
491
  # whitespace, so every space is inside a string, where \u0020
464
492
  # parses to the same string — and mix's space-join stays splittable.
@@ -1732,6 +1760,64 @@ module Phlex
1732
1760
  "on(#{action_name.inspect}) #{key}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[key]}"
1733
1761
  end
1734
1762
 
1763
+ # Issue #357: parse on()'s shortcut: (nil when absent). A shortcut
1764
+ # decides the event, the window binding and the containment rule, so
1765
+ # an explicit event:/window:/outside: next to it is a mistake.
1766
+ def shortcut_for!(caller, shortcut, event:, window:, outside:)
1767
+ { "event:" => event, "window:" => window, "outside:" => outside }.each do |name, value|
1768
+ next unless value
1769
+
1770
+ raise ArgumentError,
1771
+ "#{caller} got shortcut: with #{name} — a shortcut is always a window keydown " \
1772
+ "binding; drop #{name}"
1773
+ end
1774
+ Phlex::Reactive::Shortcut.parse(shortcut)
1775
+ end
1776
+
1777
+ # on_client's event is positional: with shortcut: it must be the bare
1778
+ # keydown (the key filter comes from the shortcut).
1779
+ def client_shortcut!(event, shortcut, window:, outside:)
1780
+ return if shortcut.nil?
1781
+
1782
+ unless event == "keydown"
1783
+ raise ArgumentError,
1784
+ "on_client(#{event.inspect}) got shortcut: — bind it to :keydown " \
1785
+ "(on_client(:keydown, ops, shortcut: \"mod+k\")); the key comes from the shortcut"
1786
+ end
1787
+ shortcut_for!("on_client(:keydown)", shortcut, event: nil, window:, outside:)
1788
+ end
1789
+
1790
+ # The data-action descriptors of an on()/on_client trigger: one, or a
1791
+ # shortcut's meta and ctrl twins (space-joined, as mix joins them).
1792
+ # The common, shortcut-less path is ONE interpolation (on() is a hot path).
1793
+ # A chord whose key Stimulus lacks (issue #366: delete, backspace) binds a
1794
+ # bare window keydown: the client's shortcut check matches it alone.
1795
+ def trigger_descriptors(event, shortcut, window_bound, method, once)
1796
+ return "#{event}#{"@window" if window_bound}->reactive##{method}#{":once" if once}" unless shortcut
1797
+ return bare_shortcut_descriptor(shortcut, method, once) unless shortcut.stimulus_filterable?
1798
+
1799
+ shortcut.filters.map { "keydown.#{it}@window->reactive##{method}#{":once" if once}" }.join(" ")
1800
+ end
1801
+
1802
+ # Stimulus removes a `:once` listener after its FIRST event, and a bare
1803
+ # keydown hears every key, so the first keypress of any kind would spend it.
1804
+ def bare_shortcut_descriptor(shortcut, method, once)
1805
+ if once
1806
+ raise ArgumentError,
1807
+ "shortcut: #{shortcut.keys.inspect} cannot take once: — its key is matched by the " \
1808
+ "library, not Stimulus, so the first key pressed of any kind would spend the binding"
1809
+ end
1810
+
1811
+ "keydown@window->reactive##{method}"
1812
+ end
1813
+
1814
+ # A Hash param is JSON (Stimulus's param reader parses it back into an
1815
+ # object); the String form stays a plain string.
1816
+ def shortcut_param_attr(shortcut)
1817
+ param = shortcut.param
1818
+ param.is_a?(Hash) ? param.to_json : param
1819
+ end
1820
+
1735
1821
  # Normalize + validate a pending-state hint (issue #181), returning its
1736
1822
  # JSON wire form. ONE vocabulary + ONE normalizer for both optimistic: (kind
1737
1823
  # :optimistic, reverts on FAILURE) and busy: (kind :busy, reverts on
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # The reactive_presence macro (issue #358). Not named `Presence`: this
7
+ # module is in every reactive component's constant lookup, where a bare
8
+ # noun would shadow a host app's kit component of that name (the #233
9
+ # rule). The behaviour it adds lives in Phlex::Reactive::Presence.
10
+ module PresenceDeclaration
11
+ extend ActiveSupport::Concern
12
+
13
+ # A presence name is the last part of the stream name, so it is one
14
+ # identifier: no colon (the stream-name separator), no spaces.
15
+ PRESENCE_NAME = /\A[a-z][a-z0-9_]*\z/
16
+
17
+ class_methods do
18
+ # Declare this component the roster of a presence stream:
19
+ # [record, :presence, name], or [*key, :presence, name] for a
20
+ # record-less component.
21
+ #
22
+ # reactive_record :board
23
+ # reactive_presence :viewers, state: { selected: :integer }, ttl: 45
24
+ #
25
+ # `state:` is each tab's ephemeral state, typed with the same table as
26
+ # action params; undeclared keys are dropped. `ttl:` is how many
27
+ # seconds a silent tab survives. Adds the presence_set action (what
28
+ # js.presence sends) and `presence` (the roster) to the component.
29
+ def reactive_presence(name, state: {}, ttl: Phlex::Reactive::Presence::DEFAULT_TTL, key: nil)
30
+ require_server_actions!(:reactive_presence)
31
+ declaration = build_presence_declaration(name, state, ttl, key)
32
+ refuse_dormant_presence! if reactive_dormant?
33
+ # The name's fit in pgbus's stream-name budget is configuration (the
34
+ # queue prefix), so it is refused here; the render re-checks it.
35
+ transport = Phlex::Reactive::Presence::Transport
36
+ if transport.wired? && !transport.fits?(declaration.name)
37
+ raise Phlex::Reactive::ConfigurationError, transport.over_budget_message(self, declaration.name)
38
+ end
39
+
40
+ # The generated action. Exempt from verify_authorized: the signed
41
+ # token already proves the actor rendered this root, and the write
42
+ # is typed, default-deny, ephemeral and lands only on the actor's own
43
+ # row (keyed by its secret SSE connection id). A component that needs
44
+ # a check defines authorize_presence_state!(**state); the action runs
45
+ # it first.
46
+ action :presence_set, params: declaration.state
47
+ skip_verify_authorized :presence_set
48
+ Registry.write_scalar(self, :presence, declaration)
49
+ include Phlex::Reactive::Presence::ComponentMethods
50
+
51
+ # Class load runs before a database exists: the signatures alone gate it.
52
+ transport.register_ttl(declaration.name, ttl) if transport.wired?
53
+ declaration
54
+ end
55
+
56
+ # The Phlex::Reactive::Presence::Declaration, or nil.
57
+ def reactive_presence_declaration
58
+ Registry.resolve_scalar(self, :presence, :reactive_presence_declaration)
59
+ end
60
+
61
+ # The presence stream name a roster of `record` subscribes to — what
62
+ # streams_authorize sees. For a strict per-record check at connect
63
+ # time: compare it with the key of each record the user may view.
64
+ # A record-less roster (key:) ignores `record`.
65
+ def reactive_presence_stream_name_for(record = nil)
66
+ declaration = reactive_presence_declaration
67
+ subject = declaration.key || [record]
68
+ Phlex::Reactive::Presence::Transport.stream_name([*subject, :presence, declaration.name])
69
+ end
70
+
71
+ private
72
+
73
+ def build_presence_declaration(name, state, ttl, key)
74
+ name = name.to_s
75
+ unless PRESENCE_NAME.match?(name)
76
+ raise ArgumentError,
77
+ "#{self.name}: reactive_presence name must be a lowercase identifier (it ends the stream " \
78
+ "name), got #{name.inspect}"
79
+ end
80
+ unless ttl.is_a?(Integer) && ttl.positive?
81
+ raise ArgumentError, "#{self.name}: reactive_presence ttl: must be a positive Integer (seconds), " \
82
+ "got #{ttl.inspect}"
83
+ end
84
+ refuse_ttl_within_heartbeat!(ttl)
85
+
86
+ state = state.transform_keys(&:to_sym)
87
+ if state.key?(Phlex::Reactive::Presence::NOTE_KEY.to_sym)
88
+ raise ArgumentError,
89
+ "#{self.name}: reactive_presence state key #{Phlex::Reactive::Presence::NOTE_KEY} is reserved"
90
+ end
91
+
92
+ Phlex::Reactive::Presence::Declaration.new(
93
+ name: name.to_sym, state: state.freeze, ttl:, key: key && Array(key).dup.freeze
94
+ )
95
+ end
96
+
97
+ # A ttl at or below the heartbeat would expire a live tab between two
98
+ # beats: its row would vanish and come back on the next one.
99
+ def refuse_ttl_within_heartbeat!(ttl)
100
+ heartbeat = Phlex::Reactive::Presence::Transport.heartbeat_interval
101
+ return if ttl > heartbeat
102
+
103
+ raise ArgumentError,
104
+ "#{name}: reactive_presence ttl: #{ttl} is at or below the transport's heartbeat interval " \
105
+ "(#{heartbeat} s), so a live tab would expire between two beats. Use more than #{heartbeat} " \
106
+ "(two missed beats is a good floor)."
107
+ end
108
+
109
+ def refuse_dormant_presence!
110
+ raise ArgumentError,
111
+ "#{name}: reactive_presence cannot be dormant — a dormant root never connects, so its tab " \
112
+ "would never join the stream. Drop reactive_dormant on this component."
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -66,7 +66,9 @@ module Phlex
66
66
  # verify_authorized opt-out (issue #168): a scalar bare flag (skip the
67
67
  # WHOLE component) plus a list of named actions.
68
68
  skip_all: :@reactive_own_skip_all,
69
- skip_actions: :@reactive_own_skip_actions
69
+ skip_actions: :@reactive_own_skip_actions,
70
+ # Presence roster — reactive_presence (issue #358).
71
+ presence: :@reactive_own_presence
70
72
  }.freeze
71
73
 
72
74
  # The identity-side hot-path memos swept by bump! (see the contract
@@ -146,6 +146,7 @@ module Phlex
146
146
  include ClientBindings
147
147
  include Identity
148
148
  include Lazy
149
+ include PresenceDeclaration
149
150
  end
150
151
  end
151
152
  end
@@ -112,6 +112,11 @@ module Phlex
112
112
  "got #{placeholder.class}"
113
113
  end
114
114
 
115
+ # The signed SSE URL a <pgbus-stream-source> subscribes to `key` with —
116
+ # minted off-view, as pgbus_stream_from would. Shared with presence
117
+ # (issue #358), whose roster subscribes to its stream the same way.
118
+ def stream_src(key) = signed_stream_src(key)
119
+
115
120
  private
116
121
 
117
122
  # The delivery directive: `<turbo-stream action="reactive:defer">`
@@ -20,10 +20,10 @@ module Phlex
20
20
  # build script (scripts/build_client.js), the core's own table and the
21
21
  # default entry name the same features —
22
22
  # spec/phlex/engine_client_pin_spec.rb fails when they disagree.
23
- CLIENT_FEATURES = %w[persist defer form bindings compute sortable effects hints devtools].freeze
24
- # (Gzipped, bun zlib level 9: the default file 28.9 KB; the core 13.5 KB;
25
- # persist 3.1, defer 3.0, form 1.0, bindings 6.2, compute 2.1, sortable
26
- # 3.1, effects 1.7, hints 1.0, devtools 1.6 KB. spec/javascript/bundle_budget.test.js
23
+ CLIENT_FEATURES = %w[persist defer form bindings compute sortable presence effects hints devtools].freeze
24
+ # (Gzipped, bun zlib level 9, KB = 1,024 B: the default file 30.9 KB; the core 14.3 KB;
25
+ # persist 3.1, defer 2.9, form 1.1, bindings 6.5, compute 2.0, sortable
26
+ # 3.1, presence 1.3, effects 1.7, hints 0.9, devtools 1.5 KB. spec/javascript/bundle_budget.test.js
27
27
  # is the source of truth and holds a ratchet on each.)
28
28
 
29
29
  # Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
@@ -202,6 +202,11 @@ module Phlex
202
202
  Phlex::Reactive.freeze_param_types!
203
203
  # Freeze the named-schema registry the same way (issue #184).
204
204
  Phlex::Reactive.freeze_param_schemas!
205
+
206
+ # Presence (issue #358): register the presence stream pattern and the
207
+ # change hook with the transport. A no-op without a capable pgbus — an
208
+ # app on Action Cable boots exactly as before.
209
+ Phlex::Reactive::Presence.install!
205
210
  end
206
211
  end
207
212
  end
@@ -186,6 +186,26 @@ module Phlex
186
186
  append("focus_first", target_args(to, global:))
187
187
  end
188
188
 
189
+ # --- Modal dialogs (issue #365) ---
190
+ #
191
+ # show_modal(to) — HTMLDialogElement#showModal(): the top layer, a
192
+ # focus trap, ::backdrop, Escape to close and focus
193
+ # restored to the opener, all the browser's own. A
194
+ # dialog already open NON-modally is closed and
195
+ # reopened as a modal; one already modal is left as is.
196
+ # close_dialog(to) — HTMLDialogElement#close() (a closed one is left as is).
197
+ # A target that is not a <dialog> is skipped. Both ACTOR-ONLY like focus:
198
+ # opening a modal steals focus, so a broadcast refuses them — a
199
+ # broadcaster renders the dialog open with data: { reactive_modal: true }.
200
+
201
+ def show_modal(to, global: false)
202
+ append("show_modal", target_args(to, global:))
203
+ end
204
+
205
+ def close_dialog(to, global: false)
206
+ append("close_dialog", target_args(to, global:))
207
+ end
208
+
189
209
  # --- Submit (issue #226) ---
190
210
  #
191
211
  # submit(to = :root) — requestSubmit() the TARGET'S OWN form: the target
@@ -304,6 +324,30 @@ module Phlex
304
324
  append("dispatch", args.freeze)
305
325
  end
306
326
 
327
+ # --- Presence (issue #358) ---
328
+ #
329
+ # presence(**state) — write this tab's presence state through the root's
330
+ # generated presence_set action (reactive_presence): only the declared
331
+ # keys reach the server, typed like action params. Always targets the
332
+ # ROOT, which must declare reactive_presence (the client warns and does
333
+ # nothing otherwise). ACTOR-ONLY like focus/submit: a broadcast that set
334
+ # every subscriber's selection would be hostile (BROADCAST_REFUSED_OPS).
335
+ # li(**on_client(:click, js.presence(selected: item.id))) { item.title }
336
+ def presence(**state)
337
+ raise ArgumentError, "#{self.class}: presence needs at least one state key (e.g. selected: 7)" if state.empty?
338
+
339
+ state.each do |name, value|
340
+ next if value.nil? || [String, Integer, TrueClass, FalseClass].any? { value.is_a?(it) }
341
+ next if value.is_a?(Float) && value.finite? # NaN and Infinity have no JSON form
342
+
343
+ raise ArgumentError,
344
+ "#{self.class}: presence state values must be scalar (String/Integer/finite Float/true/false/nil) — " \
345
+ "#{name.inspect} is #{value.class}"
346
+ end
347
+
348
+ append("presence", { "to" => ROOT_SENTINEL, "state" => state.transform_keys(&:to_s).freeze }.freeze)
349
+ end
350
+
307
351
  # The wire format: a JSON array of [op, args] pairs, applied in order.
308
352
  def to_json(*)
309
353
  @ops.to_json
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Presence
6
+ # What reactive_presence adds to a component (issue #358). Included only
7
+ # by a class that declares presence, so every other component keeps
8
+ # ActiveSupport's Object#presence.
9
+ module ComponentMethods
10
+ # The roster, read once per render (and again after #presence.set). The
11
+ # memo is namespaced: a component's own @presence must not be clobbered.
12
+ def presence
13
+ @reactive_presence_roster ||= Roster.new( # rubocop:disable Naming/MemoizedInstanceVariableName
14
+ Transport.members(reactive_presence_stream_name),
15
+ keys: self.class.reactive_presence_declaration.state.keys,
16
+ connection_id: Presence.connection_id,
17
+ writer: -> { reactive_presence_write(it) }
18
+ )
19
+ end
20
+
21
+ # The generated action js.presence sends. Runs authorize_presence_state!
22
+ # first when the component defines it (raise to refuse), writes the
23
+ # actor's tab state, and morphs the actor's own view.
24
+ #
25
+ # The client also sends it EMPTY on every connect (the announce). That
26
+ # writes no value — only the reverse note (this class and the record of
27
+ # the token the actor already holds) on the actor's own row — so there
28
+ # is nothing to authorize, and the hook is skipped: its keywords may be
29
+ # required, and calling it with none would raise.
30
+ def presence_set(**state)
31
+ authorize_presence_state!(**state) if state.any? && respond_to?(:authorize_presence_state!, true)
32
+ presence.set(**state)
33
+ reply.morph
34
+ end
35
+
36
+ # data-reactive-presence-peer / -member on a tab's element: the client
37
+ # marks the one that is this tab ("me") and this person's other tabs.
38
+ def reactive_presence_peer(peer)
39
+ { data: { reactive_presence_peer: peer.seat.to_s, reactive_presence_member: peer.member_id.to_s } }
40
+ end
41
+
42
+ # data-reactive-presence-member / -seats on a person's element.
43
+ def reactive_presence_member(member)
44
+ { data: { reactive_presence_member: member.id.to_s, reactive_presence_seats: member.seats.size.to_s } }
45
+ end
46
+
47
+ # [record, :presence, name] — or [*key, :presence, name] record-less.
48
+ def reactive_presence_stream_parts
49
+ declaration = self.class.reactive_presence_declaration
50
+ [*reactive_presence_subject, :presence, declaration.name]
51
+ end
52
+
53
+ def reactive_presence_stream_name
54
+ @reactive_presence_stream_name ||= reactive_presence_key(reactive_presence_stream_parts)
55
+ end
56
+
57
+ # The root's subscription marker (reactive_attrs): the signed SSE src of
58
+ # the presence stream. The client mints the <pgbus-stream-source> from it.
59
+ # And the seats this render saw: a template need not render an element
60
+ # per tab, so the client diffs these for reactive:presence and checks
61
+ # them to know its own tab is on the roster. Seats only (pgbus limits
62
+ # them to [A-Za-z0-9_-]), never a member or connection id.
63
+ def reactive_presence_data
64
+ Presence.require_capable!(self.class)
65
+ { reactive_presence: Transport.source_src(reactive_presence_stream_name),
66
+ reactive_presence_roster: presence.peers.filter_map(&:seat).join(" ") }
67
+ end
68
+
69
+ private
70
+
71
+ # The stream key for the parts. Only pgbus's budget error becomes a
72
+ # ConfigurationError (the backstop for a name the declaration could not
73
+ # check); a missing key keeps its own error.
74
+ def reactive_presence_key(parts)
75
+ Transport.stream_name(parts)
76
+ rescue ::ArgumentError => e # pgbus raises the core one; Phlex defines its own
77
+ raise ConfigurationError, Transport.over_budget_message(self.class, parts.last, e.message)
78
+ end
79
+
80
+ def reactive_presence_subject
81
+ declaration = self.class.reactive_presence_declaration
82
+ return declaration.key if declaration.key
83
+
84
+ record_key = self.class.reactive_record_key
85
+ unless record_key
86
+ raise ArgumentError,
87
+ "#{self.class.name}: reactive_presence needs reactive_record (the stream is the record's) " \
88
+ "or key: (the raw stream key parts of a record-less roster)"
89
+ end
90
+
91
+ [instance_variable_get(:"@#{record_key}")]
92
+ end
93
+
94
+ def reactive_presence_write(state)
95
+ declared = self.class.reactive_presence_declaration.state.keys
96
+ unknown = state.keys.map(&:to_sym) - declared
97
+ unless unknown.empty?
98
+ raise ArgumentError,
99
+ "#{self.class.name}: presence.set got undeclared state #{unknown.join(", ")} " \
100
+ "(declared: #{declared.join(", ")})"
101
+ end
102
+
103
+ connection_id = Presence.connection_id
104
+ return if connection_id.nil?
105
+
106
+ merged = (presence.me&.state || {}).merge(state.transform_keys(&:to_sym)).transform_keys(&:to_s)
107
+ written = Transport.set_state(reactive_presence_stream_name, connection_id:,
108
+ state: merged.merge(NOTE_KEY => reactive_presence_note))
109
+ @reactive_presence_roster = nil
110
+ # No row for this connection (it expired, or pgbus never joined it:
111
+ # no identity): nothing changed for the peers.
112
+ return if written.nil?
113
+
114
+ # A seatless actor (no ?seat=) is on no roster: nothing any viewer
115
+ # sees changed, so there is nothing to broadcast.
116
+ me = presence.me
117
+ return if me.nil?
118
+
119
+ Presence.broadcast(self.class.from_identity(reactive_identity_payload), exclude: connection_id)
120
+ me
121
+ end
122
+
123
+ # Which component wrote the row: its identity payload (class, record gid,
124
+ # declared reactive_state), which is how the render job rebuilds it from
125
+ # the stream (see Presence::NOTE_KEY) — the same shape a token carries.
126
+ def reactive_presence_note = reactive_identity_payload
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Presence
6
+ # The presence of one stream as one render reads it (issue #358): what
7
+ # `presence` returns in a roster component's view_template and actions.
8
+ # Built from the transport's rows once per render.
9
+ #
10
+ # A row is one SSE connection. Two presence roots in one tab are two
11
+ # connections of one seat, so peers are grouped by seat: one entry per tab.
12
+ class Roster
13
+ include Enumerable
14
+
15
+ def initialize(rows, keys:, connection_id: nil, writer: nil)
16
+ # seat => its connection ids. Private: a connection id is the bearer
17
+ # key for writing that row, so only `me` reads it.
18
+ @connections = {}
19
+ @peers = build_peers(rows, keys)
20
+ @connection_id = connection_id
21
+ @writer = writer
22
+ end
23
+
24
+ # Every tab, in join order.
25
+ attr_reader :peers
26
+
27
+ def each(&) = @peers.each(&)
28
+
29
+ # The tabs grouped by person, in order of their first tab.
30
+ def members
31
+ @members ||= @peers.group_by(&:member_id).map do |id, seats|
32
+ Member.new(id:, metadata: seats.last.metadata, seats:)
33
+ end
34
+ end
35
+
36
+ # The tabs whose state matches, e.g. where(selected: item.id).
37
+ def where(**state) = @peers.select { matches?(it, state) }
38
+
39
+ def count = @peers.size
40
+
41
+ alias size count
42
+
43
+ def empty? = @peers.empty?
44
+
45
+ # The actor's own tab during an action (found by its presence
46
+ # connection), nil in a plain render or before the tab has connected.
47
+ def me
48
+ return if @connection_id.nil?
49
+
50
+ @peers.find { @connections[it.seat].include?(@connection_id) }
51
+ end
52
+
53
+ # In an action: write the actor's tab state (merged into what it had),
54
+ # morph the roster into the other viewers' tabs, return the actor's
55
+ # peer. nil when the actor's tab has no presence connection yet.
56
+ def set(**state) = @writer.call(state)
57
+
58
+ private
59
+
60
+ def matches?(peer, state) = state.all? { |key, value| peer.state[key] == value }
61
+
62
+ # A row without a seat (a connection that sent no ?seat=: an app's own
63
+ # turbo_stream_from of the stream, a source without `presence`, a stale
64
+ # client) is no peer: no client can mark it "me", and keying it by its
65
+ # connection id would render that id — the bearer key for its row.
66
+ def build_peers(rows, keys)
67
+ rows.select { it["seat"].present? }.group_by { it["seat"] }.map do |seat, seat_rows|
68
+ first = seat_rows.first
69
+ @connections[seat] = seat_rows.map { it["connection_id"] }
70
+ Peer.new(
71
+ seat:, member_id: first["id"].to_s, metadata: seat_rows.last["metadata"] || {},
72
+ state: declared_state(seat_rows, keys), joined_at: first["joined_at"]
73
+ )
74
+ end
75
+ end
76
+
77
+ # The seat's state, declared keys only (never the reverse note), merged
78
+ # across its connections in join order.
79
+ def declared_state(seat_rows, keys)
80
+ seat_rows.each_with_object({}) do |row, state|
81
+ (row["state"] || {}).each do |key, value|
82
+ key = key.to_sym
83
+ state[key] = value if keys.include?(key)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end