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.
@@ -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,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
- def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
249
- instrument_broadcast("replace", streamables, exclude:, visible_to:) 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)
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
- # Notifications::Badge.broadcast_js_to(user, :alerts,
307
- # 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
308
408
  #
309
- # `ops` is a Phlex::Reactive::JS chain (or a raw [[op, args], ...] array);
310
- # `target` (an element id) scopes op resolution on the client (nil →
311
- # document-scoped). The ops JSON is HTML-escaped through the TagBuilder's
312
- # attributes: path (data-reactive-ops), so a value can't break out of the
313
- # attribute.
314
- #
315
- # The builder REJECTS focus-class ops (focus/focus_first) outright:
316
- # broadcasting a focus steals focus in every subscriber's tab. Focus is an
317
- # actor-reply concern only (Response#js), so it raises ArgumentError here
318
- # rather than silently shipping a hostile-feeling broadcast.
319
- def broadcast_js_to(*streamables, ops, exclude: nil, visible_to: nil, target: nil)
320
- instrument_broadcast("reactive:js", streamables, exclude:, visible_to:) do
321
- json = broadcast_js_ops_json(ops)
322
- ::Turbo::StreamsChannel.broadcast_action_to(
323
- *streamables,
324
- action: "reactive:js",
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
- # --- Multi-key fan-out (issue #119) ---------------------------------
333
- # Broadcast ONE component to K DIFFERENT stream keys a per-tenant loop
334
- # ("the list page stream AND the dashboard stream"). The classic
335
- # broadcast_*_to concatenates *streamables into ONE key, so fanning out
336
- # to K keys the hand way is K× build + K× render + K× identity HMAC for
337
- # BYTE-IDENTICAL HTML. These render the component ONCE and loop only the
338
- # cheap channel call:
339
- #
340
- # Counter.broadcast_replace_to_each(
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
- def broadcast_update_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
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
- def broadcast_append_to_each(stream_keys, target:, model: nil, exclude: nil, visible_to: nil, **options)
379
- instrument_broadcast("append", stream_keys, exclude:, visible_to:) do
380
- component = build(model, options)
381
- html = render_component(component)
382
- stream_keys.each do
383
- ::Turbo::StreamsChannel.broadcast_append_to(*Array(it), target:, html:)
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
- def broadcast_prepend_to_each(stream_keys, target:, model: nil, exclude: nil, visible_to: nil, **options)
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
- # remove has no body nothing to render. It still builds ONCE to read
399
- # the component's #id (the target), then loops the cheap channel call.
400
- def broadcast_remove_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, **options)
401
- instrument_broadcast("remove", stream_keys, exclude:, visible_to:) do
402
- component = build(model, options)
403
- stream_keys.each do
404
- ::Turbo::StreamsChannel.broadcast_remove_to(*Array(it), target: component.id)
405
- 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, {})
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
- def instrument_broadcast(stream_action, streamables, exclude: nil, visible_to: nil, &)
428
- Phlex::Reactive.instrument(
429
- "broadcast",
430
- { component: name, stream_action: stream_action, streamables: streamables.size }
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 BROADCAST path renders extra <turbo-stream> attributes through
481
- # `attributes:` (it has no `method:` kwarg that would fall into the
482
- # render args and be dropped). Same wire result: method="morph".
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
- def to_stream_replace
554
- Phlex::Reactive::Stream.wrap(
555
- self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self)),
556
- action: "replace", target: id, renders_root: true
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
- # Render THIS instance as a MORPHING replace (issue #28):
561
- # `<turbo-stream action="replace" method="morph">`. Turbo 8's bundled
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
- Phlex::Reactive::Stream.wrap(
568
- self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self), method: :morph),
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 Response.update.
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 Response.streams.
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 Response.remove.
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.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))
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.10.0"
5
+ VERSION = "0.11.0"
6
6
  end
7
7
  end