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
@@ -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
@@ -304,6 +304,30 @@ module Phlex
304
304
  append("dispatch", args.freeze)
305
305
  end
306
306
 
307
+ # --- Presence (issue #358) ---
308
+ #
309
+ # presence(**state) — write this tab's presence state through the root's
310
+ # generated presence_set action (reactive_presence): only the declared
311
+ # keys reach the server, typed like action params. Always targets the
312
+ # ROOT, which must declare reactive_presence (the client warns and does
313
+ # nothing otherwise). ACTOR-ONLY like focus/submit: a broadcast that set
314
+ # every subscriber's selection would be hostile (BROADCAST_REFUSED_OPS).
315
+ # li(**on_client(:click, js.presence(selected: item.id))) { item.title }
316
+ def presence(**state)
317
+ raise ArgumentError, "#{self.class}: presence needs at least one state key (e.g. selected: 7)" if state.empty?
318
+
319
+ state.each do |name, value|
320
+ next if value.nil? || [String, Integer, TrueClass, FalseClass].any? { value.is_a?(it) }
321
+ next if value.is_a?(Float) && value.finite? # NaN and Infinity have no JSON form
322
+
323
+ raise ArgumentError,
324
+ "#{self.class}: presence state values must be scalar (String/Integer/finite Float/true/false/nil) — " \
325
+ "#{name.inspect} is #{value.class}"
326
+ end
327
+
328
+ append("presence", { "to" => ROOT_SENTINEL, "state" => state.transform_keys(&:to_s).freeze }.freeze)
329
+ end
330
+
307
331
  # The wire format: a JSON array of [op, args] pairs, applied in order.
308
332
  def to_json(*)
309
333
  @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
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Phlex
6
+ module Reactive
7
+ module Presence
8
+ # Every pgbus touchpoint of presence (issue #358), in one place. The
9
+ # transport half lives in pgbus (zoolutions/pgbus#515): presence rows keyed
10
+ # by SSE connection, each with the tab's seat and a state jsonb, a change
11
+ # hook, and a heartbeat that expires silent rows. Nothing else in the gem
12
+ # names a pgbus presence constant or configuration key.
13
+ #
14
+ # pgbus stays optional: the capability is a probe of the real method
15
+ # signatures, never a version string, and without it nothing here runs —
16
+ # reactive_presence raises a ConfigurationError at render instead.
17
+ module Transport
18
+ # Every presence stream's name ends in ":presence:<name>" — the parts
19
+ # [record, :presence, name] joined by pgbus/Turbo. Registered with the
20
+ # transport at boot so a subscription to one joins on connect and leaves
21
+ # on disconnect, with no controller code.
22
+ PATTERN = /:presence:[^:]+\z/
23
+ # Hex characters of the subject's digest that start a presence key.
24
+ DIGEST_LENGTH = 16
25
+ # What joins the digest and the presence name.
26
+ SEPARATOR = ":presence:"
27
+
28
+ # pgbus's default streams_heartbeat_interval (seconds), used when pgbus
29
+ # is absent or does not expose the setting.
30
+ DEFAULT_HEARTBEAT = 15
31
+
32
+ # The change hook install! sets: the app's own hook (if any) first, then
33
+ # presence's. Its class is how a second install! knows it is in place.
34
+ ChainedHook = Data.define(:previous, :on_change) do
35
+ def call(stream_name, change)
36
+ previous&.call(stream_name, change)
37
+ on_change.call(stream_name, change)
38
+ end
39
+ end
40
+
41
+ class << self
42
+ # The code is there: pgbus with reactive Streams AND presence keyed by
43
+ # connection (a join that takes connection_id:, a set_state,
44
+ # seats_supported?), a change hook and a ttl setting, and ActiveJob
45
+ # (the roster re-renders off the hook's thread). Signatures only, no
46
+ # query: boot and class load gate on this, and both run before a
47
+ # database exists (`rails db:create`, `assets:precompile`). What it
48
+ # gates is configuration, harmless on an unmigrated table.
49
+ def wired?
50
+ return false unless Phlex::Reactive.pgbus_streams?
51
+ return false unless defined?(::Pgbus::Streams::Presence) && defined?(::ActiveJob::Base)
52
+
53
+ presence = ::Pgbus::Streams::Presence
54
+ join_keys = presence.instance_method(:join).parameters.map(&:last)
55
+ return false unless join_keys.include?(:connection_id) && presence.method_defined?(:set_state)
56
+ return false unless presence.respond_to?(:seats_supported?)
57
+
58
+ # streams_authorize= marks pgbus 0.18 (#524), which also fires
59
+ # pgbus:connected only after the seat joined (#523) and drops a
60
+ # closed tab at once (#520); neither of those has a signature.
61
+ config = ::Pgbus.configuration
62
+ %i[streams_presence_on_change= streams_presence_ttl streams_presence_patterns streams_authorize=].all? do
63
+ config.respond_to?(it)
64
+ end
65
+ rescue ::NameError
66
+ false
67
+ end
68
+
69
+ # Wired AND the seat table is migrated. seats_supported? is a query
70
+ # (pgbus memoizes a success), so only a render and the render job ask.
71
+ # A database error propagates: answering false would hide it.
72
+ def capable?
73
+ wired? && ::Pgbus::Streams::Presence.seats_supported?
74
+ end
75
+
76
+ # How often the transport's heartbeat touches a live tab (seconds):
77
+ # configuration only, no query, so a declaration can read it at
78
+ # class load.
79
+ def heartbeat_interval
80
+ config = ::Pgbus.configuration if Phlex::Reactive.pgbus? && ::Pgbus.respond_to?(:configuration)
81
+ interval = config.streams_heartbeat_interval if config.respond_to?(:streams_heartbeat_interval)
82
+ interval.is_a?(Numeric) ? interval : DEFAULT_HEARTBEAT
83
+ end
84
+
85
+ # The stream name the transport (and every Turbo broadcast) uses for
86
+ # these parts.
87
+ # "<16 hex of the subject>:presence:<name>": the subject (the record,
88
+ # or a record-less roster's key) becomes a fixed-length digest of its
89
+ # stream name, so the key fits pgbus's queue-name budget (41 chars by
90
+ # default) whatever the model is called — the raw gid form, or pgbus's
91
+ # own "<param_key>_<digest>" fragment, overflows it for Document,
92
+ # Conversation, ProjectBoard, …. Leaves 15 chars for the presence
93
+ # name; ":presence:<name>" stays the suffix the patterns match. Not
94
+ # reversible: the render job finds the record through the row's note.
95
+ # pgbus raises ArgumentError when it still does not fit.
96
+ def stream_name(parts)
97
+ *subject, presence, name = parts
98
+ digest = Digest::SHA256.hexdigest(::Pgbus::Streams::Stream.name_from(subject))[0, DIGEST_LENGTH]
99
+ ::Pgbus.stream_key!("#{digest}:#{presence}:#{name}")
100
+ end
101
+
102
+ def stream_name_budget = ::Pgbus.stream_name_budget
103
+
104
+ # Whether a presence name fits the key: pure configuration (the queue
105
+ # prefix), no database, so the declaration can check it.
106
+ def fits?(name) = DIGEST_LENGTH + SEPARATOR.length + name.to_s.length <= stream_name_budget
107
+
108
+ def over_budget_message(klass, name, detail = nil)
109
+ "#{klass.name}: the presence stream for reactive_presence :#{name} does not fit pgbus's " \
110
+ "stream-name budget of #{stream_name_budget} chars (a #{DIGEST_LENGTH}-char digest of the record, " \
111
+ "\":presence:\" and the name). Use a shorter presence name.#{" (#{detail})" if detail}"
112
+ end
113
+
114
+ # The signed SSE URL a <pgbus-stream-source> subscribes with.
115
+ def source_src(stream_name) = Phlex::Reactive::Defer.stream_src(stream_name)
116
+
117
+ # The rows of a stream: Hashes with "id" (the member), "connection_id",
118
+ # "seat", "state", "metadata" and "joined_at".
119
+ def members(stream_name) = ::Pgbus.stream(stream_name).presence.members
120
+
121
+ # Writes the state of ONE connection's row on the stream (pgbus
122
+ # shallow-merges it into what the row had).
123
+ def set_state(stream_name, connection_id:, state:)
124
+ ::Pgbus.stream(stream_name).presence.set_state(connection_id:, state:)
125
+ end
126
+
127
+ # At boot: auto-join presence streams, and hear their changes. An
128
+ # app's own change hook keeps running (chained, first).
129
+ def install!(on_change)
130
+ config = ::Pgbus.configuration
131
+ patterns = config.streams_presence_patterns
132
+ patterns << PATTERN unless patterns.include?(PATTERN)
133
+ previous = config.streams_presence_on_change
134
+ return if previous.is_a?(ChainedHook)
135
+
136
+ config.streams_presence_on_change = ChainedHook.new(previous:, on_change:)
137
+ end
138
+
139
+ # A component's ttl: — seconds a silent seat survives — keyed by its
140
+ # presence name, which every stream of that component ends with.
141
+ # pgbus takes an Integer (every stream) or a Hash of stream name /
142
+ # pattern => seconds with a :default; an Integer becomes the Hash's
143
+ # default so the app's own setting still covers every other stream.
144
+ def register_ttl(name, seconds)
145
+ config = ::Pgbus.configuration
146
+ ttl = config.streams_presence_ttl
147
+ config.streams_presence_ttl = ttl = { default: ttl } unless ttl.is_a?(Hash)
148
+ ttl.store(/:presence:#{Regexp.escape(name.to_s)}\z/, seconds)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # Presence (issue #358): who is viewing a stream, one SEAT per browser tab,
6
+ # each with an ephemeral, declared state (the item it has selected), and a
7
+ # roster component that re-renders itself in every viewer's tab on join,
8
+ # leave, expiry and state change.
9
+ #
10
+ # class Boards::Viewers < Phlex::HTML
11
+ # include Phlex::Reactive::Component
12
+ #
13
+ # reactive_record :board
14
+ # reactive_presence :viewers, state: { selected: :integer }, ttl: 45
15
+ #
16
+ # def view_template
17
+ # div(**reactive_root) do
18
+ # presence.members.each do |member|
19
+ # span(**reactive_presence_member(member)) { member.metadata["name"] }
20
+ # end
21
+ # end
22
+ # end
23
+ # end
24
+ #
25
+ # The transport does the membership (pgbus, zoolutions/pgbus#515): an SSE
26
+ # connection to a presence stream joins on connect and leaves on
27
+ # disconnect, a silent one expires after the stream's ttl, and each change
28
+ # calls the hook this module installs. The hook enqueues
29
+ # PresenceRenderJob, which re-renders the roster for everyone.
30
+ #
31
+ # Every pgbus call is in Presence::Transport. Without a capable transport
32
+ # (no pgbus, an older one, Action Cable) a presence root raises
33
+ # Phlex::Reactive::ConfigurationError at render: there is no degraded mode.
34
+ module Presence
35
+ # The reverse note a presence write leaves in the seat's state: the class
36
+ # that wrote it and the record it renders. A stream name cannot be turned
37
+ # back into a record (pgbus digests long names), so the render job reads
38
+ # this from any seat still on the stream. Never shown to the app: Peer#state
39
+ # holds only the declared keys.
40
+ NOTE_KEY = "_rp"
41
+
42
+ # A silent seat survives this many seconds (the transport's heartbeat,
43
+ # 15 s by default, keeps a live one fresh: two missed beats).
44
+ DEFAULT_TTL = 45
45
+
46
+ # Roster broadcasts coalesce: a page reload is a leave and a join within
47
+ # milliseconds, and a burst of selections is one morph per window.
48
+ COALESCE_MS = 150
49
+
50
+ # The client sends its presence source's connection id in this header, so
51
+ # a write lands on the row of THIS tab's presence connection — not on the
52
+ # first <pgbus-stream-source> on the page (X-Pgbus-Connection).
53
+ HEADER = "X-Reactive-Presence"
54
+
55
+ # What reactive_presence declared: the presence name, the state schema,
56
+ # the ttl, and the raw stream key parts of a record-less component.
57
+ Declaration = Data.define(:name, :state, :ttl, :key)
58
+
59
+ # One tab on the stream. `seat` is the tab's id (opaque; rendered by
60
+ # reactive_presence_peer so the client can find "me"), `state` the
61
+ # declared keys only. No connection id: it is the bearer key for writing
62
+ # the tab's row, so it stays inside the Roster.
63
+ Peer = Data.define(:seat, :member_id, :metadata, :state, :joined_at)
64
+
65
+ # One person: their seats, the newest one's metadata and state.
66
+ Member = Data.define(:id, :metadata, :seats) do
67
+ def state = seats.last&.state || {}
68
+ end
69
+
70
+ class << self
71
+ # Wired and migrated (a query: render time only). See Transport.
72
+ def capable? = Transport.capable?
73
+
74
+ # The actor's presence connection during an action (the HEADER), or nil.
75
+ def connection_id
76
+ Thread.current[:phlex_reactive_presence_connection]
77
+ end
78
+
79
+ def with_connection_id(connection_id)
80
+ previous = Thread.current[:phlex_reactive_presence_connection]
81
+ Thread.current[:phlex_reactive_presence_connection] = connection_id.presence
82
+ yield
83
+ ensure
84
+ Thread.current[:phlex_reactive_presence_connection] = previous
85
+ end
86
+
87
+ # Raise unless the transport can carry presence — at first render of a
88
+ # presence root, naming what is missing.
89
+ def require_capable!(klass)
90
+ return if capable?
91
+
92
+ raise Phlex::Reactive::ConfigurationError,
93
+ "#{klass.name} declares reactive_presence, which needs pgbus >= 0.18.1 (presence keyed by " \
94
+ "SSE connection, config.streams_authorize) with its presence table created or migrated " \
95
+ "(`rails g pgbus:add_presence`, `rails g pgbus:add_presence_seats`, or `rails pgbus:update`), " \
96
+ "and ActiveJob. Action Cable has no connection identity, so there is no fallback."
97
+ end
98
+
99
+ # Boot (the engine's after_initialize): register the presence stream
100
+ # pattern and the change hook. Gated on the signatures alone, never a
101
+ # query — boot runs before a database exists. Nothing is touched
102
+ # without them: an app on Action Cable boots exactly as before.
103
+ def install!
104
+ Transport.install!(method(:changed)) if Transport.wired?
105
+ end
106
+
107
+ # The transport's change hook (it runs on the SSE dispatcher thread):
108
+ # enqueue a roster render for a presence stream, nothing else.
109
+ def changed(stream_name, _change)
110
+ return unless Transport::PATTERN.match?(stream_name.to_s)
111
+
112
+ Phlex::Reactive::PresenceRenderJob.perform_later(stream_name.to_s)
113
+ end
114
+
115
+ # The job's body: rebuild every roster component a seat on the stream
116
+ # names, render it as the table is NOW, and morph it into every viewer's
117
+ # tab (the joiner included: its first paint ran before its connection
118
+ # existed). Nobody left, or a gone record, renders nothing.
119
+ def render_roster(stream_name)
120
+ return unless capable?
121
+
122
+ # One render per class and record. A roster's reactive_state is shared
123
+ # markup under one id, the same for every viewer, so the state any
124
+ # note carries will do; per-viewer state belongs in presence state.
125
+ notes = Transport.members(stream_name).filter_map { it["state"]&.[](NOTE_KEY) }
126
+ notes.uniq { [it["c"], it["gid"]] }.each do
127
+ component = build(it, stream_name)
128
+ broadcast(component, exclude: nil) if component
129
+ end
130
+ end
131
+
132
+ # Morph `component`'s roster into its stream's viewers.
133
+ def broadcast(component, exclude:)
134
+ Phlex::Reactive::Streamable.broadcast_component(
135
+ component.class, :replace, component, component, [component.reactive_presence_stream_name],
136
+ morph: true, target: nil, exclude:, visible_to: nil, coalesce: COALESCE_MS
137
+ )
138
+ end
139
+
140
+ # A roster component rebuilt from a note: the named class, only when it
141
+ # declares presence, with its record re-found (nil when it is gone).
142
+ def build(note, stream_name)
143
+ klass = note["c"].to_s.safe_constantize
144
+ return unless klass.respond_to?(:reactive_presence_declaration) && klass.reactive_presence_declaration
145
+
146
+ klass.from_identity(note.slice("gid", "s"))
147
+ rescue ::ActiveRecord::RecordNotFound
148
+ # The record is gone: there is nothing left to render.
149
+ nil
150
+ rescue Phlex::Reactive::Error => e
151
+ # The note cannot rebuild the component (no gid for one that needs
152
+ # its record): render nothing rather than fail the job on every retry.
153
+ Phlex::Reactive.send(:default_logger)&.warn(
154
+ "[phlex-reactive] presence stream #{stream_name}: cannot rebuild #{note["c"]} from its note " \
155
+ "(#{e.message}); its roster is not re-rendered"
156
+ )
157
+ nil
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # A presence stream changed (issue #358): a tab joined, left, or expired.
6
+ # Re-render the roster as the table is when the job runs — not the change
7
+ # it was enqueued for — and morph it into every viewer's tab. A page
8
+ # reload (a leave, then a join within milliseconds) therefore renders the
9
+ # seat present, and coalescing folds the pair into one morph.
10
+ #
11
+ # Enqueued by Phlex::Reactive::Presence.changed, the transport's change
12
+ # hook, which runs on the SSE dispatcher thread: rendering there would hold
13
+ # it up. On defer_job_queue, like the push lane's render job.
14
+ #
15
+ # NOT eager-loaded (see the loader config in phlex/reactive.rb): the class
16
+ # body needs ActiveJob::Base, which isn't a gem dependency — the constant
17
+ # is only referenced behind Phlex::Reactive::Presence.capable?.
18
+ class PresenceRenderJob < ::ActiveJob::Base
19
+ queue_as { Phlex::Reactive.defer_job_queue }
20
+
21
+ def perform(stream_name)
22
+ Phlex::Reactive::Presence.render_roster(stream_name)
23
+ end
24
+ end
25
+ end
26
+ end