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.
@@ -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): broadcast_js_to rejects a
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
- def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
249
- instrument_broadcast("replace", streamables) do
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
- # Notifications::Badge.broadcast_js_to(user, :alerts,
310
- # js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
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
- # `ops` is a Phlex::Reactive::JS chain (or a raw [[op, args], ...] array);
313
- # `target` (an element id) scopes op resolution on the client (nil →
314
- # document-scoped). The ops JSON is HTML-escaped through the TagBuilder's
315
- # attributes: path (data-reactive-ops), so a value can't break out of the
316
- # attribute.
317
- #
318
- # The builder REJECTS focus-class ops (focus/focus_first) outright:
319
- # broadcasting a focus steals focus in every subscriber's tab. Focus is an
320
- # actor-reply concern only (Response#js), so it raises ArgumentError here
321
- # rather than silently shipping a hostile-feeling broadcast.
322
- def broadcast_js_to(*streamables, ops, exclude: nil, visible_to: nil, target: nil)
323
- instrument_broadcast("reactive:js", streamables) do
324
- json = broadcast_js_ops_json(ops)
325
- ::Turbo::StreamsChannel.broadcast_action_to(
326
- *streamables,
327
- action: "reactive:js",
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
- # --- Multi-key fan-out (issue #119) ---------------------------------
337
- # Broadcast ONE component to K DIFFERENT stream keys a per-tenant loop
338
- # ("the list page stream AND the dashboard stream"). The classic
339
- # broadcast_*_to concatenates *streamables into ONE key, so fanning out
340
- # to K keys the hand way is K× build + K× render + K× identity HMAC for
341
- # BYTE-IDENTICAL HTML. These render the component ONCE and loop only the
342
- # cheap channel call:
343
- #
344
- # Counter.broadcast_replace_to_each(
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
- def broadcast_update_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
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
- def broadcast_append_to_each(stream_keys, target:, model: nil, exclude: nil, visible_to: nil, **options)
385
- instrument_broadcast("append", stream_keys) do
386
- component = build(model, options)
387
- html = render_component(component)
388
- transport = broadcast_transport_opts(exclude:, visible_to:)
389
- stream_keys.each do
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
- def broadcast_prepend_to_each(stream_keys, target:, model: nil, exclude: nil, visible_to: nil, **options)
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
- # remove has no body nothing to render. It still builds ONCE to read
407
- # the component's #id (the target), then loops the cheap channel call.
408
- def broadcast_remove_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, **options)
409
- instrument_broadcast("remove", stream_keys) do
410
- component = build(model, options)
411
- transport = broadcast_transport_opts(exclude:, visible_to:)
412
- stream_keys.each do
413
- ::Turbo::StreamsChannel.broadcast_remove_to(*Array(it), target: component.id, **transport)
414
- end
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). Payload: the component NAME, the Turbo stream action, and the
422
- # streamables COUNT never the model/state. Fires on both transports
423
- # because it wraps the shared class-method body. Returns the block's value
424
- # (the broadcast result) so callers are unaffected.
425
- def instrument_broadcast(stream_action, streamables, &)
426
- Phlex::Reactive.instrument(
427
- "broadcast",
428
- { component: name, stream_action: stream_action, streamables: streamables.size },
429
- &
430
- )
431
- end
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 BROADCAST path renders extra <turbo-stream> attributes through
460
- # `attributes:` (it has no `method:` kwarg that would fall into the
461
- # render args and be dropped). Same wire result: method="morph".
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
- def to_stream_replace
542
- Phlex::Reactive::Stream.wrap(
543
- self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self)),
544
- action: "replace", target: id, renders_root: true
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
- # Render THIS instance as a MORPHING replace (issue #28):
549
- # `<turbo-stream action="replace" method="morph">`. Turbo 8's bundled
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
- Phlex::Reactive::Stream.wrap(
556
- self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self), method: :morph),
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 Response.update.
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 Response.streams.
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 Response.remove.
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.reactive_action(action) || raise_undeclared(klass, action)
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
- streams = [@component.to_stream_replace, *streams]
279
+ # Mirror the endpoint (issue #180): a self-render reply gets the full
280
+ # replace; a companion-only reply.with (no subject_component) gets a
281
+ # token-only refresh instead.
282
+ streams =
283
+ if @response.subject_component
284
+ [@component.to_stream_replace, *streams]
285
+ else
286
+ [*streams, @component.to_stream_token]
287
+ end
280
288
  end
281
289
  streams
282
290
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.9.5"
5
+ VERSION = "0.11.0"
6
6
  end
7
7
  end