phlex-reactive 0.12.2 → 0.12.3
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +135 -1
- data/app/javascript/phlex/reactive/compute.js +152 -0
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +214 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +84 -0
- data/lib/phlex/reactive/component/helpers.rb +16 -0
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component.rb +9 -0
- data/lib/phlex/reactive/js.rb +15 -0
- data/lib/phlex/reactive/show_conditions.rb +116 -0
- data/lib/phlex/reactive/streamable.rb +13 -8
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -450,6 +450,71 @@ module Phlex
|
|
|
450
450
|
Registry.resolve_hash(self, :computes, :reactive_computes)
|
|
451
451
|
end
|
|
452
452
|
|
|
453
|
+
# A fresh client-op chain at CLASS level (issue #226), so a
|
|
454
|
+
# reactive_on_complete declaration reads like its on_client sibling:
|
|
455
|
+
#
|
|
456
|
+
# reactive_on_complete if: { code: { length: 6 } },
|
|
457
|
+
# run: js.dispatch("code:complete")
|
|
458
|
+
#
|
|
459
|
+
# The instance helper (Component::Helpers#js) is unchanged.
|
|
460
|
+
def js
|
|
461
|
+
Phlex::Reactive::JS.new
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Declarative completion binding (issue #226): when the declared
|
|
465
|
+
# conditions FIRST become true — evaluated client-side over the owned
|
|
466
|
+
# fields on input/change — run a chain of client DOM ops. The
|
|
467
|
+
# conditions are the SAME if:/if_any:/unless: kwargs reactive_show
|
|
468
|
+
# takes (the ONE ShowConditions language, including the length: form);
|
|
469
|
+
# `run:` is a js chain or a raw [[op, args], …] list (allowlist
|
|
470
|
+
# re-checked, like every raw-ops escape hatch).
|
|
471
|
+
#
|
|
472
|
+
# reactive_on_complete if: { code: { length: 6 } },
|
|
473
|
+
# run: js.dispatch("code:complete")
|
|
474
|
+
# reactive_on_complete :commit, if: { code: { length: 6 } }, run: js.submit
|
|
475
|
+
#
|
|
476
|
+
# RISING-EDGE, actor-gesture semantics on the client: ops fire once on
|
|
477
|
+
# the flip to true, re-arm when the conditions go false, and the
|
|
478
|
+
# connect/morph pass arms WITHOUT firing (a re-render with already-
|
|
479
|
+
# satisfied conditions never self-fires). The optional NAME (default
|
|
480
|
+
# :default) keys the registry so a component can declare several
|
|
481
|
+
# independent bindings; redeclaring a name overrides it (inheritance
|
|
482
|
+
# follows the Registry rules, like reactive_compute).
|
|
483
|
+
def reactive_on_complete(name = :default, run:, **conditions)
|
|
484
|
+
groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
|
|
485
|
+
Registry.write_entry(
|
|
486
|
+
self, :on_completes, name.to_sym,
|
|
487
|
+
OnCompleteDefinition.new(
|
|
488
|
+
name: name.to_sym, conditions: groups,
|
|
489
|
+
ops: normalize_on_complete_ops(name, run)
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def reactive_on_completes
|
|
495
|
+
Registry.resolve_hash(self, :on_completes, :reactive_on_completes)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# The wire JSON for the root's data-reactive-on-complete attr — a
|
|
499
|
+
# frozen array of { any:, ops: } bindings, or nil when undeclared
|
|
500
|
+
# (byte-stable wire). Memoized per class against the registry
|
|
501
|
+
# generation (one integer compare) because reactive_attrs is the
|
|
502
|
+
# token-signing hot path — the reactive_effect_attrs precedent.
|
|
503
|
+
def reactive_on_complete_attr
|
|
504
|
+
registry_gen = Registry.generation
|
|
505
|
+
return @reactive_on_complete_attr if @reactive_on_complete_attr_gen == registry_gen
|
|
506
|
+
|
|
507
|
+
bindings = reactive_on_completes
|
|
508
|
+
@reactive_on_complete_attr =
|
|
509
|
+
if bindings.empty?
|
|
510
|
+
nil
|
|
511
|
+
else
|
|
512
|
+
bindings.values.map { { "any" => it.conditions, "ops" => it.ops } }.to_json.freeze
|
|
513
|
+
end
|
|
514
|
+
@reactive_on_complete_attr_gen = registry_gen
|
|
515
|
+
@reactive_on_complete_attr
|
|
516
|
+
end
|
|
517
|
+
|
|
453
518
|
# Fetch one compute definition — the reader form matching
|
|
454
519
|
# reactive_collection_def (issue #115). The bare reactive_compute(name)
|
|
455
520
|
# getter above is a PERMANENT documented alias (it shipped in the same
|
|
@@ -480,6 +545,25 @@ module Phlex
|
|
|
480
545
|
# nil types, so the array-form wire stays byte-identical and the client
|
|
481
546
|
# keeps its numeric coercion.
|
|
482
547
|
#
|
|
548
|
+
# Validate + normalize a reactive_on_complete run: chain (issue #226)
|
|
549
|
+
# to the raw [[op, args], …] pairs. A js chain is already validated at
|
|
550
|
+
# build time; a raw list re-runs the attr allowlist — the same
|
|
551
|
+
# defense-in-depth every raw-ops escape hatch gets (js([...]) /
|
|
552
|
+
# broadcast_to(js: [...])). Loud on anything else — a dead or hostile
|
|
553
|
+
# binding must fail at class load, never no-op in the browser.
|
|
554
|
+
def normalize_on_complete_ops(name, run)
|
|
555
|
+
pairs = run.is_a?(Phlex::Reactive::JS) ? run.ops : run
|
|
556
|
+
unless pairs.is_a?(Array)
|
|
557
|
+
raise ArgumentError,
|
|
558
|
+
"reactive_on_complete #{name.inspect} run: takes a js chain " \
|
|
559
|
+
"(js.dispatch(\"code:complete\")) or a raw [[op, args], ...] list, got #{run.class}"
|
|
560
|
+
end
|
|
561
|
+
raise ArgumentError, "reactive_on_complete #{name.inspect} got no ops — a dead binding" if pairs.empty?
|
|
562
|
+
|
|
563
|
+
Phlex::Reactive::JS.assert_ops_allowed!(pairs) unless run.is_a?(Phlex::Reactive::JS)
|
|
564
|
+
pairs
|
|
565
|
+
end
|
|
566
|
+
|
|
483
567
|
# Named after the shape it normalizes.
|
|
484
568
|
def normalize_compute_inputs(inputs)
|
|
485
569
|
return normalize_typed_compute_inputs(inputs) if inputs.is_a?(Hash)
|
|
@@ -98,6 +98,14 @@ module Phlex
|
|
|
98
98
|
if self.class.respond_to?(:reactive_effect_attrs) && (effects = self.class.reactive_effect_attrs)
|
|
99
99
|
data.merge!(effects)
|
|
100
100
|
end
|
|
101
|
+
# Completion bindings (issue #226): the declared reactive_on_complete
|
|
102
|
+
# bindings ride the root as ONE JSON attr the client evaluates over
|
|
103
|
+
# the owned fields (rising edge → run the ops). nil when undeclared —
|
|
104
|
+
# no key, byte-stable wire. Per-class memo (one integer compare).
|
|
105
|
+
if self.class.respond_to?(:reactive_on_complete_attr) &&
|
|
106
|
+
(on_complete = self.class.reactive_on_complete_attr)
|
|
107
|
+
data[:reactive_on_complete] = on_complete
|
|
108
|
+
end
|
|
101
109
|
{ data: }
|
|
102
110
|
end
|
|
103
111
|
|
|
@@ -381,6 +389,14 @@ module Phlex
|
|
|
381
389
|
raise ArgumentError, "on_client(#{event.inspect}) got no ops — a dead trigger" if ops.empty?
|
|
382
390
|
|
|
383
391
|
event = event.to_s
|
|
392
|
+
# Issue #226: requestSubmit dispatches the very `submit` event this
|
|
393
|
+
# trigger would be bound to — an infinite loop. Loud at render.
|
|
394
|
+
if event == "submit" && ops.ops.any? { |name, _| name == "submit" }
|
|
395
|
+
raise ArgumentError,
|
|
396
|
+
"on_client(:submit, js.submit) would re-fire itself — requestSubmit dispatches the " \
|
|
397
|
+
"submit event this trigger is bound to. Bind the submit op to another event " \
|
|
398
|
+
"(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
|
|
399
|
+
end
|
|
384
400
|
window_bound = window || outside
|
|
385
401
|
attrs = {
|
|
386
402
|
data: {
|
|
@@ -50,6 +50,8 @@ module Phlex
|
|
|
50
50
|
state_keys: :@reactive_own_state_keys,
|
|
51
51
|
collections: :@reactive_own_collections,
|
|
52
52
|
computes: :@reactive_own_computes,
|
|
53
|
+
# Completion bindings — reactive_on_complete (issue #226).
|
|
54
|
+
on_completes: :@reactive_own_on_completes,
|
|
53
55
|
record_key: :@reactive_own_record_key,
|
|
54
56
|
# Form-field namespace — reactive_scope (issue #180).
|
|
55
57
|
scope: :@reactive_own_scope,
|
|
@@ -104,6 +104,15 @@ module Phlex
|
|
|
104
104
|
# default-deny; the client interpreter re-checks the shape).
|
|
105
105
|
ComputeDefinition = Data.define(:name, :inputs, :outputs, :reducer, :input_types, :mirror)
|
|
106
106
|
|
|
107
|
+
# A declared completion binding (issue #226): `conditions` are the
|
|
108
|
+
# pre-compiled ShowConditions DNF groups (the ONE conditions language,
|
|
109
|
+
# including the #226 length: form); `ops` is the validated [[op, args],…]
|
|
110
|
+
# client-op chain. The generic controller evaluates the conditions over
|
|
111
|
+
# the owned fields on input/change and runs the ops on the RISING EDGE —
|
|
112
|
+
# the conditions' first flip to true — through the same frozen CLIENT_OPS
|
|
113
|
+
# whitelist as on_client. The connect/morph pass arms without firing.
|
|
114
|
+
OnCompleteDefinition = Data.define(:name, :conditions, :ops)
|
|
115
|
+
|
|
107
116
|
# A declared add/remove-row collection (issue #35): the list contract tied
|
|
108
117
|
# into one unit — the per-row item component, the container DOM id rows
|
|
109
118
|
# live in, an optional companion count id, an optional empty-state
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -170,6 +170,21 @@ module Phlex
|
|
|
170
170
|
append("focus_first", target_args(to, global:))
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
+
# --- Submit (issue #226) ---
|
|
174
|
+
#
|
|
175
|
+
# submit(to = :root) — requestSubmit() the TARGET'S OWN form: the target
|
|
176
|
+
# itself when it IS a form, else its form owner (input.form, honoring a
|
|
177
|
+
# form= attribute), else the nearest ancestor form. requestSubmit runs
|
|
178
|
+
# constraint validation and fires a REAL cancelable `submit` event, so it
|
|
179
|
+
# composes with both a native/Turbo form and an on(:action, event:
|
|
180
|
+
# "submit") interception. ACTOR-ONLY like focus: allowed from on_client /
|
|
181
|
+
# reply.js / a reducer's $ops, refused in broadcast_to(js:) — a broadcast
|
|
182
|
+
# submit would force-submit every subscriber's form.
|
|
183
|
+
|
|
184
|
+
def submit(to = :root, global: false)
|
|
185
|
+
append("submit", target_args(to, global:))
|
|
186
|
+
end
|
|
187
|
+
|
|
173
188
|
# --- Text content (issue #159) ---
|
|
174
189
|
#
|
|
175
190
|
# text(to, value) — set the target's textContent (stringified; nil clears).
|
|
@@ -17,6 +17,9 @@ module Phlex
|
|
|
17
17
|
# (..10) -> lte 10
|
|
18
18
|
# (...10) -> lt 10
|
|
19
19
|
# (10..20) -> gte 10 AND lte 20 (two terms, one group)
|
|
20
|
+
# { length: 6 } -> len_eq 6 (issue #226 — the value's
|
|
21
|
+
# { length: 6.. } / (4..8) CODEPOINT count; Integer Ranges reuse
|
|
22
|
+
# the threshold vocabulary as len_*)
|
|
20
23
|
# Under unless: each term is NEGATED (De Morgan):
|
|
21
24
|
# scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
|
|
22
25
|
# predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
|
|
@@ -29,6 +32,12 @@ module Phlex
|
|
|
29
32
|
# bounded-range complement splits). There is NO expression surface: every
|
|
30
33
|
# term is a declared literal predicate — the pre-#180 default-deny posture.
|
|
31
34
|
module ShowConditions
|
|
35
|
+
# The length-predicate wire keys (issue #226) — the Ruby half; the client
|
|
36
|
+
# mirrors them in SHOW_LENGTH_KEYS. Length is counted in CODEPOINTS
|
|
37
|
+
# (String#length here, [...value].length there) so multibyte values agree
|
|
38
|
+
# — the shared fixture's emoji vector proves it.
|
|
39
|
+
LENGTH_KEYS = %w[len_eq len_gte len_gt len_lte len_lt].freeze
|
|
40
|
+
|
|
32
41
|
module_function
|
|
33
42
|
|
|
34
43
|
# Compile if:/if_any:/unless: into DNF groups. Loud validation at render
|
|
@@ -128,6 +137,7 @@ module Phlex
|
|
|
128
137
|
case value
|
|
129
138
|
when Range then range_terms(name, value)
|
|
130
139
|
when Array then [membership_term(name, value)]
|
|
140
|
+
when Hash then length_terms(name, value)
|
|
131
141
|
else [{ "field" => name, "equals" => equals_literal(value) }]
|
|
132
142
|
end
|
|
133
143
|
end
|
|
@@ -171,6 +181,93 @@ module Phlex
|
|
|
171
181
|
end
|
|
172
182
|
end
|
|
173
183
|
|
|
184
|
+
# --- the length predicate (issue #226) ----------------------------------
|
|
185
|
+
|
|
186
|
+
# A Hash value names a STRUCTURAL predicate — today only length:. Exact
|
|
187
|
+
# Integer -> one len_eq term; an Integer Range -> len_gte/len_lte/len_lt
|
|
188
|
+
# terms exactly like range_terms. Length is a total function over the
|
|
189
|
+
# value's codepoints (blank/absent -> 0), so { length: 0 } legitimately
|
|
190
|
+
# matches a blank field.
|
|
191
|
+
def length_terms(name, hash)
|
|
192
|
+
value = length_value!(name, hash)
|
|
193
|
+
case value
|
|
194
|
+
when Integer
|
|
195
|
+
validate_length_literal!(name, value)
|
|
196
|
+
[{ "field" => name, "len_eq" => value }]
|
|
197
|
+
when Range then length_range_terms(name, value)
|
|
198
|
+
else raise_length_kind(name, value)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def length_range_terms(name, range)
|
|
203
|
+
first = range.begin
|
|
204
|
+
last = range.end
|
|
205
|
+
raise_length_kind(name, range) if first.nil? && last.nil?
|
|
206
|
+
[first, last].compact.each { validate_length_literal!(name, it) }
|
|
207
|
+
|
|
208
|
+
terms = []
|
|
209
|
+
terms << { "field" => name, "len_gte" => first } unless first.nil?
|
|
210
|
+
if last
|
|
211
|
+
terms << { "field" => name, (range.exclude_end? ? "len_lt" : "len_lte") => last }
|
|
212
|
+
end
|
|
213
|
+
terms
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The complement of a length predicate, under unless:. Exact length
|
|
217
|
+
# negates to the outside disjunction (len < n OR len > n) — two
|
|
218
|
+
# alternatives, like a bounded numeric range; ranges complement each leg
|
|
219
|
+
# (not-gte -> lt, not-lte -> gt, not-lt -> gte). Length is total, so
|
|
220
|
+
# unlike the numeric complements there is no blank/NaN fail-closed
|
|
221
|
+
# asymmetry — the complement is exact.
|
|
222
|
+
def length_complement(name, hash)
|
|
223
|
+
value = length_value!(name, hash)
|
|
224
|
+
case value
|
|
225
|
+
when Integer
|
|
226
|
+
validate_length_literal!(name, value)
|
|
227
|
+
[[{ "field" => name, "len_lt" => value }], [{ "field" => name, "len_gt" => value }]]
|
|
228
|
+
when Range
|
|
229
|
+
first = value.begin
|
|
230
|
+
last = value.end
|
|
231
|
+
raise_length_kind(name, value) if first.nil? && last.nil?
|
|
232
|
+
[first, last].compact.each { validate_length_literal!(name, it) }
|
|
233
|
+
|
|
234
|
+
low = first.nil? ? nil : [{ "field" => name, "len_lt" => first }]
|
|
235
|
+
high =
|
|
236
|
+
if last.nil? then nil
|
|
237
|
+
elsif value.exclude_end? then [{ "field" => name, "len_gte" => last }]
|
|
238
|
+
else [{ "field" => name, "len_gt" => last }]
|
|
239
|
+
end
|
|
240
|
+
[low, high].compact
|
|
241
|
+
else raise_length_kind(name, value)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The one Hash key must be length: — anything else is a typo'd predicate
|
|
246
|
+
# that must fail at render, never silently in the browser.
|
|
247
|
+
def length_value!(name, hash)
|
|
248
|
+
unless hash.size == 1 && hash.keys.first.to_s == "length"
|
|
249
|
+
raise ArgumentError,
|
|
250
|
+
"reactive_show: #{name.inspect} Hash value supports only length: " \
|
|
251
|
+
"(got #{hash.keys.inspect})"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
hash.values.first
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def validate_length_literal!(name, value)
|
|
258
|
+
return if value.is_a?(Integer) && value >= 0
|
|
259
|
+
|
|
260
|
+
raise ArgumentError,
|
|
261
|
+
"reactive_show: #{name.inspect} length: takes a non-negative Integer " \
|
|
262
|
+
"(a codepoint count), got #{value.inspect}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def raise_length_kind(name, value)
|
|
266
|
+
raise ArgumentError,
|
|
267
|
+
"reactive_show: #{name.inspect} length: takes a non-negative Integer " \
|
|
268
|
+
"or an Integer Range, got #{value.inspect}"
|
|
269
|
+
end
|
|
270
|
+
|
|
174
271
|
# --- the value language (negated, under unless:) -----------------------
|
|
175
272
|
|
|
176
273
|
# A field => value under unless: -> a LIST of alternative term-lists.
|
|
@@ -182,6 +279,7 @@ module Phlex
|
|
|
182
279
|
name = field.to_s
|
|
183
280
|
case value
|
|
184
281
|
when Range then range_complement(name, value)
|
|
282
|
+
when Hash then length_complement(name, value)
|
|
185
283
|
when Array
|
|
186
284
|
list = value.map(&:to_s)
|
|
187
285
|
raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
|
|
@@ -223,10 +321,28 @@ module Phlex
|
|
|
223
321
|
if term.key?("equals") then value == term["equals"]
|
|
224
322
|
elsif term.key?("not") then value != term["not"]
|
|
225
323
|
elsif term.key?("in") then term["in"].include?(value)
|
|
324
|
+
elsif (key = LENGTH_KEYS.find { term.key?(it) }) then length_term_matches?(key, term[key], value)
|
|
226
325
|
else numeric_term_matches?(term, value)
|
|
227
326
|
end
|
|
228
327
|
end
|
|
229
328
|
|
|
329
|
+
# len_* — compare the value's CODEPOINT count (String#length) against the
|
|
330
|
+
# Integer literal. Length is total (blank/absent -> 0), so every value is
|
|
331
|
+
# decidable; a malformed non-Integer literal (a hand-built term) is
|
|
332
|
+
# fail-closed, mirroring the client's warn-skip.
|
|
333
|
+
def length_term_matches?(key, literal, value)
|
|
334
|
+
return false unless literal.is_a?(Integer)
|
|
335
|
+
|
|
336
|
+
length = value.to_s.length
|
|
337
|
+
case key
|
|
338
|
+
when "len_eq" then length == literal
|
|
339
|
+
when "len_gte" then length >= literal
|
|
340
|
+
when "len_gt" then length > literal
|
|
341
|
+
when "len_lte" then length <= literal
|
|
342
|
+
when "len_lt" then length < literal
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
230
346
|
# gte/gt/lte/lt — coerce the field value to a number; a blank/non-numeric
|
|
231
347
|
# value is fail-closed (never matches). Mirrors the client's
|
|
232
348
|
# numericPredicateMatches (blank/whitespace -> NaN -> false).
|
|
@@ -33,9 +33,12 @@ module Phlex
|
|
|
33
33
|
# up without ever sharing a mutable context across threads.
|
|
34
34
|
ThreadViewContext = Struct.new(:view_context, :builder, :renderer, :generation)
|
|
35
35
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
36
|
+
# Actor-only ops: a js broadcast rejects a broadcast that carries one.
|
|
37
|
+
# Focus ops steal focus in every subscriber's tab (issue #96); submit
|
|
38
|
+
# (issue #226) would force-submit every subscriber's form. Both belong to
|
|
39
|
+
# the actor's own reply (reply.js) or gesture (on_client / a reducer's
|
|
40
|
+
# $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
|
|
41
|
+
BROADCAST_REFUSED_OPS = %w[focus focus_first submit].freeze
|
|
39
42
|
|
|
40
43
|
# The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
|
|
41
44
|
# SELF-TARGETING verbs derive the target from the component's #id (require a
|
|
@@ -516,16 +519,18 @@ module Phlex
|
|
|
516
519
|
# instrumentation + pgbus-threading path for the class-level and module-level
|
|
517
520
|
# broadcast_to, so the transport logic has exactly one spelling.)
|
|
518
521
|
|
|
519
|
-
# Validate + serialize broadcast ops: reject
|
|
520
|
-
# focus in every tab
|
|
521
|
-
#
|
|
522
|
+
# Validate + serialize broadcast ops: reject actor-only ops (focus steals
|
|
523
|
+
# focus in every tab; submit force-submits every subscriber's form) and an
|
|
524
|
+
# empty chain (a dead broadcast), then return the JSON wire form. Works on
|
|
525
|
+
# a JS chain (inspect .ops) and a raw array.
|
|
522
526
|
def broadcast_js_ops_json(ops)
|
|
523
527
|
pairs = ops.is_a?(Phlex::Reactive::JS) ? ops.ops : Array(ops)
|
|
524
528
|
refused = pairs.map { |name, _| name.to_s } & Phlex::Reactive::Streamable::BROADCAST_REFUSED_OPS
|
|
525
529
|
unless refused.empty?
|
|
526
530
|
raise ArgumentError,
|
|
527
|
-
"
|
|
528
|
-
"steals it in every subscriber's tab
|
|
531
|
+
"broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
|
|
532
|
+
"steals it in every subscriber's tab; broadcasting submit force-submits every " \
|
|
533
|
+
"subscriber's form. These are actor concerns (reply.js / on_client / $ops)."
|
|
529
534
|
end
|
|
530
535
|
|
|
531
536
|
Phlex::Reactive::Response.js_ops_json(ops)
|