phlex-reactive 0.13.0 → 0.13.2

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.
@@ -50,6 +50,10 @@ module Phlex
50
50
  prepend: "prepend", remove: "remove", js: "reactive:js"
51
51
  }.freeze
52
52
  BROADCAST_SELF_TARGETING = %i[replace remove].freeze
53
+ # The broadcast_collection_to verbs (issue #248) — a collection DELTA, so
54
+ # only the three that change the size. (A `replace:` row is an ordinary
55
+ # broadcast_to: it moves no boundary and needs no count refresh.)
56
+ COLLECTION_VERBS = %i[append prepend remove].freeze
53
57
  BROADCAST_CONTAINER = %i[update append prepend].freeze
54
58
  BROADCAST_MORPHABLE = %i[replace update].freeze
55
59
 
@@ -118,7 +122,8 @@ module Phlex
118
122
  # thread-local path (so exclude:/visible_to: reach pgbus and Action Cable
119
123
  # no-ops). Self-targeting verbs derive the target from the component's #id
120
124
  # and REQUIRE a Streamable payload; container verbs need an explicit target.
121
- def broadcast_component(owner, verb, payload, component, keys, morph:, target:, exclude:, visible_to:, effect: nil)
125
+ def broadcast_component(owner, verb, payload, component, keys, morph:, target:, exclude:, visible_to:,
126
+ effect: nil, coalesce: nil)
122
127
  if verb == :js && !effect.nil?
123
128
  raise ArgumentError,
124
129
  "broadcast_to js: takes no effect: — effects animate element streams (replace/update/" \
@@ -136,7 +141,7 @@ module Phlex
136
141
  Phlex::Reactive.instrument(
137
142
  "broadcast", { component: component_name, stream_action: BROADCAST_VERBS[verb], streamables: keys.size }
138
143
  ) do
139
- with_pgbus_broadcast_opts(exclude:, visible_to:) do
144
+ with_pgbus_broadcast_opts(exclude:, visible_to:, coalesce:) do
140
145
  # A broadcast render NEVER inherits the actor's url_options (issue
141
146
  # #232): this call may run inside an action request (where the
142
147
  # endpoint threaded the actor's host), but subscribers can be on
@@ -150,6 +155,21 @@ module Phlex
150
155
  end
151
156
  end
152
157
 
158
+ # Broadcast ALREADY-RENDERED html (issue #248) — no component to build or
159
+ # render. The count companion is a plain number, not a component, so it
160
+ # has no render leg; everything else (instrumentation, the pgbus
161
+ # thread-locals, the per-key dispatch) is identical to
162
+ # broadcast_component.
163
+ def broadcast_raw(owner, verb, target, html, keys, exclude: nil, visible_to: nil, coalesce: nil)
164
+ Phlex::Reactive.instrument(
165
+ "broadcast", { component: owner.name, stream_action: BROADCAST_VERBS[verb], streamables: keys.size }
166
+ ) do
167
+ with_pgbus_broadcast_opts(exclude:, visible_to:, coalesce:) do
168
+ keys.each { dispatch_broadcast(verb, it, target, html, nil, false, nil) }
169
+ end
170
+ end
171
+ end
172
+
153
173
  # Validate + serialize broadcast ops: reject actor-only ops (focus steals
154
174
  # focus in every tab; submit force-submits every subscriber's form;
155
175
  # paste_into reads every subscriber's clipboard) and an empty chain (a
@@ -189,18 +209,27 @@ module Phlex
189
209
  # instrument_broadcast uses (issue #185/#187). Duplicated at module level so
190
210
  # the shared broadcast_component owns its transport threading. On Action
191
211
  # Cable / old pgbus this is a pure `yield`.
192
- def with_pgbus_broadcast_opts(exclude:, visible_to:)
212
+ # `coalesce:` (issue #248) rides the SAME thread-local convention. Unlike
213
+ # a kwarg, an unknown thread-local is silently IGNORED by a pgbus that
214
+ # does not forward it (pre-zoolutions/pgbus#465) — so setting it is
215
+ # always safe: an old pgbus just does not coalesce (more messages, same
216
+ # correctness), a new one does. That is why there is no capability gate
217
+ # here beyond the existing pgbus_streams? one.
218
+ def with_pgbus_broadcast_opts(exclude:, visible_to:, coalesce: nil)
193
219
  return yield unless Phlex::Reactive.pgbus_streams?
194
220
 
195
221
  prev_exclude = Thread.current[:pgbus_broadcast_exclude]
196
222
  prev_visible = Thread.current[:pgbus_broadcast_visible_to]
223
+ prev_coalesce = Thread.current[:pgbus_broadcast_coalesce]
197
224
  Thread.current[:pgbus_broadcast_exclude] = exclude
198
225
  Thread.current[:pgbus_broadcast_visible_to] = visible_to
226
+ Thread.current[:pgbus_broadcast_coalesce] = coalesce
199
227
  yield
200
228
  ensure
201
229
  if Phlex::Reactive.pgbus_streams?
202
230
  Thread.current[:pgbus_broadcast_exclude] = prev_exclude
203
231
  Thread.current[:pgbus_broadcast_visible_to] = prev_visible
232
+ Thread.current[:pgbus_broadcast_coalesce] = prev_coalesce
204
233
  end
205
234
  end
206
235
 
@@ -494,6 +523,49 @@ module Phlex
494
523
  )
495
524
  end
496
525
 
526
+ # Broadcast a collection DELTA — the row PLUS the count companion PLUS
527
+ # the 0<->1 empty-state toggle (issue #248), the broadcast-side
528
+ # counterpart of reply.append / reply.remove.
529
+ #
530
+ # Container.broadcast_collection_to(@list, :todos,
531
+ # container: @list_component, append: todo, in: :todos,
532
+ # exclude: reactive_connection_id)
533
+ #
534
+ # broadcast_to(append:) deliberately emits the BARE row: it has no
535
+ # container instance, so it cannot resolve the declaration or run the
536
+ # size resolver. This verb takes that instance as `container:` and runs
537
+ # the SAME Phlex::Reactive::Collections decisions the reply path runs,
538
+ # so a peer's list stays as correct as the actor's.
539
+ #
540
+ # `in:` names the declared reactive_collection; the verb is `append:`,
541
+ # `prepend:` or `remove:` (exactly one). `exclude:`/`visible_to:` thread
542
+ # to pgbus as everywhere else.
543
+ #
544
+ # `row:` is the row component's extra init kwargs (issue #186's row_kwargs
545
+ # on the reply side) — pass the SAME ones the actor got, or a peer whose
546
+ # row component has a required kwarg raises instead of rendering.
547
+ #
548
+ # `coalesce:` (a window in ms, or true) applies to the AGGREGATE streams
549
+ # only — the count companion and the empty-state toggle are idempotent
550
+ # replaces of stable targets, so a 177-row fan-out collapses to a
551
+ # handful of count refreshes. The ROW stream is NEVER coalesced: an
552
+ # append is not idempotent and each row is a distinct target. Needs
553
+ # pgbus with zoolutions/pgbus#465; on anything older the thread-local is
554
+ # ignored and every aggregate stream simply goes out (correct, chattier).
555
+ # (`in:` is a Ruby keyword, so it cannot be a named parameter — it is
556
+ # pulled out of **opts, which then holds exactly the verb kwarg.)
557
+ def broadcast_collection_to(*streamables, container:, exclude: nil, visible_to: nil,
558
+ coalesce: nil, effect: nil, row: {}, **opts)
559
+ name = opts.delete(:in) ||
560
+ raise(ArgumentError, "broadcast_collection_to needs in: :collection_name")
561
+ action, model = extract_collection_verb(opts)
562
+ definition = Phlex::Reactive::Collections.definition!(container, name)
563
+ keys = [streamables]
564
+
565
+ broadcast_collection_row(definition, action, model, keys, exclude:, visible_to:, effect:, row:)
566
+ broadcast_collection_aggregates(definition, container, action, keys, exclude:, visible_to:, coalesce:)
567
+ end
568
+
497
569
  # Define the guided-error stub for each removed broadcast method (issue
498
570
  # #185). `verb` is referenced in define_method AND the message, so the outer
499
571
  # block param must be named — `it` is illegal here.
@@ -519,6 +591,77 @@ module Phlex
519
591
  verb.first
520
592
  end
521
593
 
594
+ # The collection verb split — append:/prepend:/remove:, exactly one.
595
+ def extract_collection_verb(verb)
596
+ unless verb.size == 1 && COLLECTION_VERBS.include?(verb.keys.first)
597
+ raise ArgumentError,
598
+ "broadcast_collection_to needs exactly ONE verb kwarg " \
599
+ "(#{COLLECTION_VERBS.join("/")}), got #{verb.keys.inspect}"
600
+ end
601
+
602
+ verb.first
603
+ end
604
+
605
+ # The ROW leg: routed through the ordinary broadcast_to so the row is
606
+ # built, rendered and instrumented exactly like every other broadcast.
607
+ # Never coalesced (distinct targets; an append is not idempotent).
608
+ #
609
+ # A STRING model is an already-built dom id, the same form
610
+ # reply.remove(id, from:) and Collections.row_remove_stream accept.
611
+ # Building a row component from it would hand the id to the component's
612
+ # initializer as if it were a record — so it short-circuits to a raw
613
+ # remove of that target instead.
614
+ def broadcast_collection_row(definition, action, model, keys, exclude:, visible_to:, effect:, row: {})
615
+ if action == :remove
616
+ if model.is_a?(::String)
617
+ return Phlex::Reactive::Streamable.broadcast_raw(
618
+ definition.item, :remove, model, nil, keys, exclude:, visible_to:
619
+ )
620
+ end
621
+
622
+ return Phlex::Reactive::Streamable.broadcast_component(
623
+ definition.item, :remove, model, definition.item.send(:build, model, row), keys,
624
+ morph: false, target: nil, exclude:, visible_to:, effect:
625
+ )
626
+ end
627
+
628
+ Phlex::Reactive::Streamable.broadcast_component(
629
+ definition.item, action, model, definition.item.send(:build, model, row), keys,
630
+ morph: false, target: definition.container, exclude:, visible_to:, effect:
631
+ )
632
+ end
633
+
634
+ # The AGGREGATE leg: the count companion and the empty-state toggle,
635
+ # both idempotent replaces of stable targets — so both carry `coalesce:`.
636
+ def broadcast_collection_aggregates(definition, container, action, keys, exclude:, visible_to:, coalesce:)
637
+ delta = action == :remove ? :remove : :add
638
+ # Resolve the size ONCE (it is usually a DB count) and hand the same
639
+ # value to both decisions — see Collections.size_of.
640
+ size = Phlex::Reactive::Collections.size_of(definition, container)
641
+
642
+ if (refresh = Phlex::Reactive::Collections.count_refresh(definition, container, size))
643
+ # NOT `target, size = refresh` — that would rebind `size` to the
644
+ # count's STRING form and hand a String to empty_toggle below.
645
+ count_target, count_html = refresh
646
+ Phlex::Reactive::Streamable.broadcast_raw(
647
+ self, :update, count_target, count_html, keys, exclude:, visible_to:, coalesce:
648
+ )
649
+ end
650
+
651
+ case Phlex::Reactive::Collections.empty_toggle(definition, container, delta, size)
652
+ when :clear
653
+ Phlex::Reactive::Streamable.broadcast_component(
654
+ definition.empty, :remove, nil, definition.empty.new, keys,
655
+ morph: false, target: nil, exclude:, visible_to:, coalesce:
656
+ )
657
+ when :restore
658
+ Phlex::Reactive::Streamable.broadcast_component(
659
+ definition.empty, :append, nil, definition.empty.new, keys,
660
+ morph: false, target: definition.container, exclude:, visible_to:, coalesce:
661
+ )
662
+ end
663
+ end
664
+
522
665
  # Coerce a verb payload into a built component: a Phlex component passes
523
666
  # through (issue #185 — built payloads); a Hash is init kwargs verbatim (no
524
667
  # **options collision); anything else is the record built via
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.13.0"
5
+ VERSION = "0.13.2"
6
6
  end
7
7
  end
@@ -712,6 +712,41 @@ module Phlex
712
712
  defined?(::ActiveJob::Base) ? true : false
713
713
  end
714
714
 
715
+ # --- Async-action lifecycle / settles (issue #248) -----------------
716
+
717
+ # NOTE: there is deliberately NO settle_token_ttl. Issue #248's sketch had
718
+ # one for a fallback PULL token, but a settle has no pull lane at all — the
719
+ # client cannot poll "is the job done yet", and redeeming such a token at
720
+ # the defer endpoint would render the PRE-JOB component, which is the exact
721
+ # bug reply.pending exists to fix. A setting that cannot change behavior is
722
+ # worse than no setting: it tells an operator they can extend a wait window
723
+ # that is not governed by a token in the first place. The settle's wait is
724
+ # bounded by the JOB, not by a TTL.
725
+
726
+ # Window (ms) the AGGREGATE settle streams coalesce on — the count
727
+ # companion, the empty-state toggle, any companion refresh. They are
728
+ # idempotent replaces of stable targets, so a 177-row fan-out collapses to
729
+ # a handful of them instead of 177. The ROW streams are never coalesced.
730
+ # Needs pgbus with zoolutions/pgbus#465 on the peers path; without it the
731
+ # window is simply ignored. nil resets to the default.
732
+ attr_writer :settle_coalesce_window_ms
733
+
734
+ def settle_coalesce_window_ms
735
+ @settle_coalesce_window_ms ||= 50
736
+ end
737
+
738
+ # Can reply.pending mint a settle handle at all? A settle has NO pull
739
+ # fallback — the client cannot poll "is the job done yet" — so it needs
740
+ # the defer PUSH lane (durable pgbus one-shot stream + ActiveJob). A
741
+ # forced defer_transport of :fetch is therefore also a no.
742
+ #
743
+ # False does NOT break anything: reply.pending degrades to a plain
744
+ # enqueue (no pending markers, no handle, reactive_settle no-ops in the
745
+ # job) — today's behavior, never a permanently pending row.
746
+ def settle_capable?
747
+ defer_push_capable? && defer_transport != :fetch
748
+ end
749
+
715
750
  # DOM id of the host-app container a Response#flash appends into.
716
751
  # Default "flash"; override to match your layout's flash region.
717
752
  def flash_target
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.13.0
4
+ version: 0.13.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -153,6 +153,7 @@ files:
153
153
  - lib/phlex/reactive/authorization.rb
154
154
  - lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md
155
155
  - lib/phlex/reactive/client_bindings.rb
156
+ - lib/phlex/reactive/collections.rb
156
157
  - lib/phlex/reactive/component.rb
157
158
  - lib/phlex/reactive/component/dsl.rb
158
159
  - lib/phlex/reactive/component/helpers.rb
@@ -178,8 +179,11 @@ files:
178
179
  - lib/phlex/reactive/mcp/tools/doctor_tool.rb
179
180
  - lib/phlex/reactive/mcp/tools/find_tool.rb
180
181
  - lib/phlex/reactive/param_schema.rb
182
+ - lib/phlex/reactive/pending.rb
181
183
  - lib/phlex/reactive/reply.rb
182
184
  - lib/phlex/reactive/response.rb
185
+ - lib/phlex/reactive/settle.rb
186
+ - lib/phlex/reactive/settles.rb
183
187
  - lib/phlex/reactive/show_conditions.rb
184
188
  - lib/phlex/reactive/stream.rb
185
189
  - lib/phlex/reactive/streamable.rb