phlex-reactive 0.11.7 → 0.12.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.
@@ -114,7 +114,12 @@ module Phlex
114
114
  # thread-local path (so exclude:/visible_to: reach pgbus and Action Cable
115
115
  # no-ops). Self-targeting verbs derive the target from the component's #id
116
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:)
117
+ def broadcast_component(owner, verb, payload, component, keys, morph:, target:, exclude:, visible_to:, effect: nil)
118
+ if verb == :js && !effect.nil?
119
+ raise ArgumentError,
120
+ "broadcast_to js: takes no effect: — effects animate element streams (replace/update/" \
121
+ "append/prepend/remove), not client-op dispatches"
122
+ end
118
123
  resolved_target = resolve_broadcast_target(verb, component, payload, target)
119
124
  # broadcast_js_ops_json is a private class method on the owner Streamable
120
125
  # class (reached via send); the instrumentation + pgbus thread-locals are
@@ -127,7 +132,7 @@ module Phlex
127
132
  with_pgbus_broadcast_opts(exclude:, visible_to:) do
128
133
  html = verb == :js ? nil : render_broadcast_html(component)
129
134
  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) }
135
+ keys.each { dispatch_broadcast(verb, it, resolved_target, html, ops_json, morph, effect) }
131
136
  end
132
137
  end
133
138
  end
@@ -192,14 +197,18 @@ module Phlex
192
197
  # Route ONE key to its Turbo::StreamsChannel call for the verb (issue #185).
193
198
  # exclude:/visible_to: are NOT passed here — they ride the pgbus thread-locals
194
199
  # set by with_pgbus_broadcast_opts (turbo-rails would swallow them as kwargs).
195
- def dispatch_broadcast(verb, key, target, html, ops_json, morph)
200
+ # `effect` (issue #215) rides the same attributes: seam morph does — every
201
+ # broadcast_*_to delegates to broadcast_action_to(attributes:), so the attr
202
+ # lands on the <turbo-stream> element on every verb.
203
+ def dispatch_broadcast(verb, key, target, html, ops_json, morph, effect = nil)
196
204
  parts = Array(key)
205
+ wire = broadcast_wire(morph, effect)
197
206
  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:)
207
+ when :replace then ::Turbo::StreamsChannel.broadcast_replace_to(*parts, target:, html:, **wire)
208
+ when :update then ::Turbo::StreamsChannel.broadcast_update_to(*parts, target:, html:, **wire)
209
+ when :append then ::Turbo::StreamsChannel.broadcast_append_to(*parts, target:, html:, **wire)
210
+ when :prepend then ::Turbo::StreamsChannel.broadcast_prepend_to(*parts, target:, html:, **wire)
211
+ when :remove then ::Turbo::StreamsChannel.broadcast_remove_to(*parts, target:, **wire)
203
212
  when :js
204
213
  ::Turbo::StreamsChannel.broadcast_action_to(
205
214
  *parts, action: "reactive:js", target:, attributes: { data: { reactive_ops: ops_json } }, render: false
@@ -207,11 +216,18 @@ module Phlex
207
216
  end
208
217
  end
209
218
 
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" } } : {}
219
+ # The ONE broadcast attributes compiler (issue #185, extended #215): the
220
+ # broadcast path emits extra <turbo-stream> attributes via `attributes:`
221
+ # (it has no `method:` kwarg) — method="morph" and/or the per-call
222
+ # data-reactive-effect. {} when neither, so the plain call's wire is
223
+ # byte-identical. (Replaces morph_wire.)
224
+ def broadcast_wire(morph, effect = nil)
225
+ return {} if !morph && effect.nil?
226
+
227
+ attrs = {}
228
+ attrs[:method] = "morph" if morph
229
+ attrs[:data] = { reactive_effect: Phlex::Reactive::Effects.wire_value(effect) } unless effect.nil?
230
+ { attributes: attrs }
215
231
  end
216
232
 
217
233
  # Split the single verb kwarg out of the module-level broadcast_to's **verb
@@ -326,10 +342,17 @@ module Phlex
326
342
  # action/target/token-ness structurally. renders_root: true — a replace
327
343
  # re-renders the component's own root (carrying its fresh token); wire
328
344
  # bytes are byte-identical.
329
- def replace(model = nil, morph: false, **options)
345
+ # `effect:` (issue #215) the PER-CALL effect override, stamped as
346
+ # data-reactive-effect on the <turbo-stream> element (false → "off").
347
+ # nil (the default) leaves the wire byte-identical. Same on every
348
+ # builder below.
349
+ def replace(model = nil, morph: false, effect: nil, **options)
330
350
  component = build(model, options)
331
351
  Phlex::Reactive::Stream.wrap(
332
- turbo_stream_builder.replace(component.id, html: render_component(component), **morph_method(morph)),
352
+ Phlex::Reactive::Effects.annotate(
353
+ turbo_stream_builder.replace(component.id, html: render_component(component), **morph_method(morph)),
354
+ effect
355
+ ),
333
356
  action: "replace", target: component.id, renders_root: true
334
357
  )
335
358
  end
@@ -338,10 +361,13 @@ module Phlex
338
361
  # Turbo 8 morphs the inner HTML in place instead of replacing it (issue
339
362
  # #113) — a cross-tab update no longer clobbers a peer's focus/caret.
340
363
  # Default (morph: false) is the unchanged plain update.
341
- def update(model = nil, morph: false, **options)
364
+ def update(model = nil, morph: false, effect: nil, **options)
342
365
  component = build(model, options)
343
366
  Phlex::Reactive::Stream.wrap(
344
- turbo_stream_builder.update(component.id, html: render_component(component), **morph_method(morph)),
367
+ Phlex::Reactive::Effects.annotate(
368
+ turbo_stream_builder.update(component.id, html: render_component(component), **morph_method(morph)),
369
+ effect
370
+ ),
345
371
  action: "update", target: component.id, renders_root: true
346
372
  )
347
373
  end
@@ -349,26 +375,32 @@ module Phlex
349
375
  # renders_root: false — append inserts a CHILD into `target`; even when
350
376
  # the appended component carries its OWN token, that must never count as
351
377
  # the container's own refresh (issue #44).
352
- def append(target:, model: nil, **options)
378
+ def append(target:, model: nil, effect: nil, **options)
353
379
  component = build(model, options)
354
380
  Phlex::Reactive::Stream.wrap(
355
- turbo_stream_builder.append(target, html: render_component(component)),
381
+ Phlex::Reactive::Effects.annotate(
382
+ turbo_stream_builder.append(target, html: render_component(component)),
383
+ effect
384
+ ),
356
385
  action: "append", target: target, renders_root: false
357
386
  )
358
387
  end
359
388
 
360
- def prepend(target:, model: nil, **options)
389
+ def prepend(target:, model: nil, effect: nil, **options)
361
390
  component = build(model, options)
362
391
  Phlex::Reactive::Stream.wrap(
363
- turbo_stream_builder.prepend(target, html: render_component(component)),
392
+ Phlex::Reactive::Effects.annotate(
393
+ turbo_stream_builder.prepend(target, html: render_component(component)),
394
+ effect
395
+ ),
364
396
  action: "prepend", target: target, renders_root: false
365
397
  )
366
398
  end
367
399
 
368
- def remove(model = nil, **options)
400
+ def remove(model = nil, effect: nil, **options)
369
401
  component = build(model, options)
370
402
  Phlex::Reactive::Stream.wrap(
371
- turbo_stream_builder.remove(component.id),
403
+ Phlex::Reactive::Effects.annotate(turbo_stream_builder.remove(component.id), effect),
372
404
  action: "remove", target: component.id, renders_root: false
373
405
  )
374
406
  end
@@ -415,12 +447,13 @@ module Phlex
415
447
  # thread to pgbus via the capability-gated instrument_broadcast path; on
416
448
  # Action Cable they no-op. The class-level and module-level (Phlex::Reactive.
417
449
  # broadcast_to) forms share broadcast_component (below).
418
- def broadcast_to(*streamables, morph: false, target: nil, exclude: nil, visible_to: nil, each: nil, **verb)
450
+ def broadcast_to(*streamables, morph: false, target: nil, exclude: nil, visible_to: nil, each: nil,
451
+ effect: nil, **verb)
419
452
  name, payload = extract_broadcast_verb(verb)
420
453
  component = name == :js ? nil : coerce_broadcast_payload(payload)
421
454
  keys = each ? each.map { Array(it) } : [streamables]
422
455
  Phlex::Reactive::Streamable.broadcast_component(
423
- self, name, payload, component, keys, morph:, target:, exclude:, visible_to:
456
+ self, name, payload, component, keys, morph:, target:, exclude:, visible_to:, effect:
424
457
  )
425
458
  end
426
459
 
@@ -509,9 +542,9 @@ module Phlex
509
542
  morph ? { method: :morph } : {}
510
543
  end
511
544
 
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.)
545
+ # (The broadcast path's extra-attribute wire lives in
546
+ # Streamable.broadcast_wire — the single compiler both the class-level
547
+ # and module-level broadcast_to share; issue #185, extended #215.)
515
548
 
516
549
  def renderer
517
550
  Phlex::Reactive.renderer
@@ -586,10 +619,13 @@ module Phlex
586
619
  # refreshes). Default (morph: false) is the plain outerHTML replace,
587
620
  # byte-identical to before. Used by reply.replace / reply.morph. The morph:
588
621
  # kwarg replaces the deleted to_stream_morph (issue #185).
589
- def to_stream_replace(morph: false)
622
+ # `effect:` (issue #215) — the per-call effect override on the actor's
623
+ # own reply stream, same wire as the class builders (nil = untouched).
624
+ def to_stream_replace(morph: false, effect: nil)
590
625
  builder = self.class.turbo_stream_builder
591
626
  html = self.class.render_component(self)
592
627
  rendered = morph ? builder.replace(id, html:, method: :morph) : builder.replace(id, html:)
628
+ rendered = Phlex::Reactive::Effects.annotate(rendered, effect)
593
629
  Phlex::Reactive::Stream.wrap(rendered, action: "replace", target: id, renders_root: true)
594
630
  end
595
631
 
@@ -606,10 +642,11 @@ module Phlex
606
642
  # false) is the unchanged plain update. Passing `method: :morph` inline
607
643
  # (as #to_stream_morph does) keeps the plain call's wire byte-identical.
608
644
  # Used by reply.update.
609
- def to_stream_update(morph: false)
645
+ def to_stream_update(morph: false, effect: nil)
610
646
  builder = self.class.turbo_stream_builder
611
647
  html = self.class.render_component(self)
612
648
  rendered = morph ? builder.update(id, html:, method: :morph) : builder.update(id, html:)
649
+ rendered = Phlex::Reactive::Effects.annotate(rendered, effect)
613
650
  Phlex::Reactive::Stream.wrap(rendered, action: "update", target: id, renders_root: true)
614
651
  end
615
652
 
@@ -647,9 +684,9 @@ module Phlex
647
684
  # Render THIS instance as a remove stream. The component already knows its
648
685
  # own #id, so no record/class reconstruction is needed (works for record-
649
686
  # and state-backed components alike). Used by reply.remove.
650
- def to_stream_remove
687
+ def to_stream_remove(effect: nil)
651
688
  Phlex::Reactive::Stream.wrap(
652
- self.class.turbo_stream_builder.remove(id),
689
+ Phlex::Reactive::Effects.annotate(self.class.turbo_stream_builder.remove(id), effect),
653
690
  action: "remove", target: id, renders_root: false
654
691
  )
655
692
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.11.7"
5
+ VERSION = "0.12.0"
6
6
  end
7
7
  end
@@ -704,6 +704,33 @@ module Phlex
704
704
 
705
705
  attr_writer :flash_target
706
706
 
707
+ # Global effects opt-in (issue #215). nil (the default) = OFF: no root
708
+ # attrs are emitted anywhere, the wire is byte-identical to pre-effects,
709
+ # and the client interceptor sees nothing to do. Setting it is the
710
+ # opt-in AND the app-wide default set — refined per component with
711
+ # `reactive_effects` and per call with `effect:` (most specific wins):
712
+ #
713
+ # Phlex::Reactive.effects = true # { enter: :fade, exit: :fade, update: :highlight }
714
+ # Phlex::Reactive.effects = { enter: :slide, update: :highlight }
715
+ #
716
+ # Stored pre-normalized (wire strings), validated at WRITE time — a
717
+ # typo'd effect name raises here, not at render time.
718
+ def effects
719
+ defined?(@effects) ? @effects : nil
720
+ end
721
+
722
+ def effects=(value)
723
+ @effects = Phlex::Reactive::Effects.normalize_config(value)
724
+ @effects_generation = effects_generation + 1
725
+ end
726
+
727
+ # Monotonic write counter for effects= — the cache key half that lets
728
+ # each component class memoize its RESOLVED effect attrs (reactive_attrs
729
+ # is the token-signing hot path) without ever serving a stale config.
730
+ def effects_generation
731
+ @effects_generation ||= 0
732
+ end
733
+
707
734
  # A user-visible flash rendered on every endpoint rescue path (issue #100).
708
735
  # Default nil = today's behavior (a bare head, or the verbose_errors
709
736
  # plain-text diagnostic). Set a lambda ->(kind) { "message" } (kind is
@@ -763,7 +790,8 @@ module Phlex
763
790
  # verbs (replace:/remove:) require a Streamable payload (its #id is the target)
764
791
  # — a plain component gets a guided error steering to update:. Shares the one
765
792
  # broadcast implementation with the class-level form.
766
- def broadcast_to(*streamables, morph: false, target: nil, exclude: nil, visible_to: nil, each: nil, **verb)
793
+ def broadcast_to(*streamables, morph: false, target: nil, exclude: nil, visible_to: nil, each: nil,
794
+ effect: nil, **verb)
767
795
  name, payload = Phlex::Reactive::Streamable.extract_module_broadcast_verb(verb)
768
796
  component = name == :js ? nil : payload
769
797
  keys = each ? each.map { Array(it) } : [streamables]
@@ -773,7 +801,7 @@ module Phlex
773
801
  # the module form is for BUILT component payloads.
774
802
  owner = payload.is_a?(Phlex::Reactive::Streamable) ? payload.class : Phlex::Reactive::Streamable
775
803
  Phlex::Reactive::Streamable.broadcast_component(
776
- owner, name, payload, component, keys, morph:, target:, exclude:, visible_to:
804
+ owner, name, payload, component, keys, morph:, target:, exclude:, visible_to:, effect:
777
805
  )
778
806
  end
779
807
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.7
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -116,6 +116,7 @@ files:
116
116
  - CHANGELOG.md
117
117
  - LICENSE.txt
118
118
  - README.md
119
+ - app/assets/stylesheets/phlex/reactive/effects.css
119
120
  - app/controllers/phlex/reactive/actions_controller.rb
120
121
  - app/javascript/phlex/reactive/compute.js
121
122
  - app/javascript/phlex/reactive/compute.min.js
@@ -161,6 +162,7 @@ files:
161
162
  - lib/phlex/reactive/defer.rb
162
163
  - lib/phlex/reactive/deferred_render_job.rb
163
164
  - lib/phlex/reactive/doctor.rb
165
+ - lib/phlex/reactive/effects.rb
164
166
  - lib/phlex/reactive/engine.rb
165
167
  - lib/phlex/reactive/inspector.rb
166
168
  - lib/phlex/reactive/inspector/report.rb