phlex-reactive 0.9.5 → 0.10.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.
@@ -57,13 +57,24 @@ module Phlex
57
57
  # end
58
58
  # end
59
59
  #
60
- # Assembled from three cohesive concerns (issue #115) — one include, zero
61
- # public API change:
62
- # * Component::DSL — the five declaration registries (via
63
- # Component::Registry) + from_identity
64
- # * Component::Identityreactive_token + the hot-path ivar precomputation
65
- # * Component::Helpers — reply/js, reactive_attrs/root, on/on_client,
66
- # the field/select/text bindings, and the nested-attributes helpers
60
+ # Assembled from cohesive concerns (issue #115, restructured in #180) — one
61
+ # include, zero public API change. The include stack, in order:
62
+ # * Phlex::Reactive::Streamable — the render/broadcast/#id surface (mixed in
63
+ # first, so its methods sit below the component's own).
64
+ # * Phlex::Reactive::ClientBindings — the CLIENT-ONLY surface (issue #180),
65
+ # itself Component::DSL (the declaration registries via Component::Registry
66
+ # + from_identity) + Component::Helpers (reply/js, reactive_attrs/root,
67
+ # on/on_client, the field/select/text bindings, reactive_show/filter/
68
+ # compute, and the nested-attributes helpers). ClientBindings is the ONE
69
+ # implementation of that surface, shared with the standalone client-only
70
+ # include; it carries NO token machinery.
71
+ # * Component::Identity — reactive_token + the hot-path ivar precomputation.
72
+ # Its presence is what makes the server-action macros (action/
73
+ # reactive_record/reactive_state) legal here — they raise on a
74
+ # ClientBindings-only class that lacks it.
75
+ # * Component::Lazy — reactive_lazy (deferred initial mount, issue #165).
76
+ # So a full, token-bearing reactive component is a SUPERSET of a client-only
77
+ # one, not a fork.
67
78
  module Component
68
79
  extend ActiveSupport::Concern
69
80
  include Phlex::Reactive::Streamable
@@ -111,9 +122,12 @@ module Phlex
111
122
  end
112
123
  end
113
124
 
114
- include DSL
125
+ # ClientBindings (issue #180) is the client-only surface (DSL + Helpers),
126
+ # tokenless and Streamable-free. Component includes it as the ONE
127
+ # implementation, then layers Identity + Lazy (and Streamable, above) on
128
+ # top — a token-bearing root is a superset of the client-only one.
129
+ include ClientBindings
115
130
  include Identity
116
- include Helpers
117
131
  include Lazy
118
132
  end
119
133
  end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The ONE conditions language behind reactive_show / reactive_show_targets
6
+ # (issue #180 Phase A). It compiles Ruby-native values into a DNF wire shape
7
+ # and evaluates that shape in Ruby with semantics identical to the client —
8
+ # so first-paint `hidden:` (server) and live toggling (client) can never
9
+ # drift (the shared spec/fixtures/show_predicate_vectors.json proves it).
10
+ #
11
+ # THE VALUE LANGUAGE (used by every if:/if_any:/unless: term):
12
+ # String/Symbol/Integer/Float -> equals (stringified)
13
+ # true / false -> equals "true"/"false" (checkbox state)
14
+ # nil -> equals "" (blank)
15
+ # Array -> in (each stringified; empty raises)
16
+ # (10..) -> gte 10
17
+ # (..10) -> lte 10
18
+ # (...10) -> lt 10
19
+ # (10..20) -> gte 10 AND lte 20 (two terms, one group)
20
+ # Under unless: each term is NEGATED (De Morgan):
21
+ # scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
22
+ # predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
23
+ # to a disjunction (x<a ∨ x>b) that splits the group.
24
+ #
25
+ # THE WIRE (DNF): an ARRAY OF GROUPS. Terms AND within a group; groups OR.
26
+ # [[{ "field"=>"a", "equals"=>"1" }], [{ "field"=>"b", "gte"=>10 }]]
27
+ # A single if: compiles to one group; if_any: to one group per hash; unless:
28
+ # distributes its negated terms into every group (multiplying groups when a
29
+ # bounded-range complement splits). There is NO expression surface: every
30
+ # term is a declared literal predicate — the pre-#180 default-deny posture.
31
+ module ShowConditions
32
+ module_function
33
+
34
+ # Compile if:/if_any:/unless: into DNF groups. Loud validation at render
35
+ # (the pre-#180 posture) — empty conditions, empty Array/group values, a
36
+ # non-Numeric Range endpoint, and if:+if_any: together all raise here so a
37
+ # dead binding fails at render, never silently in the browser.
38
+ def normalize(**kwargs)
39
+ positive = kwargs.slice(:if, :if_any)
40
+ negative = kwargs[:unless]
41
+
42
+ if positive.key?(:if) && positive.key?(:if_any)
43
+ raise ArgumentError,
44
+ "reactive_show: exactly one of if:/if_any: (unless: composes with either), " \
45
+ "got both"
46
+ end
47
+ raise ArgumentError, "reactive_show needs if:, if_any:, or unless:" unless positive.any? || negative
48
+
49
+ groups = base_groups(positive)
50
+ groups = apply_unless(groups, negative) if negative
51
+ groups
52
+ end
53
+
54
+ # Evaluate DNF groups against a { field => value(String) } map — true when
55
+ # ANY group's EVERY term matches. Mirrors the client fold exactly; a
56
+ # referenced field absent from `values` reads as "" (fail-closed, same as
57
+ # a blank field).
58
+ # rubocop:disable Style/ItBlockParameter -- nested blocks: `it` would shadow ambiguously
59
+ def match?(groups, values)
60
+ groups.any? { |group| group.all? { |term| term_matches?(term, values) } }
61
+ end
62
+
63
+ # Every field named across all groups/terms — drives reactive_values
64
+ # coverage (server first paint only fires when every field is provided).
65
+ def fields(groups)
66
+ groups.flat_map { |group| group.map { |term| term.fetch("field") } }.uniq
67
+ end
68
+ # rubocop:enable Style/ItBlockParameter
69
+
70
+ # --- normalization internals -------------------------------------------
71
+
72
+ # The positive base: if: -> one group; if_any: -> a group per hash; neither
73
+ # (unless: only) -> a single empty "all true" group that unless: negates
74
+ # into.
75
+ def base_groups(positive)
76
+ if positive.key?(:if)
77
+ [group_terms(:if, positive.fetch(:if))]
78
+ elsif positive.key?(:if_any)
79
+ any = positive.fetch(:if_any)
80
+ unless any.is_a?(Array) && any.any?
81
+ raise ArgumentError, "reactive_show if_any: needs at least one group (an Array of hashes)"
82
+ end
83
+
84
+ any.map { group_terms(:if_any, it) }
85
+ else
86
+ [[]] # unless: only — one empty group; unless distributes its nots in
87
+ end
88
+ end
89
+
90
+ # One hash -> its ANDed positive terms.
91
+ def group_terms(context, hash)
92
+ unless hash.is_a?(Hash) && hash.any?
93
+ label = context == :if_any ? "each if_any: group needs at least one field" : "if: needs at least one field"
94
+ raise ArgumentError, "reactive_show: #{label} (a Hash of field => value), got #{hash.inspect}"
95
+ end
96
+
97
+ hash.flat_map { |field, value| positive_terms(field, value) }
98
+ end
99
+
100
+ # Distribute the negated unless: over every base group, obeying De Morgan.
101
+ # `unless:` negates the AND of its whole hash — ¬(a ∧ b) = ¬a ∨ ¬b — so the
102
+ # negation is a DISJUNCTION: the UNION of each field's alternative
103
+ # term-lists, never their cartesian product. Each field contributes 1
104
+ # alternative (a scalar `not`, or an Array's ∉ = AND-of-nots, or an endless
105
+ # range complement) or 2 (a bounded range, itself a disjunction); those
106
+ # alternatives OR across fields. Distributing that ORed set over the base
107
+ # groups multiplies them: |base| × |alternatives| groups, each base group
108
+ # ANDed with exactly one alternative.
109
+ def apply_unless(groups, negative)
110
+ unless negative.is_a?(Hash) && negative.any?
111
+ raise ArgumentError, "reactive_show unless: needs a Hash of field => value, got #{negative.inspect}"
112
+ end
113
+
114
+ alternatives = negative.flat_map { |field, value| negative_alternatives(field, value) }
115
+ # rubocop:disable Style/ItBlockParameter -- nested block: `it` would shadow `group`
116
+ groups.flat_map do |group|
117
+ alternatives.map { |extra| group + extra }
118
+ end
119
+ # rubocop:enable Style/ItBlockParameter
120
+ end
121
+
122
+ # --- the value language (positive) -------------------------------------
123
+
124
+ # A field => value under if:/if_any: -> a list of ANDed terms (one, or two
125
+ # for a bounded range).
126
+ def positive_terms(field, value)
127
+ name = field.to_s
128
+ case value
129
+ when Range then range_terms(name, value)
130
+ when Array then [membership_term(name, value)]
131
+ else [{ "field" => name, "equals" => equals_literal(value) }]
132
+ end
133
+ end
134
+
135
+ def equals_literal(value)
136
+ return "" if value.nil?
137
+
138
+ value.to_s
139
+ end
140
+
141
+ def membership_term(name, array)
142
+ list = array.map(&:to_s)
143
+ raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
144
+
145
+ { "field" => name, "in" => list }
146
+ end
147
+
148
+ # A Range -> gte / lte / lt terms. Endless -> one term; bounded -> two
149
+ # (gte + lte/lt) in the SAME group. Endpoints must be numbers (a literal
150
+ # numeric threshold, never an expression).
151
+ def range_terms(name, range)
152
+ first = range.begin
153
+ last = range.end
154
+ validate_range_endpoints!(name, first, last)
155
+
156
+ terms = []
157
+ terms << { "field" => name, "gte" => first } unless first.nil?
158
+ if last
159
+ terms << { "field" => name, (range.exclude_end? ? "lt" : "lte") => last }
160
+ end
161
+ terms
162
+ end
163
+
164
+ def validate_range_endpoints!(name, first, last)
165
+ [first, last].compact.each do
166
+ next if it.is_a?(Numeric)
167
+
168
+ raise ArgumentError,
169
+ "reactive_show: #{name.inspect} Range endpoints must be numbers " \
170
+ "(an ordered threshold), got #{it.inspect}"
171
+ end
172
+ end
173
+
174
+ # --- the value language (negated, under unless:) -----------------------
175
+
176
+ # A field => value under unless: -> a LIST of alternative term-lists.
177
+ # Scalar/Array negate to a single conjunctive alternative (an Array is
178
+ # ¬(x ∈ set) = AND of nots); a bounded Range complements to TWO
179
+ # alternatives (the disjunction x<a ∨ x>b). An empty Array raises, exactly
180
+ # like the positive membership path (a dead binding must fail at render).
181
+ def negative_alternatives(field, value)
182
+ name = field.to_s
183
+ case value
184
+ when Range then range_complement(name, value)
185
+ when Array
186
+ list = value.map(&:to_s)
187
+ raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
188
+
189
+ [list.map { { "field" => name, "not" => it } }]
190
+ else [[{ "field" => name, "not" => equals_literal(value) }]]
191
+ end
192
+ end
193
+
194
+ # The complement of a range predicate. Endless ranges complement to a
195
+ # single opposite threshold (¬gte→lt, ¬lte→gt, ¬lt→gte) — itself a positive
196
+ # numeric predicate, so a blank/NaN field stays FALSE (fail-closed: the
197
+ # complement never flips a blank to visible). A bounded range complements
198
+ # to two alternatives.
199
+ def range_complement(name, range)
200
+ first = range.begin
201
+ last = range.end
202
+ validate_range_endpoints!(name, first, last)
203
+
204
+ low = first.nil? ? nil : [{ "field" => name, "lt" => first }]
205
+ high =
206
+ if last.nil?
207
+ nil
208
+ elsif range.exclude_end?
209
+ [{ "field" => name, "gte" => last }]
210
+ else
211
+ [{ "field" => name, "gt" => last }]
212
+ end
213
+
214
+ [low, high].compact
215
+ end
216
+
217
+ # --- evaluation (mirrors the client) -----------------------------------
218
+
219
+ def term_matches?(term, values)
220
+ field = term.fetch("field")
221
+ value = values.fetch(field, "")
222
+
223
+ if term.key?("equals") then value == term["equals"]
224
+ elsif term.key?("not") then value != term["not"]
225
+ elsif term.key?("in") then term["in"].include?(value)
226
+ else numeric_term_matches?(term, value)
227
+ end
228
+ end
229
+
230
+ # gte/gt/lte/lt — coerce the field value to a number; a blank/non-numeric
231
+ # value is fail-closed (never matches). Mirrors the client's
232
+ # numericPredicateMatches (blank/whitespace -> NaN -> false).
233
+ def numeric_term_matches?(term, value)
234
+ trimmed = value.to_s.strip
235
+ return false if trimmed.empty?
236
+
237
+ number = Float(trimmed, exception: false)
238
+ return false if number.nil?
239
+
240
+ if term.key?("gte") then number >= term["gte"]
241
+ elsif term.key?("gt") then number > term["gt"]
242
+ elsif term.key?("lte") then number <= term["lte"]
243
+ elsif term.key?("lt") then number < term["lt"]
244
+ else false
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -246,11 +246,11 @@ module Phlex
246
246
  # transports (Action Cable AND pgbus): it wraps this class-method body,
247
247
  # which is the same on either, so pgbus optionality is preserved.
248
248
  def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
249
- instrument_broadcast("replace", streamables) do
249
+ instrument_broadcast("replace", streamables, exclude:, visible_to:) do
250
250
  component = build(model, options)
251
251
  ::Turbo::StreamsChannel.broadcast_replace_to(
252
252
  *streamables, target: component.id, html: render_component(component),
253
- **morph_attributes(morph), **broadcast_transport_opts(exclude:, visible_to:)
253
+ **morph_attributes(morph)
254
254
  )
255
255
  end
256
256
  end
@@ -261,41 +261,38 @@ module Phlex
261
261
  # flag, it rides through `attributes:` (the broadcast path has no
262
262
  # `method:` kwarg) via morph_attributes.
263
263
  def broadcast_update_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
264
- instrument_broadcast("update", streamables) do
264
+ instrument_broadcast("update", streamables, exclude:, visible_to:) do
265
265
  component = build(model, options)
266
266
  ::Turbo::StreamsChannel.broadcast_update_to(
267
267
  *streamables, target: component.id, html: render_component(component),
268
- **morph_attributes(morph), **broadcast_transport_opts(exclude:, visible_to:)
268
+ **morph_attributes(morph)
269
269
  )
270
270
  end
271
271
  end
272
272
 
273
273
  def broadcast_append_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
274
- instrument_broadcast("append", streamables) do
274
+ instrument_broadcast("append", streamables, exclude:, visible_to:) do
275
275
  component = build(model, options)
276
276
  ::Turbo::StreamsChannel.broadcast_append_to(
277
- *streamables, target:, html: render_component(component),
278
- **broadcast_transport_opts(exclude:, visible_to:)
277
+ *streamables, target:, html: render_component(component)
279
278
  )
280
279
  end
281
280
  end
282
281
 
283
282
  def broadcast_prepend_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
284
- instrument_broadcast("prepend", streamables) do
283
+ instrument_broadcast("prepend", streamables, exclude:, visible_to:) do
285
284
  component = build(model, options)
286
285
  ::Turbo::StreamsChannel.broadcast_prepend_to(
287
- *streamables, target:, html: render_component(component),
288
- **broadcast_transport_opts(exclude:, visible_to:)
286
+ *streamables, target:, html: render_component(component)
289
287
  )
290
288
  end
291
289
  end
292
290
 
293
291
  def broadcast_remove_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
294
- instrument_broadcast("remove", streamables) do
292
+ instrument_broadcast("remove", streamables, exclude:, visible_to:) do
295
293
  component = build(model, options)
296
294
  ::Turbo::StreamsChannel.broadcast_remove_to(
297
- *streamables, target: component.id,
298
- **broadcast_transport_opts(exclude:, visible_to:)
295
+ *streamables, target: component.id
299
296
  )
300
297
  end
301
298
  end
@@ -303,8 +300,8 @@ module Phlex
303
300
  # Push server-side client DOM ops to EVERY subscriber of a stream (issue
304
301
  # #97) — the broadcast sibling of Response#js. Rides a `reactive:js`
305
302
  # custom stream action over Turbo::StreamsChannel, so it works on Action
306
- # Cable AND pgbus (transport opts pass through broadcast_transport_opts
307
- # exactly like every other broadcast):
303
+ # Cable AND pgbus (exclude:/visible_to: thread to pgbus via
304
+ # instrument_broadcast exactly like every other broadcast):
308
305
  #
309
306
  # Notifications::Badge.broadcast_js_to(user, :alerts,
310
307
  # js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
@@ -320,15 +317,14 @@ module Phlex
320
317
  # actor-reply concern only (Response#js), so it raises ArgumentError here
321
318
  # rather than silently shipping a hostile-feeling broadcast.
322
319
  def broadcast_js_to(*streamables, ops, exclude: nil, visible_to: nil, target: nil)
323
- instrument_broadcast("reactive:js", streamables) do
320
+ instrument_broadcast("reactive:js", streamables, exclude:, visible_to:) do
324
321
  json = broadcast_js_ops_json(ops)
325
322
  ::Turbo::StreamsChannel.broadcast_action_to(
326
323
  *streamables,
327
324
  action: "reactive:js",
328
325
  target: target,
329
326
  attributes: { data: { reactive_ops: json } },
330
- render: false,
331
- **broadcast_transport_opts(exclude:, visible_to:)
327
+ render: false
332
328
  )
333
329
  end
334
330
  end
@@ -356,49 +352,45 @@ module Phlex
356
352
  # render-per-viewer by definition and can't be shared. This fan-out is
357
353
  # for the same payload to many keys.
358
354
  def broadcast_replace_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
359
- instrument_broadcast("replace", stream_keys) do
355
+ instrument_broadcast("replace", stream_keys, exclude:, visible_to:) do
360
356
  component = build(model, options)
361
357
  html = render_component(component)
362
- transport = broadcast_transport_opts(exclude:, visible_to:)
363
358
  stream_keys.each do
364
359
  ::Turbo::StreamsChannel.broadcast_replace_to(
365
- *Array(it), target: component.id, html:, **morph_attributes(morph), **transport
360
+ *Array(it), target: component.id, html:, **morph_attributes(morph)
366
361
  )
367
362
  end
368
363
  end
369
364
  end
370
365
 
371
366
  def broadcast_update_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
372
- instrument_broadcast("update", stream_keys) do
367
+ instrument_broadcast("update", stream_keys, exclude:, visible_to:) do
373
368
  component = build(model, options)
374
369
  html = render_component(component)
375
- transport = broadcast_transport_opts(exclude:, visible_to:)
376
370
  stream_keys.each do
377
371
  ::Turbo::StreamsChannel.broadcast_update_to(
378
- *Array(it), target: component.id, html:, **morph_attributes(morph), **transport
372
+ *Array(it), target: component.id, html:, **morph_attributes(morph)
379
373
  )
380
374
  end
381
375
  end
382
376
  end
383
377
 
384
378
  def broadcast_append_to_each(stream_keys, target:, model: nil, exclude: nil, visible_to: nil, **options)
385
- instrument_broadcast("append", stream_keys) do
379
+ instrument_broadcast("append", stream_keys, exclude:, visible_to:) do
386
380
  component = build(model, options)
387
381
  html = render_component(component)
388
- transport = broadcast_transport_opts(exclude:, visible_to:)
389
382
  stream_keys.each do
390
- ::Turbo::StreamsChannel.broadcast_append_to(*Array(it), target:, html:, **transport)
383
+ ::Turbo::StreamsChannel.broadcast_append_to(*Array(it), target:, html:)
391
384
  end
392
385
  end
393
386
  end
394
387
 
395
388
  def broadcast_prepend_to_each(stream_keys, target:, model: nil, exclude: nil, visible_to: nil, **options)
396
- instrument_broadcast("prepend", stream_keys) do
389
+ instrument_broadcast("prepend", stream_keys, exclude:, visible_to:) do
397
390
  component = build(model, options)
398
391
  html = render_component(component)
399
- transport = broadcast_transport_opts(exclude:, visible_to:)
400
392
  stream_keys.each do
401
- ::Turbo::StreamsChannel.broadcast_prepend_to(*Array(it), target:, html:, **transport)
393
+ ::Turbo::StreamsChannel.broadcast_prepend_to(*Array(it), target:, html:)
402
394
  end
403
395
  end
404
396
  end
@@ -406,11 +398,10 @@ module Phlex
406
398
  # remove has no body — nothing to render. It still builds ONCE to read
407
399
  # the component's #id (the target), then loops the cheap channel call.
408
400
  def broadcast_remove_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, **options)
409
- instrument_broadcast("remove", stream_keys) do
401
+ instrument_broadcast("remove", stream_keys, exclude:, visible_to:) do
410
402
  component = build(model, options)
411
- transport = broadcast_transport_opts(exclude:, visible_to:)
412
403
  stream_keys.each do
413
- ::Turbo::StreamsChannel.broadcast_remove_to(*Array(it), target: component.id, **transport)
404
+ ::Turbo::StreamsChannel.broadcast_remove_to(*Array(it), target: component.id)
414
405
  end
415
406
  end
416
407
  end
@@ -418,16 +409,46 @@ module Phlex
418
409
  private
419
410
 
420
411
  # Wrap a broadcast_*_to body in a broadcast.phlex_reactive event (issue
421
- # #107). Payload: the component NAME, the Turbo stream action, and the
422
- # streamables COUNT never the model/state. Fires on both transports
423
- # because it wraps the shared class-method body. Returns the block's value
424
- # (the broadcast result) so callers are unaffected.
425
- def instrument_broadcast(stream_action, streamables, &)
412
+ # #107) AND thread the pgbus transport opts (issue #187). Payload: the
413
+ # component NAME, the Turbo stream action, and the streamables COUNT —
414
+ # never the model/state. Fires on both transports because it wraps the
415
+ # shared class-method body. Returns the block's value (the broadcast
416
+ # result) so callers are unaffected.
417
+ #
418
+ # exclude:/visible_to: (issue #187): pgbus reads these from thread-locals
419
+ # (Thread.current[:pgbus_broadcast_exclude]/_visible_to), which its
420
+ # Turbo::StreamsChannel#broadcast_stream_to patch consults — NOT from the
421
+ # broadcast_*_to kwargs (turbo-rails swallows unknown kwargs into its
422
+ # render locals, so passing exclude: as a kwarg to StreamsChannel silently
423
+ # drops it: the actor-echo suppression never fired on pgbus). We set the
424
+ # thread-locals around the broadcast (capability-gated on pgbus_streams?)
425
+ # and clear them in ensure. On Action Cable there is no such thread-local
426
+ # reader, so this is a no-op there — pgbus optionality preserved.
427
+ def instrument_broadcast(stream_action, streamables, exclude: nil, visible_to: nil, &)
426
428
  Phlex::Reactive.instrument(
427
429
  "broadcast",
428
- { component: name, stream_action: stream_action, streamables: streamables.size },
429
- &
430
- )
430
+ { component: name, stream_action: stream_action, streamables: streamables.size }
431
+ ) { with_pgbus_broadcast_opts(exclude:, visible_to:, &) }
432
+ end
433
+
434
+ # Set the pgbus broadcast thread-locals for the duration of the block,
435
+ # ONLY when streams-capable pgbus is present (the capability gate — an old
436
+ # pgbus / Action Cable never reads these keys, so we skip the work and the
437
+ # ensure entirely). Cleared in ensure so a broadcast never leaks its
438
+ # exclude into a later one on the same thread.
439
+ def with_pgbus_broadcast_opts(exclude:, visible_to:)
440
+ return yield unless Phlex::Reactive.pgbus_streams?
441
+
442
+ prev_exclude = Thread.current[:pgbus_broadcast_exclude]
443
+ prev_visible = Thread.current[:pgbus_broadcast_visible_to]
444
+ Thread.current[:pgbus_broadcast_exclude] = exclude
445
+ Thread.current[:pgbus_broadcast_visible_to] = visible_to
446
+ yield
447
+ ensure
448
+ if Phlex::Reactive.pgbus_streams?
449
+ Thread.current[:pgbus_broadcast_exclude] = prev_exclude
450
+ Thread.current[:pgbus_broadcast_visible_to] = prev_visible
451
+ end
431
452
  end
432
453
 
433
454
  # Validate + serialize broadcast ops: reject focus-class ops (they steal
@@ -463,15 +484,6 @@ module Phlex
463
484
  morph ? { attributes: { method: "morph" } } : {}
464
485
  end
465
486
 
466
- # Only include transport opts that were actually given, so on Action
467
- # Cable (which doesn't accept them) the common no-opts call is unchanged.
468
- def broadcast_transport_opts(exclude:, visible_to:)
469
- opts = {}
470
- opts[:exclude] = exclude unless exclude.nil?
471
- opts[:visible_to] = visible_to unless visible_to.nil?
472
- opts
473
- end
474
-
475
487
  def renderer
476
488
  Phlex::Reactive.renderer
477
489
  end
@@ -276,7 +276,15 @@ module Phlex
276
276
  end
277
277
 
278
278
  if @response.render_self? && streams.none? { it.include?("data-reactive-token-value") }
279
- streams = [@component.to_stream_replace, *streams]
279
+ # Mirror the endpoint (issue #180): a self-render reply gets the full
280
+ # replace; a companion-only reply.with (no subject_component) gets a
281
+ # token-only refresh instead.
282
+ streams =
283
+ if @response.subject_component
284
+ [@component.to_stream_replace, *streams]
285
+ else
286
+ [*streams, @component.to_stream_token]
287
+ end
280
288
  end
281
289
  streams
282
290
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.9.5"
5
+ VERSION = "0.10.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.5
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -142,6 +142,7 @@ files:
142
142
  - lib/phlex/reactive.rb
143
143
  - lib/phlex/reactive/authorization.rb
144
144
  - lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md
145
+ - lib/phlex/reactive/client_bindings.rb
145
146
  - lib/phlex/reactive/component.rb
146
147
  - lib/phlex/reactive/component/dsl.rb
147
148
  - lib/phlex/reactive/component/helpers.rb
@@ -168,6 +169,7 @@ files:
168
169
  - lib/phlex/reactive/param_schema.rb
169
170
  - lib/phlex/reactive/reply.rb
170
171
  - lib/phlex/reactive/response.rb
172
+ - lib/phlex/reactive/show_conditions.rb
171
173
  - lib/phlex/reactive/stream.rb
172
174
  - lib/phlex/reactive/streamable.rb
173
175
  - lib/phlex/reactive/test_helpers.rb