phlex-reactive 0.10.0 → 0.11.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +246 -0
- data/README.md +127 -78
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -8
- data/app/javascript/phlex/reactive/confirm_predicate.js +52 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +370 -179
- 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 +122 -34
- data/lib/phlex/reactive/component/helpers.rb +307 -165
- data/lib/phlex/reactive/component/registry.rb +6 -1
- data/lib/phlex/reactive/component.rb +3 -3
- data/lib/phlex/reactive/defer.rb +1 -1
- data/lib/phlex/reactive/deferred_render_job.rb +2 -2
- data/lib/phlex/reactive/engine.rb +14 -0
- data/lib/phlex/reactive/js.rb +29 -15
- data/lib/phlex/reactive/reply.rb +105 -44
- data/lib/phlex/reactive/response.rb +139 -48
- data/lib/phlex/reactive/streamable.rb +229 -199
- data/lib/phlex/reactive/test_helpers.rb +1 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +137 -12
- metadata +4 -1
|
@@ -33,10 +33,40 @@ 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
|
-
# Focus ops are actor-only (they steal focus):
|
|
36
|
+
# Focus ops are actor-only (they steal focus): a js broadcast rejects a
|
|
37
37
|
# broadcast that carries one. Names mirror Phlex::Reactive::JS's focus verbs.
|
|
38
38
|
BROADCAST_REFUSED_OPS = %w[focus focus_first].freeze
|
|
39
39
|
|
|
40
|
+
# The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
|
|
41
|
+
# SELF-TARGETING verbs derive the target from the component's #id (require a
|
|
42
|
+
# Streamable payload); CONTAINER verbs need an explicit target: and accept any
|
|
43
|
+
# Phlex component; :js rides the reactive:js custom action.
|
|
44
|
+
BROADCAST_VERBS = {
|
|
45
|
+
replace: "replace", update: "update", append: "append",
|
|
46
|
+
prepend: "prepend", remove: "remove", js: "reactive:js"
|
|
47
|
+
}.freeze
|
|
48
|
+
BROADCAST_SELF_TARGETING = %i[replace remove].freeze
|
|
49
|
+
BROADCAST_CONTAINER = %i[update append prepend].freeze
|
|
50
|
+
BROADCAST_MORPHABLE = %i[replace update].freeze
|
|
51
|
+
|
|
52
|
+
# Issue #185: the 11 broadcast_*_to / _to_each methods are removed — each
|
|
53
|
+
# raises a guided error printing the broadcast_to rewrite for that verb.
|
|
54
|
+
# (A module constant, not defined inside class_methods, so it's a clean
|
|
55
|
+
# top-level definition the removal loop below reads.)
|
|
56
|
+
REMOVED_BROADCASTS = {
|
|
57
|
+
broadcast_replace_to: "broadcast_to(*keys, replace: model, morph: …)",
|
|
58
|
+
broadcast_update_to: "broadcast_to(*keys, update: model, morph: …)",
|
|
59
|
+
broadcast_append_to: "broadcast_to(*keys, append: model, target: …)",
|
|
60
|
+
broadcast_prepend_to: "broadcast_to(*keys, prepend: model, target: …)",
|
|
61
|
+
broadcast_remove_to: "broadcast_to(*keys, remove: model)",
|
|
62
|
+
broadcast_js_to: "broadcast_to(*keys, js: ops)",
|
|
63
|
+
broadcast_replace_to_each: "broadcast_to(each: keys, replace: model)",
|
|
64
|
+
broadcast_update_to_each: "broadcast_to(each: keys, update: model)",
|
|
65
|
+
broadcast_append_to_each: "broadcast_to(each: keys, append: model, target: …)",
|
|
66
|
+
broadcast_prepend_to_each: "broadcast_to(each: keys, prepend: model, target: …)",
|
|
67
|
+
broadcast_remove_to_each: "broadcast_to(each: keys, remove: model)"
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
40
70
|
# Every class that includes Streamable, so the engine can flush their
|
|
41
71
|
# memoized view contexts on a Rails code reload (dev) in one pass. A
|
|
42
72
|
# WeakMap used as a set (the class is the key) so a class reloaded by
|
|
@@ -73,6 +103,127 @@ module Phlex
|
|
|
73
103
|
classes
|
|
74
104
|
end
|
|
75
105
|
end
|
|
106
|
+
|
|
107
|
+
# The ONE broadcast implementation shared by the class-level
|
|
108
|
+
# Streamable.broadcast_to and the module-level Phlex::Reactive.broadcast_to
|
|
109
|
+
# (issue #185). `owner` is the Streamable class (for the instrument name +
|
|
110
|
+
# its build/render seam); `component` is the built payload (nil for :js);
|
|
111
|
+
# `keys` is a list of key-part arrays (one per fan-out key). Renders ONCE
|
|
112
|
+
# (instrumented → render.phlex_reactive) and loops the cheap channel call
|
|
113
|
+
# per key, all wrapped in the broadcast.phlex_reactive event + the pgbus
|
|
114
|
+
# thread-local path (so exclude:/visible_to: reach pgbus and Action Cable
|
|
115
|
+
# no-ops). Self-targeting verbs derive the target from the component's #id
|
|
116
|
+
# and REQUIRE a Streamable payload; container verbs need an explicit target.
|
|
117
|
+
def broadcast_component(owner, verb, payload, component, keys, morph:, target:, exclude:, visible_to:)
|
|
118
|
+
resolved_target = resolve_broadcast_target(verb, component, payload, target)
|
|
119
|
+
# broadcast_js_ops_json is a private class method on the owner Streamable
|
|
120
|
+
# class (reached via send); the instrumentation + pgbus thread-locals are
|
|
121
|
+
# owned HERE so the class-level and module-level (plain-component) forms
|
|
122
|
+
# share ONE path and neither is silently un-instrumented (issue #185).
|
|
123
|
+
component_name = component ? component.class.name : owner.name
|
|
124
|
+
Phlex::Reactive.instrument(
|
|
125
|
+
"broadcast", { component: component_name, stream_action: BROADCAST_VERBS[verb], streamables: keys.size }
|
|
126
|
+
) do
|
|
127
|
+
with_pgbus_broadcast_opts(exclude:, visible_to:) do
|
|
128
|
+
html = verb == :js ? nil : render_broadcast_html(component)
|
|
129
|
+
ops_json = verb == :js ? owner.send(:broadcast_js_ops_json, payload) : nil
|
|
130
|
+
keys.each { dispatch_broadcast(verb, it, resolved_target, html, ops_json, morph) }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Render a built component to HTML for a broadcast, ALWAYS instrumented
|
|
136
|
+
# (issue #185): a Streamable renders through its own render_component (which
|
|
137
|
+
# fires render.phlex_reactive); a plain component renders through the module
|
|
138
|
+
# render wrapped in the SAME render event, so neither path is silent.
|
|
139
|
+
def render_broadcast_html(component)
|
|
140
|
+
if component.is_a?(Phlex::Reactive::Streamable)
|
|
141
|
+
component.class.render_component(component)
|
|
142
|
+
else
|
|
143
|
+
Phlex::Reactive.instrument(
|
|
144
|
+
"render", { component: component.class.name }
|
|
145
|
+
) { Phlex::Reactive.render(component) }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Set the pgbus broadcast thread-locals for the block, gated on
|
|
150
|
+
# pgbus_streams? — the SAME capability-gated path the class-level
|
|
151
|
+
# instrument_broadcast uses (issue #185/#187). Duplicated at module level so
|
|
152
|
+
# the shared broadcast_component owns its transport threading. On Action
|
|
153
|
+
# Cable / old pgbus this is a pure `yield`.
|
|
154
|
+
def with_pgbus_broadcast_opts(exclude:, visible_to:)
|
|
155
|
+
return yield unless Phlex::Reactive.pgbus_streams?
|
|
156
|
+
|
|
157
|
+
prev_exclude = Thread.current[:pgbus_broadcast_exclude]
|
|
158
|
+
prev_visible = Thread.current[:pgbus_broadcast_visible_to]
|
|
159
|
+
Thread.current[:pgbus_broadcast_exclude] = exclude
|
|
160
|
+
Thread.current[:pgbus_broadcast_visible_to] = visible_to
|
|
161
|
+
yield
|
|
162
|
+
ensure
|
|
163
|
+
if Phlex::Reactive.pgbus_streams?
|
|
164
|
+
Thread.current[:pgbus_broadcast_exclude] = prev_exclude
|
|
165
|
+
Thread.current[:pgbus_broadcast_visible_to] = prev_visible
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Resolve the DOM target for a broadcast (issue #185): a self-targeting
|
|
170
|
+
# verb (replace/remove) uses the component's #id and REQUIRES a Streamable
|
|
171
|
+
# payload (the #id contract) — a plain component gets a guided error steering
|
|
172
|
+
# to update:; a container verb (update/append/prepend) uses the caller's
|
|
173
|
+
# explicit target: (update self-targets a Streamable when no target: given).
|
|
174
|
+
# :js scopes ops by the caller target (nil → document-scoped).
|
|
175
|
+
def resolve_broadcast_target(verb, component, payload, target)
|
|
176
|
+
return target if verb == :js
|
|
177
|
+
|
|
178
|
+
streamable = component.is_a?(Phlex::Reactive::Streamable)
|
|
179
|
+
if BROADCAST_SELF_TARGETING.include?(verb) || (verb == :update && target.nil?)
|
|
180
|
+
unless streamable
|
|
181
|
+
raise ArgumentError,
|
|
182
|
+
"broadcast_to #{verb}: needs a Streamable payload (its #id is the target). A plain " \
|
|
183
|
+
"component #{payload.class} has no #id — use update: with an explicit target:."
|
|
184
|
+
end
|
|
185
|
+
return component.id
|
|
186
|
+
end
|
|
187
|
+
raise ArgumentError, "broadcast_to #{verb}: needs a target: (the container element's id)." unless target
|
|
188
|
+
|
|
189
|
+
target
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Route ONE key to its Turbo::StreamsChannel call for the verb (issue #185).
|
|
193
|
+
# exclude:/visible_to: are NOT passed here — they ride the pgbus thread-locals
|
|
194
|
+
# set by with_pgbus_broadcast_opts (turbo-rails would swallow them as kwargs).
|
|
195
|
+
def dispatch_broadcast(verb, key, target, html, ops_json, morph)
|
|
196
|
+
parts = Array(key)
|
|
197
|
+
case verb
|
|
198
|
+
when :replace then ::Turbo::StreamsChannel.broadcast_replace_to(*parts, target:, html:, **morph_wire(morph))
|
|
199
|
+
when :update then ::Turbo::StreamsChannel.broadcast_update_to(*parts, target:, html:, **morph_wire(morph))
|
|
200
|
+
when :append then ::Turbo::StreamsChannel.broadcast_append_to(*parts, target:, html:)
|
|
201
|
+
when :prepend then ::Turbo::StreamsChannel.broadcast_prepend_to(*parts, target:, html:)
|
|
202
|
+
when :remove then ::Turbo::StreamsChannel.broadcast_remove_to(*parts, target:)
|
|
203
|
+
when :js
|
|
204
|
+
::Turbo::StreamsChannel.broadcast_action_to(
|
|
205
|
+
*parts, action: "reactive:js", target:, attributes: { data: { reactive_ops: ops_json } }, render: false
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The ONE morph wire compiler (issue #185): the broadcast path emits the
|
|
211
|
+
# method="morph" attribute via `attributes:` (it has no `method:` kwarg).
|
|
212
|
+
# Replaces the morph_method/morph_attributes twins.
|
|
213
|
+
def morph_wire(morph)
|
|
214
|
+
morph ? { attributes: { method: "morph" } } : {}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Split the single verb kwarg out of the module-level broadcast_to's **verb
|
|
218
|
+
# (issue #185) — public counterpart of the class-level extract_broadcast_verb.
|
|
219
|
+
def extract_module_broadcast_verb(verb)
|
|
220
|
+
unless verb.size == 1 && BROADCAST_VERBS.key?(verb.keys.first)
|
|
221
|
+
raise ArgumentError,
|
|
222
|
+
"broadcast_to needs exactly ONE verb kwarg (#{BROADCAST_VERBS.keys.join("/")}), got #{verb.keys.inspect}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
verb.first
|
|
226
|
+
end
|
|
76
227
|
end
|
|
77
228
|
|
|
78
229
|
included do
|
|
@@ -245,169 +396,72 @@ module Phlex
|
|
|
245
396
|
# COUNT + component name, never the model/state. The event fires on BOTH
|
|
246
397
|
# transports (Action Cable AND pgbus): it wraps this class-method body,
|
|
247
398
|
# which is the same on either, so pgbus optionality is preserved.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
component = build(model, options)
|
|
251
|
-
::Turbo::StreamsChannel.broadcast_replace_to(
|
|
252
|
-
*streamables, target: component.id, html: render_component(component),
|
|
253
|
-
**morph_attributes(morph)
|
|
254
|
-
)
|
|
255
|
-
end
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
# `morph: true` makes the live cross-tab update morph in place (issue
|
|
259
|
-
# #113), so a peer tab editing this component keeps its focus/caret
|
|
260
|
-
# instead of an inner-HTML clobber. Like broadcast_replace_to's morph
|
|
261
|
-
# flag, it rides through `attributes:` (the broadcast path has no
|
|
262
|
-
# `method:` kwarg) via morph_attributes.
|
|
263
|
-
def broadcast_update_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
|
|
264
|
-
instrument_broadcast("update", streamables, exclude:, visible_to:) do
|
|
265
|
-
component = build(model, options)
|
|
266
|
-
::Turbo::StreamsChannel.broadcast_update_to(
|
|
267
|
-
*streamables, target: component.id, html: render_component(component),
|
|
268
|
-
**morph_attributes(morph)
|
|
269
|
-
)
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
def broadcast_append_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
|
|
274
|
-
instrument_broadcast("append", streamables, exclude:, visible_to:) do
|
|
275
|
-
component = build(model, options)
|
|
276
|
-
::Turbo::StreamsChannel.broadcast_append_to(
|
|
277
|
-
*streamables, target:, html: render_component(component)
|
|
278
|
-
)
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
def broadcast_prepend_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
|
|
283
|
-
instrument_broadcast("prepend", streamables, exclude:, visible_to:) do
|
|
284
|
-
component = build(model, options)
|
|
285
|
-
::Turbo::StreamsChannel.broadcast_prepend_to(
|
|
286
|
-
*streamables, target:, html: render_component(component)
|
|
287
|
-
)
|
|
288
|
-
end
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def broadcast_remove_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
|
|
292
|
-
instrument_broadcast("remove", streamables, exclude:, visible_to:) do
|
|
293
|
-
component = build(model, options)
|
|
294
|
-
::Turbo::StreamsChannel.broadcast_remove_to(
|
|
295
|
-
*streamables, target: component.id
|
|
296
|
-
)
|
|
297
|
-
end
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
# Push server-side client DOM ops to EVERY subscriber of a stream (issue
|
|
301
|
-
# #97) — the broadcast sibling of Response#js. Rides a `reactive:js`
|
|
302
|
-
# custom stream action over Turbo::StreamsChannel, so it works on Action
|
|
303
|
-
# Cable AND pgbus (exclude:/visible_to: thread to pgbus via
|
|
304
|
-
# instrument_broadcast exactly like every other broadcast):
|
|
399
|
+
# ONE broadcast method (issue #185) — the verb is a KWARG whose value is
|
|
400
|
+
# the payload, replacing the 11 broadcast_*_to / _to_each methods:
|
|
305
401
|
#
|
|
306
|
-
#
|
|
307
|
-
#
|
|
402
|
+
# Item.broadcast_to(@list, :todos, replace: @todo, morph: true) # self-target
|
|
403
|
+
# Item.broadcast_to(@list, :todos, append: todo, target: dom_id(@list, :todos),
|
|
404
|
+
# exclude: reactive_connection_id)
|
|
405
|
+
# Badge.broadcast_to(user, :alerts, js: js.add_class("#bell", "has-unread"))
|
|
406
|
+
# Counter.broadcast_to(each: accounts.map { [it, :counters] }, replace: counter)
|
|
407
|
+
# ChatComposer.broadcast_to("chat", room, update: { room:, author: }) # Hash = init kwargs
|
|
308
408
|
#
|
|
309
|
-
#
|
|
310
|
-
#
|
|
311
|
-
#
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
target: target,
|
|
326
|
-
attributes: { data: { reactive_ops: json } },
|
|
327
|
-
render: false
|
|
328
|
-
)
|
|
329
|
-
end
|
|
409
|
+
# Exactly ONE verb kwarg — replace:/update:/append:/prepend:/remove:/js:.
|
|
410
|
+
# The payload is a record (built via model_param_name), an init-kwargs Hash
|
|
411
|
+
# (verbatim — no **options collision), or a built Phlex component. `each:`
|
|
412
|
+
# (an enumerable of stream keys) fans ONE render out to K keys (1 build + 1
|
|
413
|
+
# render + 1 signing + K cheap channel calls); otherwise *streamables is the
|
|
414
|
+
# single key. `morph:` applies to replace/update. `exclude:`/`visible_to:`
|
|
415
|
+
# thread to pgbus via the capability-gated instrument_broadcast path; on
|
|
416
|
+
# Action Cable they no-op. The class-level and module-level (Phlex::Reactive.
|
|
417
|
+
# broadcast_to) forms share broadcast_component (below).
|
|
418
|
+
def broadcast_to(*streamables, morph: false, target: nil, exclude: nil, visible_to: nil, each: nil, **verb)
|
|
419
|
+
name, payload = extract_broadcast_verb(verb)
|
|
420
|
+
component = name == :js ? nil : coerce_broadcast_payload(payload)
|
|
421
|
+
keys = each ? each.map { Array(it) } : [streamables]
|
|
422
|
+
Phlex::Reactive::Streamable.broadcast_component(
|
|
423
|
+
self, name, payload, component, keys, morph:, target:, exclude:, visible_to:
|
|
424
|
+
)
|
|
330
425
|
end
|
|
331
426
|
|
|
332
|
-
#
|
|
333
|
-
#
|
|
334
|
-
#
|
|
335
|
-
#
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
# accounts.map { [it, :counters] }, model: counter, exclude: reactive_connection_id)
|
|
342
|
-
#
|
|
343
|
-
# `stream_keys` is an enumerable of keys; each key is passed to the
|
|
344
|
-
# transport as its raw parts (a [record, :symbol] pair, or a bare string
|
|
345
|
-
# — `Array(key)` handles both). Transport opts (exclude:/visible_to:) and
|
|
346
|
-
# morph: forward PER key exactly as the single-key verbs do, so
|
|
347
|
-
# pgbus-present suppresses the actor's echo on every stream and
|
|
348
|
-
# pgbus-absent is unchanged (the no-opts call passes no unknown keyword).
|
|
349
|
-
#
|
|
350
|
-
# Irreducible exception: per-VIEWER content (visible_to: rendering
|
|
351
|
-
# DIFFERENT HTML per viewer) still renders per call — that's a
|
|
352
|
-
# render-per-viewer by definition and can't be shared. This fan-out is
|
|
353
|
-
# for the same payload to many keys.
|
|
354
|
-
def broadcast_replace_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
|
|
355
|
-
instrument_broadcast("replace", stream_keys, exclude:, visible_to:) do
|
|
356
|
-
component = build(model, options)
|
|
357
|
-
html = render_component(component)
|
|
358
|
-
stream_keys.each do
|
|
359
|
-
::Turbo::StreamsChannel.broadcast_replace_to(
|
|
360
|
-
*Array(it), target: component.id, html:, **morph_attributes(morph)
|
|
361
|
-
)
|
|
362
|
-
end
|
|
427
|
+
# Define the guided-error stub for each removed broadcast method (issue
|
|
428
|
+
# #185). `verb` is referenced in define_method AND the message, so the outer
|
|
429
|
+
# block param must be named — `it` is illegal here.
|
|
430
|
+
# rubocop:disable Style/ItBlockParameter
|
|
431
|
+
Phlex::Reactive::Streamable::REMOVED_BROADCASTS.each_key do |verb|
|
|
432
|
+
define_method(verb) do |*, **|
|
|
433
|
+
raise NoMethodError,
|
|
434
|
+
"#{name}.#{verb} was removed in issue #185 — " \
|
|
435
|
+
"use #{name}.#{Phlex::Reactive::Streamable::REMOVED_BROADCASTS[verb]}"
|
|
363
436
|
end
|
|
364
437
|
end
|
|
438
|
+
# rubocop:enable Style/ItBlockParameter
|
|
365
439
|
|
|
366
|
-
|
|
367
|
-
instrument_broadcast("update", stream_keys, exclude:, visible_to:) do
|
|
368
|
-
component = build(model, options)
|
|
369
|
-
html = render_component(component)
|
|
370
|
-
stream_keys.each do
|
|
371
|
-
::Turbo::StreamsChannel.broadcast_update_to(
|
|
372
|
-
*Array(it), target: component.id, html:, **morph_attributes(morph)
|
|
373
|
-
)
|
|
374
|
-
end
|
|
375
|
-
end
|
|
376
|
-
end
|
|
440
|
+
private
|
|
377
441
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
end
|
|
442
|
+
# Split the single verb kwarg (replace:/update:/…) out of **verb, raising
|
|
443
|
+
# for zero or more than one — exactly one verb per broadcast_to call.
|
|
444
|
+
def extract_broadcast_verb(verb)
|
|
445
|
+
unless verb.size == 1 && BROADCAST_VERBS.key?(verb.keys.first)
|
|
446
|
+
raise ArgumentError,
|
|
447
|
+
"broadcast_to needs exactly ONE verb kwarg (#{BROADCAST_VERBS.keys.join("/")}), got #{verb.keys.inspect}"
|
|
385
448
|
end
|
|
386
|
-
end
|
|
387
449
|
|
|
388
|
-
|
|
389
|
-
instrument_broadcast("prepend", stream_keys, exclude:, visible_to:) do
|
|
390
|
-
component = build(model, options)
|
|
391
|
-
html = render_component(component)
|
|
392
|
-
stream_keys.each do
|
|
393
|
-
::Turbo::StreamsChannel.broadcast_prepend_to(*Array(it), target:, html:)
|
|
394
|
-
end
|
|
395
|
-
end
|
|
450
|
+
verb.first
|
|
396
451
|
end
|
|
397
452
|
|
|
398
|
-
#
|
|
399
|
-
#
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
453
|
+
# Coerce a verb payload into a built component: a Phlex component passes
|
|
454
|
+
# through (issue #185 — built payloads); a Hash is init kwargs verbatim (no
|
|
455
|
+
# **options collision); anything else is the record built via
|
|
456
|
+
# model_param_name. nil builds argument-free (a state-backed default).
|
|
457
|
+
def coerce_broadcast_payload(payload)
|
|
458
|
+
case payload
|
|
459
|
+
when ::Phlex::SGML then payload
|
|
460
|
+
when Hash then new(**payload)
|
|
461
|
+
else build(payload, {})
|
|
406
462
|
end
|
|
407
463
|
end
|
|
408
464
|
|
|
409
|
-
private
|
|
410
|
-
|
|
411
465
|
# Wrap a broadcast_*_to body in a broadcast.phlex_reactive event (issue
|
|
412
466
|
# #107) AND thread the pgbus transport opts (issue #187). Payload: the
|
|
413
467
|
# component NAME, the Turbo stream action, and the streamables COUNT —
|
|
@@ -424,32 +478,10 @@ module Phlex
|
|
|
424
478
|
# thread-locals around the broadcast (capability-gated on pgbus_streams?)
|
|
425
479
|
# and clear them in ensure. On Action Cable there is no such thread-local
|
|
426
480
|
# reader, so this is a no-op there — pgbus optionality preserved.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
452
|
-
end
|
|
481
|
+
# (issue #185: instrument_broadcast + the class-level with_pgbus_broadcast_opts
|
|
482
|
+
# were folded into the shared Streamable.broadcast_component — ONE
|
|
483
|
+
# instrumentation + pgbus-threading path for the class-level and module-level
|
|
484
|
+
# broadcast_to, so the transport logic has exactly one spelling.)
|
|
453
485
|
|
|
454
486
|
# Validate + serialize broadcast ops: reject focus-class ops (they steal
|
|
455
487
|
# focus in every tab) and an empty chain (a dead broadcast), then return
|
|
@@ -477,12 +509,9 @@ module Phlex
|
|
|
477
509
|
morph ? { method: :morph } : {}
|
|
478
510
|
end
|
|
479
511
|
|
|
480
|
-
# The
|
|
481
|
-
#
|
|
482
|
-
#
|
|
483
|
-
def morph_attributes(morph)
|
|
484
|
-
morph ? { attributes: { method: "morph" } } : {}
|
|
485
|
-
end
|
|
512
|
+
# (The broadcast path's morph wire moved to Streamable.morph_wire, issue
|
|
513
|
+
# #185 — the single compiler both the class-level and module-level
|
|
514
|
+
# broadcast_to share.)
|
|
486
515
|
|
|
487
516
|
def renderer
|
|
488
517
|
Phlex::Reactive.renderer
|
|
@@ -550,24 +579,25 @@ module Phlex
|
|
|
550
579
|
# Phlex::Reactive::Stream (issue #114) so the endpoint reads the action /
|
|
551
580
|
# target / token-ness structurally instead of regexing the markup; the wire
|
|
552
581
|
# bytes are byte-identical.
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
582
|
+
# `morph: true` emits `<turbo-stream action="replace" method="morph">`
|
|
583
|
+
# (issue #28): Turbo 8's bundled Idiomorph morphs the subtree in place —
|
|
584
|
+
# preserving the focused <input> + caret across the re-render — while still
|
|
585
|
+
# carrying the root's fresh data-reactive-token-value (so the signed token
|
|
586
|
+
# refreshes). Default (morph: false) is the plain outerHTML replace,
|
|
587
|
+
# byte-identical to before. Used by reply.replace / reply.morph. The morph:
|
|
588
|
+
# kwarg replaces the deleted to_stream_morph (issue #185).
|
|
589
|
+
def to_stream_replace(morph: false)
|
|
590
|
+
builder = self.class.turbo_stream_builder
|
|
591
|
+
html = self.class.render_component(self)
|
|
592
|
+
rendered = morph ? builder.replace(id, html:, method: :morph) : builder.replace(id, html:)
|
|
593
|
+
Phlex::Reactive::Stream.wrap(rendered, action: "replace", target: id, renders_root: true)
|
|
558
594
|
end
|
|
559
595
|
|
|
560
|
-
#
|
|
561
|
-
#
|
|
562
|
-
# Idiomorph morphs the subtree in place — preserving the focused <input> +
|
|
563
|
-
# caret across the re-render — while still carrying the root's fresh
|
|
564
|
-
# data-reactive-token-value (so the signed token refreshes). Used by
|
|
565
|
-
# Response.morph / Response.replace(self, morph: true).
|
|
596
|
+
# Issue #185: to_stream_morph is removed — the morph flag is a kwarg now.
|
|
597
|
+
# Guided error → to_stream_replace(morph: true).
|
|
566
598
|
def to_stream_morph
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
action: "replace", target: id, renders_root: true
|
|
570
|
-
)
|
|
599
|
+
raise NoMethodError,
|
|
600
|
+
"to_stream_morph was removed in issue #185 — use to_stream_replace(morph: true)"
|
|
571
601
|
end
|
|
572
602
|
|
|
573
603
|
# `morph: true` emits `<turbo-stream action="update" method="morph">`
|
|
@@ -575,7 +605,7 @@ module Phlex
|
|
|
575
605
|
# focused <input> + caret across a per-field update. Default (morph:
|
|
576
606
|
# false) is the unchanged plain update. Passing `method: :morph` inline
|
|
577
607
|
# (as #to_stream_morph does) keeps the plain call's wire byte-identical.
|
|
578
|
-
# Used by
|
|
608
|
+
# Used by reply.update.
|
|
579
609
|
def to_stream_update(morph: false)
|
|
580
610
|
builder = self.class.turbo_stream_builder
|
|
581
611
|
html = self.class.render_component(self)
|
|
@@ -591,7 +621,7 @@ module Phlex
|
|
|
591
621
|
# attribute (#extractToken) and an inert client action writes it onto the
|
|
592
622
|
# root (a pure attribute set, so a focused <input> + caret survive). Unlike
|
|
593
623
|
# to_stream_replace, it does NOT re-render the children, so a live input
|
|
594
|
-
# the user is typing into is never torn down. Used by
|
|
624
|
+
# the user is typing into is never torn down. Used by reply.streams.
|
|
595
625
|
#
|
|
596
626
|
# The component carries its token via Component#reactive_token; a Streamable
|
|
597
627
|
# that isn't a Component (no token) simply has nothing to refresh — guarded
|
|
@@ -616,7 +646,7 @@ module Phlex
|
|
|
616
646
|
|
|
617
647
|
# Render THIS instance as a remove stream. The component already knows its
|
|
618
648
|
# own #id, so no record/class reconstruction is needed (works for record-
|
|
619
|
-
# and state-backed components alike). Used by
|
|
649
|
+
# and state-backed components alike). Used by reply.remove.
|
|
620
650
|
def to_stream_remove
|
|
621
651
|
Phlex::Reactive::Stream.wrap(
|
|
622
652
|
self.class.turbo_stream_builder.remove(id),
|
|
@@ -88,7 +88,7 @@ module Phlex
|
|
|
88
88
|
# maps it to 403; a unit test asserts the real exception).
|
|
89
89
|
def run_reactive(component, action, **params)
|
|
90
90
|
klass = component.class
|
|
91
|
-
action_def = klass.
|
|
91
|
+
action_def = klass.reactive_actions[action.to_sym] || raise_undeclared(klass, action)
|
|
92
92
|
|
|
93
93
|
rebuilt = rebuild_from_identity(component, klass)
|
|
94
94
|
coerced = action_def.schema.coerce(stringify_params(params))
|