phlex-reactive 0.16.7 → 0.16.9
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 +94 -1
- data/README.md +106 -10
- data/app/javascript/phlex/reactive/core.min.js +6 -6
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/early.js +28 -1
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +52 -17
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/compute.js +32 -4
- data/app/javascript/phlex/reactive/features/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/features/compute.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/devtools.js +15 -5
- data/app/javascript/phlex/reactive/features/devtools.min.js +2 -2
- data/app/javascript/phlex/reactive/features/devtools.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +6 -6
- data/app/javascript/phlex/reactive/runtime.js +146 -22
- data/lib/phlex/reactive/component/dsl.rb +2 -0
- data/lib/phlex/reactive/component/helpers.rb +50 -4
- data/lib/phlex/reactive/js.rb +211 -41
- data/lib/phlex/reactive/response.rb +4 -0
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -23,8 +23,16 @@ module Phlex
|
|
|
23
23
|
#
|
|
24
24
|
# Targets: a CSS selector string is resolved WITHIN the component's root by
|
|
25
25
|
# default (nested reactive roots excluded, issue #15 semantics); `:root`
|
|
26
|
-
# targets the root element itself; `
|
|
27
|
-
#
|
|
26
|
+
# targets the root element itself; `:trigger` (issue #442) targets the
|
|
27
|
+
# element whose on_client binding fired (with on_client's within:, the
|
|
28
|
+
# matching element under it); `global: true` opts a single op out of root
|
|
29
|
+
# scoping (document escape hatch, e.g. a page-level overlay).
|
|
30
|
+
#
|
|
31
|
+
# Reads (issue #442): set_value, text and set_attr take `from:` + `attr:`
|
|
32
|
+
# in place of a literal value — the value is read in the browser off the
|
|
33
|
+
# `from` element (a data-* attribute or its text), so one chain bound once
|
|
34
|
+
# serves every row of a list:
|
|
35
|
+
# js.set_value("#item-value", from: :trigger, attr: "data-value")
|
|
28
36
|
#
|
|
29
37
|
# The op vocabulary is a fixed whitelist mirrored by the client interpreter
|
|
30
38
|
# — an op name the client doesn't know is warn-and-skipped there
|
|
@@ -35,6 +43,38 @@ module Phlex
|
|
|
35
43
|
# Serialized stand-in for "the component's own root element".
|
|
36
44
|
ROOT_SENTINEL = "@root"
|
|
37
45
|
|
|
46
|
+
# Serialized stand-in for "the element whose on_client binding fired"
|
|
47
|
+
# (issue #442). Only an on_client binding has one: a server-pushed op
|
|
48
|
+
# (reply.js, broadcast_to(js:)) or a reactive_on_complete run refuses it.
|
|
49
|
+
TRIGGER_SENTINEL = "@trigger"
|
|
50
|
+
|
|
51
|
+
# attr: :text's wire value — read the element's textContent.
|
|
52
|
+
TEXT_READ = "@text"
|
|
53
|
+
|
|
54
|
+
# What a from: read may take off an element (issue #442): a data-*
|
|
55
|
+
# attribute — state the server declared on the element — or its text.
|
|
56
|
+
# Two-sided like the write allowlist below: the client refuses anything
|
|
57
|
+
# else in a hand-built record. Narrow on purpose (widening later breaks
|
|
58
|
+
# nothing; narrowing would).
|
|
59
|
+
READABLE_ATTR = /\Adata-[a-z][a-z0-9-]*\z/i
|
|
60
|
+
|
|
61
|
+
# The ops whose value from: + attr: can read, and the arg keys that name
|
|
62
|
+
# an element (where a server-pushed op has no @trigger to put).
|
|
63
|
+
READ_OPS = %w[set_value text set_attr].freeze
|
|
64
|
+
TARGET_KEYS = %w[to from expanded submitter].freeze
|
|
65
|
+
|
|
66
|
+
# The field-writing ops write a field the author names: never the root
|
|
67
|
+
# (a container) nor the trigger (the element clicked).
|
|
68
|
+
FIELD_REFUSED = {
|
|
69
|
+
:root => "the component root", ROOT_SENTINEL => "the component root",
|
|
70
|
+
:trigger => "the trigger", TRIGGER_SENTINEL => "the trigger"
|
|
71
|
+
}.freeze
|
|
72
|
+
|
|
73
|
+
# The positional value set_value/text/set_attr default to, so nil keeps
|
|
74
|
+
# meaning "clear" and only from: may replace it.
|
|
75
|
+
NO_VALUE = Object.new.freeze
|
|
76
|
+
private_constant :NO_VALUE
|
|
77
|
+
|
|
38
78
|
# The attribute-name allowlist (issue #96) — the security-critical part of
|
|
39
79
|
# the attr ops, enforced HERE at build time AND again in the client
|
|
40
80
|
# interpreter (two-sided default-deny: a hand-built ops attr must not
|
|
@@ -75,15 +115,73 @@ module Phlex
|
|
|
75
115
|
"target must be :root or a CSS selector string, got #{to.inspect}"
|
|
76
116
|
end
|
|
77
117
|
|
|
118
|
+
# The op builder's target: normalize_target plus :trigger (issue #442).
|
|
119
|
+
# Separate so a pending hint or a css-vars binding given :trigger keeps
|
|
120
|
+
# raising — neither has a trigger element.
|
|
121
|
+
def self.normalize_client_target(to)
|
|
122
|
+
return TRIGGER_SENTINEL if to == :trigger
|
|
123
|
+
return ROOT_SENTINEL if to == :root
|
|
124
|
+
return to if to.is_a?(String)
|
|
125
|
+
|
|
126
|
+
raise ArgumentError,
|
|
127
|
+
"target must be :trigger, :root or a CSS selector string, got #{to.inspect}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Also refuses @trigger (a raw list is server-pushed or a
|
|
131
|
+
# reactive_on_complete run: no trigger) and a read attr outside the
|
|
132
|
+
# data-*/text rule (issue #442).
|
|
78
133
|
def self.assert_ops_allowed!(list)
|
|
134
|
+
refuse_trigger!(list)
|
|
79
135
|
Array(list).each do |op, args|
|
|
80
|
-
next unless
|
|
136
|
+
next unless args.is_a?(::Hash)
|
|
137
|
+
|
|
138
|
+
name = arg(args, "name")
|
|
139
|
+
assert_allowed_attr(name.to_s) if name && ATTR_NAME_OPS.include?(op.to_s)
|
|
140
|
+
# A read: the client takes args.attr only on a read op with a from.
|
|
141
|
+
read_attr(arg(args, "attr"), op) if READ_OPS.include?(op.to_s) && arg(args, "from")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Raise when an op in `list` (a JS chain or a raw [[op, args]] list)
|
|
146
|
+
# names @trigger (a target, from:, or dispatch's detail_from:, which
|
|
147
|
+
# reads off it). Called for every op the server pushes
|
|
148
|
+
# (Response.js_ops_json: reply.js and broadcast_to(js:)) and for a
|
|
149
|
+
# reactive_on_complete run.
|
|
150
|
+
def self.refuse_trigger!(list, where = "a server-pushed op (reply.js, broadcast_to(js:)) " \
|
|
151
|
+
"or a reactive_on_complete run")
|
|
152
|
+
op = trigger_op(list.is_a?(JS) ? list.ops : list)
|
|
153
|
+
return if op.nil?
|
|
154
|
+
|
|
155
|
+
raise ArgumentError,
|
|
156
|
+
"#{self}: #{op} names @trigger, the element whose on_client binding fired (a :trigger " \
|
|
157
|
+
"target, from: :trigger or detail_from:) — #{where} has no trigger. Name the element " \
|
|
158
|
+
"with a CSS selector."
|
|
159
|
+
end
|
|
81
160
|
|
|
82
|
-
|
|
83
|
-
|
|
161
|
+
# The name of the first op that names @trigger, or nil. A raw list, or a
|
|
162
|
+
# hand-built JS.new([...]), may carry Symbol keys or no args Hash.
|
|
163
|
+
def self.trigger_op(list)
|
|
164
|
+
Array(list).each do |op, args|
|
|
165
|
+
next unless args.is_a?(::Hash)
|
|
166
|
+
return op.to_s if arg(args, "detail_from") || TARGET_KEYS.any? { arg(args, it) == TRIGGER_SENTINEL }
|
|
84
167
|
end
|
|
168
|
+
nil
|
|
85
169
|
end
|
|
86
170
|
|
|
171
|
+
# What attr: may read (issue #442): :text (or its "@text" wire form) or a
|
|
172
|
+
# data-* attribute name. Returns the wire value; raises otherwise.
|
|
173
|
+
def self.read_attr(attr, verb = "a read")
|
|
174
|
+
return TEXT_READ if [:text, TEXT_READ].include?(attr)
|
|
175
|
+
return attr if attr.is_a?(String) && attr.match?(READABLE_ATTR)
|
|
176
|
+
|
|
177
|
+
raise ArgumentError,
|
|
178
|
+
"#{self}: #{verb} attr: reads a data-* attribute or :text (textContent), got #{attr.inspect}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# A raw list's args may carry String or Symbol keys.
|
|
182
|
+
def self.arg(args, key) = args.key?(key) ? args[key] : args[key.to_sym]
|
|
183
|
+
private_class_method :arg
|
|
184
|
+
|
|
87
185
|
# The attribute-name allowlist (issue #96), case-insensitive. Refuses
|
|
88
186
|
# event-handler (on*), URL-bearing, and style attributes. Enforced at build
|
|
89
187
|
# time by the instance builder AND on the raw-list escape hatch — two-sided
|
|
@@ -172,9 +270,15 @@ module Phlex
|
|
|
172
270
|
# instead of toggling presence — current == on ? off : on, so an absent
|
|
173
271
|
# attribute becomes `on`. For aria-expanded/aria-pressed/data-state:
|
|
174
272
|
# js.toggle_attr("#trigger", "aria-expanded", "true", "false")
|
|
273
|
+
#
|
|
274
|
+
# set_attr(to, name, from:, attr:) (issue #442) reads the value off an
|
|
275
|
+
# element instead (see #set_value); the name allowlist above still
|
|
276
|
+
# decides what may be WRITTEN:
|
|
277
|
+
# js.set_attr(:trigger, "aria-selected", "true")
|
|
278
|
+
# js.set_attr("#preview", "data-state", from: :trigger, attr: "data-state")
|
|
175
279
|
|
|
176
|
-
def set_attr(to, name, value, global: false, keep: false)
|
|
177
|
-
append("set_attr", attr_args(to, name, global:, keep:,
|
|
280
|
+
def set_attr(to, name, value = NO_VALUE, from: nil, attr: nil, global: false, keep: false)
|
|
281
|
+
append("set_attr", attr_args(to, name, global:, keep:) { value_args(it, "set_attr", value, from:, attr:) })
|
|
178
282
|
end
|
|
179
283
|
|
|
180
284
|
def remove_attr(to, name, global: false, keep: false)
|
|
@@ -251,12 +355,13 @@ module Phlex
|
|
|
251
355
|
args = target_args(to, global:)
|
|
252
356
|
return append("submit", args) if submitter.nil?
|
|
253
357
|
|
|
254
|
-
unless submitter.is_a?(String) && !submitter.empty?
|
|
358
|
+
unless submitter == :trigger || (submitter.is_a?(String) && !submitter.empty?)
|
|
255
359
|
raise ArgumentError,
|
|
256
|
-
"js.submit submitter: must be a CSS selector string naming a submit control
|
|
360
|
+
"js.submit submitter: must be a CSS selector string naming a submit control " \
|
|
361
|
+
"(or :trigger), got #{submitter.inspect}"
|
|
257
362
|
end
|
|
258
363
|
|
|
259
|
-
append("submit", args.merge("submitter" => submitter).freeze)
|
|
364
|
+
append("submit", args.merge("submitter" => normalize_target(submitter)).freeze)
|
|
260
365
|
end
|
|
261
366
|
|
|
262
367
|
# --- Clipboard-source paste (issue #228) ---
|
|
@@ -279,12 +384,8 @@ module Phlex
|
|
|
279
384
|
# element (the two passes would fight over the same attribute).
|
|
280
385
|
|
|
281
386
|
def paste_into(to, global: false)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
"#{self.class}: paste_into targets a field, not the component root — " \
|
|
285
|
-
"pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))"
|
|
286
|
-
end
|
|
287
|
-
|
|
387
|
+
refuse_field_target!(to, "paste_into targets a field, not %s — " \
|
|
388
|
+
"pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))")
|
|
288
389
|
append("paste_into", target_args(to, global:))
|
|
289
390
|
end
|
|
290
391
|
|
|
@@ -344,12 +445,8 @@ module Phlex
|
|
|
344
445
|
|
|
345
446
|
def preview_file(to, into:, name: nil, global: false, valid: false, wrap: nil, max_bytes: nil, accept: nil,
|
|
346
447
|
per_file: false)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
"#{self.class}: preview_file reads a file input, not the component root — " \
|
|
350
|
-
"pass the input's CSS selector (e.g. js.preview_file(\"#photo-file\", into: \"#photo\"))"
|
|
351
|
-
end
|
|
352
|
-
|
|
448
|
+
refuse_field_target!(to, "preview_file reads a file input, not %s — pass the input's CSS selector " \
|
|
449
|
+
"(e.g. js.preview_file(\"#photo-file\", into: \"#photo\"))")
|
|
353
450
|
args = { "to" => normalize_target(to), "into" => preview_selector(:into, into) }
|
|
354
451
|
args["name"] = preview_selector(:name, name) unless name.nil?
|
|
355
452
|
args["global"] = true if global
|
|
@@ -368,9 +465,14 @@ module Phlex
|
|
|
368
465
|
# less powerful than set_attr. Pair with `global: true` to paint a value
|
|
369
466
|
# into a node OUTSIDE the component's root (the cross-root text escape,
|
|
370
467
|
# e.g. a read-only recap in another tab pane).
|
|
468
|
+
#
|
|
469
|
+
# text(to, from:, attr:) (issue #442) reads the text off an element
|
|
470
|
+
# instead (see #set_value) — the label of the row that was clicked:
|
|
471
|
+
# js.text("#item-label", from: :trigger, attr: "data-label")
|
|
371
472
|
|
|
372
|
-
def text(to, value, global: false)
|
|
373
|
-
args = { "to" => normalize_target(to)
|
|
473
|
+
def text(to, value = NO_VALUE, from: nil, attr: nil, global: false)
|
|
474
|
+
args = { "to" => normalize_target(to) }
|
|
475
|
+
value_args(args, "text", value, from:, attr:)
|
|
374
476
|
args["global"] = true if global
|
|
375
477
|
append("text", args.freeze)
|
|
376
478
|
end
|
|
@@ -413,15 +515,21 @@ module Phlex
|
|
|
413
515
|
# ACTOR-ONLY like paste_into: a broadcast that overwrote every
|
|
414
516
|
# subscriber's field would be hostile (BROADCAST_REFUSED_OPS).
|
|
415
517
|
# button(**on_client(:click, js.set_value("#notes", "Thanks"))) { "Thanks" }
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
518
|
+
#
|
|
519
|
+
# Reads (issue #442): `from:` + `attr:` in place of the value read it in
|
|
520
|
+
# the browser off an element — `from:` is :trigger, :root or a selector,
|
|
521
|
+
# resolved with the op's own scoping (global: included), first match;
|
|
522
|
+
# `attr:` is a data-* attribute or :text (textContent). A missing
|
|
523
|
+
# attribute reads "", so it clears; a `from:` matching nothing skips the
|
|
524
|
+
# op. Exactly one of the value or `from:`. The same on text and set_attr:
|
|
525
|
+
# ul(**on_client(:click, js.set_value("#item-value", from: :trigger, attr: "data-value"),
|
|
526
|
+
# within: "[role=option]"))
|
|
527
|
+
|
|
528
|
+
def set_value(to, value = NO_VALUE, from: nil, attr: nil, global: false)
|
|
529
|
+
refuse_field_target!(to, "set_value targets a field, not %s — " \
|
|
530
|
+
"pass the field's CSS selector (e.g. js.set_value(\"#notes\", \"Thanks\"))")
|
|
531
|
+
args = { "to" => normalize_target(to) }
|
|
532
|
+
value_args(args, "set_value", value, from:, attr:)
|
|
425
533
|
args["global"] = true if global
|
|
426
534
|
append("set_value", args.freeze)
|
|
427
535
|
end
|
|
@@ -465,8 +573,15 @@ module Phlex
|
|
|
465
573
|
# uniformly); `detail:` is the event's `detail` payload. The client uses raw
|
|
466
574
|
# element.dispatchEvent — the shared controller SHADOWS Stimulus's
|
|
467
575
|
# this.dispatch helper, so the interpreter must not use it.
|
|
468
|
-
|
|
576
|
+
#
|
|
577
|
+
# detail_from: (issue #442) maps detail keys to what to read off the
|
|
578
|
+
# TRIGGER (a data-* attribute or :text), merged over `detail:` in the
|
|
579
|
+
# browser — every value a String. Only an on_client binding has a
|
|
580
|
+
# trigger: a server-pushed dispatch refuses it.
|
|
581
|
+
# js.dispatch("app:item-picked", detail_from: { id: "data-id", text: :text })
|
|
582
|
+
def dispatch(name, to: nil, detail: {}, detail_from: {}, global: false)
|
|
469
583
|
args = { "name" => name.to_s, "to" => normalize_target(to.nil? ? :root : to), "detail" => detail }
|
|
584
|
+
args["detail_from"] = detail_reads(detail, detail_from) unless detail_from.nil? || detail_from == {}
|
|
470
585
|
args["global"] = true if global
|
|
471
586
|
append("dispatch", args.freeze)
|
|
472
587
|
end
|
|
@@ -609,17 +724,72 @@ module Phlex
|
|
|
609
724
|
[on, off].freeze
|
|
610
725
|
end
|
|
611
726
|
|
|
612
|
-
# An attr op's args: the target, the allowlisted name,
|
|
613
|
-
# (
|
|
614
|
-
|
|
727
|
+
# An attr op's args: the target, the allowlisted name, then whatever the
|
|
728
|
+
# block adds (set_attr's value or read). The name is validated LOUDLY at
|
|
729
|
+
# build time.
|
|
730
|
+
def attr_args(to, name, global:, keep: false)
|
|
615
731
|
name = name.to_s
|
|
616
732
|
assert_allowed_attr(name)
|
|
617
733
|
args = { "to" => normalize_target(to), "name" => name }
|
|
618
|
-
args
|
|
734
|
+
yield args if block_given?
|
|
619
735
|
args["global"] = true if global
|
|
620
736
|
with_keep(args, keep)
|
|
621
737
|
end
|
|
622
738
|
|
|
739
|
+
# The value of set_value/text/set_attr (issue #442): the literal
|
|
740
|
+
# (stringified; nil clears) as "value", or a read as "from" + "attr".
|
|
741
|
+
# Exactly one — loud at render.
|
|
742
|
+
def value_args(args, verb, value, from:, attr:)
|
|
743
|
+
literal = !value.equal?(NO_VALUE)
|
|
744
|
+
raise ArgumentError, "#{self.class}: #{verb} takes a value or from:, not both" if literal && !from.nil?
|
|
745
|
+
if from.nil? && !attr.nil?
|
|
746
|
+
raise ArgumentError,
|
|
747
|
+
"#{self.class}: #{verb} attr: needs from: (the element to read it off)"
|
|
748
|
+
end
|
|
749
|
+
return args["value"] = value.to_s if literal
|
|
750
|
+
raise ArgumentError, "#{self.class}: #{verb} needs a value or from: + attr:" if from.nil?
|
|
751
|
+
raise ArgumentError, "#{self.class}: #{verb} from: needs attr: (a data-* attribute or :text)" if attr.nil?
|
|
752
|
+
|
|
753
|
+
source = normalize_target(from)
|
|
754
|
+
raise ArgumentError, "#{self.class}: #{verb} from: needs a CSS selector, got an empty string" if source.empty?
|
|
755
|
+
|
|
756
|
+
args["from"] = source
|
|
757
|
+
args["attr"] = self.class.read_attr(attr, verb)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# dispatch's detail_from: — detail key → read attr, the keys Strings.
|
|
761
|
+
# A key also in detail: would silently override it, so it raises.
|
|
762
|
+
def detail_reads(detail, reads)
|
|
763
|
+
unless reads.is_a?(Hash)
|
|
764
|
+
raise ArgumentError,
|
|
765
|
+
"#{self.class}: dispatch detail_from: takes a Hash of detail keys to data-* attributes " \
|
|
766
|
+
"(or :text), got #{reads.inspect}"
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
literal = detail.is_a?(Hash) ? detail.keys.map(&:to_s) : []
|
|
770
|
+
normalized = reads.to_h do |key, attr|
|
|
771
|
+
key = key.to_s
|
|
772
|
+
if literal.include?(key)
|
|
773
|
+
raise ArgumentError,
|
|
774
|
+
"#{self.class}: dispatch detail_from: key #{key.inspect} is also in detail: — one would override the other"
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
[key, self.class.read_attr(attr, "dispatch detail_from: #{key.inspect}")]
|
|
778
|
+
end
|
|
779
|
+
if normalized.size < reads.size
|
|
780
|
+
raise ArgumentError, "#{self.class}: dispatch detail_from: keys must be unique as strings"
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
normalized.freeze
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# set_value/paste_into/preview_file write a field the author names:
|
|
787
|
+
# `message` has a %s for what was named instead (the root, the trigger).
|
|
788
|
+
def refuse_field_target!(to, message)
|
|
789
|
+
where = FIELD_REFUSED[to]
|
|
790
|
+
raise ArgumentError, "#{self.class}: #{format(message, where)}" if where
|
|
791
|
+
end
|
|
792
|
+
|
|
623
793
|
# Build-time attr-name allowlist for the instance builder — delegates to the
|
|
624
794
|
# shared class-method check (also used by the raw-list escape hatch).
|
|
625
795
|
def assert_allowed_attr(name)
|
|
@@ -655,9 +825,9 @@ module Phlex
|
|
|
655
825
|
with_keep(args, keep)
|
|
656
826
|
end
|
|
657
827
|
|
|
658
|
-
#
|
|
659
|
-
# JS.
|
|
660
|
-
def normalize_target(to) = self.class.
|
|
828
|
+
# The op builder's target translation: :trigger, :root or a selector
|
|
829
|
+
# (JS.normalize_client_target; issue #442).
|
|
830
|
+
def normalize_target(to) = self.class.normalize_client_target(to)
|
|
661
831
|
end
|
|
662
832
|
end
|
|
663
833
|
end
|
|
@@ -302,10 +302,14 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
|
|
|
302
302
|
# Normalize `ops` to its JSON wire form, rejecting an empty chain — a
|
|
303
303
|
# reactive:js stream with no ops is a dead stream (a mistake at the call
|
|
304
304
|
# site), so fail loudly rather than emit an inert tag.
|
|
305
|
+
# Every op the server pushes (reply.js, broadcast_to(js:)) passes here,
|
|
306
|
+
# so this is where @trigger is refused for a chain too (issue #442): a
|
|
307
|
+
# pushed op has no element whose binding fired.
|
|
305
308
|
def js_ops_json(ops)
|
|
306
309
|
if ops.is_a?(Phlex::Reactive::JS)
|
|
307
310
|
raise ArgumentError, "js(...) got no ops — a dead reactive:js stream" if ops.empty?
|
|
308
311
|
|
|
312
|
+
Phlex::Reactive::JS.refuse_trigger!(ops)
|
|
309
313
|
ops.to_json
|
|
310
314
|
else
|
|
311
315
|
list = Array(ops)
|