phlex-reactive 0.9.5 → 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 +315 -0
- data/README.md +176 -122
- data/app/controllers/phlex/reactive/actions_controller.rb +41 -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 +475 -218
- 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/client_bindings.rb +57 -0
- data/lib/phlex/reactive/component/dsl.rb +158 -33
- data/lib/phlex/reactive/component/helpers.rb +478 -381
- data/lib/phlex/reactive/component/registry.rb +8 -1
- data/lib/phlex/reactive/component.rb +26 -12
- 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/show_conditions.rb +249 -0
- data/lib/phlex/reactive/streamable.rb +244 -202
- data/lib/phlex/reactive/test_helpers.rb +10 -2
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +137 -12
- metadata +6 -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,190 +396,92 @@ 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), **broadcast_transport_opts(exclude:, visible_to:)
|
|
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) 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), **broadcast_transport_opts(exclude:, visible_to:)
|
|
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) do
|
|
275
|
-
component = build(model, options)
|
|
276
|
-
::Turbo::StreamsChannel.broadcast_append_to(
|
|
277
|
-
*streamables, target:, html: render_component(component),
|
|
278
|
-
**broadcast_transport_opts(exclude:, visible_to:)
|
|
279
|
-
)
|
|
280
|
-
end
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
def broadcast_prepend_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
|
|
284
|
-
instrument_broadcast("prepend", streamables) do
|
|
285
|
-
component = build(model, options)
|
|
286
|
-
::Turbo::StreamsChannel.broadcast_prepend_to(
|
|
287
|
-
*streamables, target:, html: render_component(component),
|
|
288
|
-
**broadcast_transport_opts(exclude:, visible_to:)
|
|
289
|
-
)
|
|
290
|
-
end
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def broadcast_remove_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
|
|
294
|
-
instrument_broadcast("remove", streamables) do
|
|
295
|
-
component = build(model, options)
|
|
296
|
-
::Turbo::StreamsChannel.broadcast_remove_to(
|
|
297
|
-
*streamables, target: component.id,
|
|
298
|
-
**broadcast_transport_opts(exclude:, visible_to:)
|
|
299
|
-
)
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
# Push server-side client DOM ops to EVERY subscriber of a stream (issue
|
|
304
|
-
# #97) — the broadcast sibling of Response#js. Rides a `reactive:js`
|
|
305
|
-
# 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):
|
|
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:
|
|
308
401
|
#
|
|
309
|
-
#
|
|
310
|
-
#
|
|
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
|
|
311
408
|
#
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
#
|
|
320
|
-
#
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
target: target,
|
|
329
|
-
attributes: { data: { reactive_ops: json } },
|
|
330
|
-
render: false,
|
|
331
|
-
**broadcast_transport_opts(exclude:, visible_to:)
|
|
332
|
-
)
|
|
333
|
-
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
|
+
)
|
|
334
425
|
end
|
|
335
426
|
|
|
336
|
-
#
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
# accounts.map { [it, :counters] }, model: counter, exclude: reactive_connection_id)
|
|
346
|
-
#
|
|
347
|
-
# `stream_keys` is an enumerable of keys; each key is passed to the
|
|
348
|
-
# transport as its raw parts (a [record, :symbol] pair, or a bare string
|
|
349
|
-
# — `Array(key)` handles both). Transport opts (exclude:/visible_to:) and
|
|
350
|
-
# morph: forward PER key exactly as the single-key verbs do, so
|
|
351
|
-
# pgbus-present suppresses the actor's echo on every stream and
|
|
352
|
-
# pgbus-absent is unchanged (the no-opts call passes no unknown keyword).
|
|
353
|
-
#
|
|
354
|
-
# Irreducible exception: per-VIEWER content (visible_to: rendering
|
|
355
|
-
# DIFFERENT HTML per viewer) still renders per call — that's a
|
|
356
|
-
# render-per-viewer by definition and can't be shared. This fan-out is
|
|
357
|
-
# for the same payload to many keys.
|
|
358
|
-
def broadcast_replace_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
|
|
359
|
-
instrument_broadcast("replace", stream_keys) do
|
|
360
|
-
component = build(model, options)
|
|
361
|
-
html = render_component(component)
|
|
362
|
-
transport = broadcast_transport_opts(exclude:, visible_to:)
|
|
363
|
-
stream_keys.each do
|
|
364
|
-
::Turbo::StreamsChannel.broadcast_replace_to(
|
|
365
|
-
*Array(it), target: component.id, html:, **morph_attributes(morph), **transport
|
|
366
|
-
)
|
|
367
|
-
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]}"
|
|
368
436
|
end
|
|
369
437
|
end
|
|
438
|
+
# rubocop:enable Style/ItBlockParameter
|
|
370
439
|
|
|
371
|
-
|
|
372
|
-
instrument_broadcast("update", stream_keys) do
|
|
373
|
-
component = build(model, options)
|
|
374
|
-
html = render_component(component)
|
|
375
|
-
transport = broadcast_transport_opts(exclude:, visible_to:)
|
|
376
|
-
stream_keys.each do
|
|
377
|
-
::Turbo::StreamsChannel.broadcast_update_to(
|
|
378
|
-
*Array(it), target: component.id, html:, **morph_attributes(morph), **transport
|
|
379
|
-
)
|
|
380
|
-
end
|
|
381
|
-
end
|
|
382
|
-
end
|
|
440
|
+
private
|
|
383
441
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
::Turbo::StreamsChannel.broadcast_append_to(*Array(it), target:, html:, **transport)
|
|
391
|
-
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}"
|
|
392
448
|
end
|
|
393
|
-
end
|
|
394
449
|
|
|
395
|
-
|
|
396
|
-
instrument_broadcast("prepend", stream_keys) do
|
|
397
|
-
component = build(model, options)
|
|
398
|
-
html = render_component(component)
|
|
399
|
-
transport = broadcast_transport_opts(exclude:, visible_to:)
|
|
400
|
-
stream_keys.each do
|
|
401
|
-
::Turbo::StreamsChannel.broadcast_prepend_to(*Array(it), target:, html:, **transport)
|
|
402
|
-
end
|
|
403
|
-
end
|
|
450
|
+
verb.first
|
|
404
451
|
end
|
|
405
452
|
|
|
406
|
-
#
|
|
407
|
-
#
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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, {})
|
|
415
462
|
end
|
|
416
463
|
end
|
|
417
464
|
|
|
418
|
-
private
|
|
419
|
-
|
|
420
465
|
# Wrap a broadcast_*_to body in a broadcast.phlex_reactive event (issue
|
|
421
|
-
# #107)
|
|
422
|
-
#
|
|
423
|
-
#
|
|
424
|
-
#
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
466
|
+
# #107) AND thread the pgbus transport opts (issue #187). Payload: the
|
|
467
|
+
# component NAME, the Turbo stream action, and the streamables COUNT —
|
|
468
|
+
# never the model/state. Fires on both transports because it wraps the
|
|
469
|
+
# shared class-method body. Returns the block's value (the broadcast
|
|
470
|
+
# result) so callers are unaffected.
|
|
471
|
+
#
|
|
472
|
+
# exclude:/visible_to: (issue #187): pgbus reads these from thread-locals
|
|
473
|
+
# (Thread.current[:pgbus_broadcast_exclude]/_visible_to), which its
|
|
474
|
+
# Turbo::StreamsChannel#broadcast_stream_to patch consults — NOT from the
|
|
475
|
+
# broadcast_*_to kwargs (turbo-rails swallows unknown kwargs into its
|
|
476
|
+
# render locals, so passing exclude: as a kwarg to StreamsChannel silently
|
|
477
|
+
# drops it: the actor-echo suppression never fired on pgbus). We set the
|
|
478
|
+
# thread-locals around the broadcast (capability-gated on pgbus_streams?)
|
|
479
|
+
# and clear them in ensure. On Action Cable there is no such thread-local
|
|
480
|
+
# reader, so this is a no-op there — pgbus optionality preserved.
|
|
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.)
|
|
432
485
|
|
|
433
486
|
# Validate + serialize broadcast ops: reject focus-class ops (they steal
|
|
434
487
|
# focus in every tab) and an empty chain (a dead broadcast), then return
|
|
@@ -456,21 +509,9 @@ module Phlex
|
|
|
456
509
|
morph ? { method: :morph } : {}
|
|
457
510
|
end
|
|
458
511
|
|
|
459
|
-
# The
|
|
460
|
-
#
|
|
461
|
-
#
|
|
462
|
-
def morph_attributes(morph)
|
|
463
|
-
morph ? { attributes: { method: "morph" } } : {}
|
|
464
|
-
end
|
|
465
|
-
|
|
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
|
|
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.)
|
|
474
515
|
|
|
475
516
|
def renderer
|
|
476
517
|
Phlex::Reactive.renderer
|
|
@@ -538,24 +579,25 @@ module Phlex
|
|
|
538
579
|
# Phlex::Reactive::Stream (issue #114) so the endpoint reads the action /
|
|
539
580
|
# target / token-ness structurally instead of regexing the markup; the wire
|
|
540
581
|
# bytes are byte-identical.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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)
|
|
546
594
|
end
|
|
547
595
|
|
|
548
|
-
#
|
|
549
|
-
#
|
|
550
|
-
# Idiomorph morphs the subtree in place — preserving the focused <input> +
|
|
551
|
-
# caret across the re-render — while still carrying the root's fresh
|
|
552
|
-
# data-reactive-token-value (so the signed token refreshes). Used by
|
|
553
|
-
# 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).
|
|
554
598
|
def to_stream_morph
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
action: "replace", target: id, renders_root: true
|
|
558
|
-
)
|
|
599
|
+
raise NoMethodError,
|
|
600
|
+
"to_stream_morph was removed in issue #185 — use to_stream_replace(morph: true)"
|
|
559
601
|
end
|
|
560
602
|
|
|
561
603
|
# `morph: true` emits `<turbo-stream action="update" method="morph">`
|
|
@@ -563,7 +605,7 @@ module Phlex
|
|
|
563
605
|
# focused <input> + caret across a per-field update. Default (morph:
|
|
564
606
|
# false) is the unchanged plain update. Passing `method: :morph` inline
|
|
565
607
|
# (as #to_stream_morph does) keeps the plain call's wire byte-identical.
|
|
566
|
-
# Used by
|
|
608
|
+
# Used by reply.update.
|
|
567
609
|
def to_stream_update(morph: false)
|
|
568
610
|
builder = self.class.turbo_stream_builder
|
|
569
611
|
html = self.class.render_component(self)
|
|
@@ -579,7 +621,7 @@ module Phlex
|
|
|
579
621
|
# attribute (#extractToken) and an inert client action writes it onto the
|
|
580
622
|
# root (a pure attribute set, so a focused <input> + caret survive). Unlike
|
|
581
623
|
# to_stream_replace, it does NOT re-render the children, so a live input
|
|
582
|
-
# 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.
|
|
583
625
|
#
|
|
584
626
|
# The component carries its token via Component#reactive_token; a Streamable
|
|
585
627
|
# that isn't a Component (no token) simply has nothing to refresh — guarded
|
|
@@ -604,7 +646,7 @@ module Phlex
|
|
|
604
646
|
|
|
605
647
|
# Render THIS instance as a remove stream. The component already knows its
|
|
606
648
|
# own #id, so no record/class reconstruction is needed (works for record-
|
|
607
|
-
# and state-backed components alike). Used by
|
|
649
|
+
# and state-backed components alike). Used by reply.remove.
|
|
608
650
|
def to_stream_remove
|
|
609
651
|
Phlex::Reactive::Stream.wrap(
|
|
610
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))
|
|
@@ -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
|
-
|
|
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
|