phlex-reactive 0.12.6 → 0.13.1

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.
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The reactive_collection bookkeeping (issue #35), extracted from Response's
6
+ # privates so MORE THAN ONE caller can run it (issue #248).
7
+ #
8
+ # A collection row is never just a row: adding one must also refresh the
9
+ # count companion and clear the empty-state at the 0->1 boundary; removing
10
+ # one must refresh the count and restore the empty-state at the 1->0
11
+ # boundary. That arithmetic used to live inside Response, reachable only
12
+ # from `reply.*` — so a JOB that finished background work had to re-derive
13
+ # it by hand and got the boundary subtly wrong.
14
+ #
15
+ # Three callers now share this module:
16
+ #
17
+ # * Response.build_collection_{append,prepend,remove} — the actor's reply
18
+ # * Phlex::Reactive::Settle — the job-side settle (issue #248)
19
+ # * Streamable.broadcast_collection_to — the peers' broadcast (issue #248)
20
+ #
21
+ # The reply/settle paths build <turbo-stream> STRINGS; the broadcast path
22
+ # hands pieces to Turbo::StreamsChannel, which builds its own tags. They
23
+ # therefore cannot share the rendering — so what they share is the layer
24
+ # that actually drifts: the DECISIONS (#count_refresh and #empty_toggle,
25
+ # the 0<->1 boundary). Every renderer below reads those two.
26
+ #
27
+ # The module is stateless: every method takes the CollectionDefinition plus
28
+ # the bound container instance (the size resolver is `instance_exec`d
29
+ # against it, so it reads the container's ivars/association).
30
+ module Collections
31
+ class << self
32
+ # Resolve a declared collection off the container's class. A typo'd name
33
+ # must fail LOUDLY here, not silently emit an empty stream list.
34
+ def definition!(container, name)
35
+ container.class.reactive_collections[name.to_sym] ||
36
+ raise(Phlex::Reactive::Error,
37
+ "undeclared reactive_collection :#{name} on #{container.class}")
38
+ end
39
+
40
+ # --- The two shared DECISIONS -------------------------------------
41
+
42
+ # The collection's LIVE size, resolved ONCE per delta. Both decisions
43
+ # below read it, and both renderers pass the same value down — the
44
+ # resolver is usually a DB count, so evaluating it twice per delta is
45
+ # both an extra query AND a correctness hazard: a concurrent write
46
+ # landing between the two reads would emit a count companion that
47
+ # disagrees with the empty-state toggle it ships beside.
48
+ # `:__unresolved` is the "not computed yet" sentinel, distinct from a
49
+ # legitimately nil size (no size: declared).
50
+ def size_of(definition, container) = definition.size_for(container)
51
+
52
+ # [count_target, size_string] when a count companion AND a size resolver
53
+ # are both declared and the resolver returned a number; nil otherwise
54
+ # (the count stream is simply omitted — a list with just rows works).
55
+ # `size:` may be passed in by a caller that already resolved it.
56
+ def count_refresh(definition, container, size = :__unresolved)
57
+ return nil unless definition.count
58
+
59
+ size = size_of(definition, container) if size == :__unresolved
60
+ return nil if size.nil?
61
+
62
+ [definition.count, size.to_s]
63
+ end
64
+
65
+ # What the empty-state must do for this delta, or nil for "nothing":
66
+ # :clear — the list just crossed 0->1, remove the empty-state
67
+ # :restore — the list just emptied, append the empty-state back
68
+ # Both are edge-triggered off the LIVE size (the resolver runs after the
69
+ # mutation), never off a client-side increment.
70
+ def empty_toggle(definition, container, delta, size = :__unresolved)
71
+ return nil unless definition.empty
72
+
73
+ size = size_of(definition, container) if size == :__unresolved
74
+ case delta
75
+ when :add then :clear if size == 1
76
+ else :restore if size&.zero?
77
+ end
78
+ end
79
+
80
+ # --- The reply/settle renderer (turbo-stream strings) --------------
81
+
82
+ # Row add (append/prepend) + count + empty-state clear.
83
+ #
84
+ # row_kwargs (issue #186) thread to the row component's init via the
85
+ # class stream builder's **options passthrough (ItemRow.new(model:,
86
+ # **row_kwargs)). `effect:` (issue #215) stamps the ROW stream only —
87
+ # the count companion and the empty-state toggle are bookkeeping, not
88
+ # the thing entering/leaving.
89
+ def add_streams(definition, container, model, action, row_kwargs = {}, effect: nil)
90
+ size = size_of(definition, container)
91
+ streams = [
92
+ definition.item.public_send(action, target: definition.container, model:, effect:, **row_kwargs)
93
+ ]
94
+ streams.concat(count_streams(definition, container, size))
95
+ streams << definition.empty.new.to_stream_remove if empty_toggle(definition, container, :add, size) == :clear
96
+ streams
97
+ end
98
+
99
+ # Row remove + count + empty-state restore. The empty-state is appended
100
+ # back INTO the container (not its own id) when the list just emptied —
101
+ # restoring "No items yet" after the last row went. model: nil builds it
102
+ # argument-free (an empty-state is a static view).
103
+ def remove_streams(definition, container, model, effect: nil)
104
+ size = size_of(definition, container)
105
+ streams = [row_remove_stream(definition, model, effect)]
106
+ streams.concat(count_streams(definition, container, size))
107
+ if empty_toggle(definition, container, :remove, size) == :restore
108
+ streams << definition.empty.append(target: definition.container, model: nil)
109
+ end
110
+ streams
111
+ end
112
+
113
+ # The count companion's update stream, or [] when there is nothing to
114
+ # refresh. Its own method (rather than an inline append) because the
115
+ # settle path refreshes the count WITHOUT a row delta — a job that
116
+ # neither added nor removed a row can still have changed the size.
117
+ def count_streams(definition, container, size = :__unresolved)
118
+ target, resolved = count_refresh(definition, container, size)
119
+ return [] unless target
120
+
121
+ [Phlex::Reactive::Response.update_stream(target, resolved)]
122
+ end
123
+
124
+ # Remove the row by its DOM id. Accepts the record (so dom_id is
125
+ # derived) or an already-built dom-id string (e.g. the value the row
126
+ # used as its #id).
127
+ def row_remove_stream(definition, model, effect = nil)
128
+ if model.is_a?(String)
129
+ Phlex::Reactive::Effects.annotate(Phlex::Reactive.stream_builder.remove(model), effect)
130
+ else
131
+ definition.item.remove(model, effect:)
132
+ end
133
+ end
134
+
135
+ # Re-render ONE row in place (a settle that neither added nor removed —
136
+ # the work ran and the row is simply different now). No count/empty
137
+ # streams: a replace cannot change the size.
138
+ def replace_streams(definition, model, effect: nil, **row_kwargs)
139
+ [definition.item.replace(model, effect:, **row_kwargs)]
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -1103,6 +1103,11 @@ module Phlex
1103
1103
  # anything carrying reactive_persist_skip, a nested reactive root's
1104
1104
  # controls (#15 ownership), and — when `fields:` narrows the set —
1105
1105
  # any name outside it (scope-aware symbols, the reactive_show form).
1106
+ # Rich editors ARE persisted (#241): a named lexxy-editor / trix-editor
1107
+ # (Trix's name may come from its `input=`-paired hidden input) or a
1108
+ # bare named [contenteditable] is drafted and restored through its own
1109
+ # value surface (the editor's `value` setter, textContent) — never
1110
+ # innerHTML; put reactive_persist_skip on the editor element to opt out.
1106
1111
  # `autocomplete="off"` is NOT an implicit skip: honeypots (an
1107
1112
  # invisible_captcha text input looks like any other) must opt out
1108
1113
  # explicitly or sit outside the root.
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The async-action lifecycle (issue #248): the machinery behind
6
+ # `reply.pending` — "mark these targets pending, let MY job settle them".
7
+ #
8
+ # ## Why this exists
9
+ #
10
+ # The endpoint runs an action inside a transaction and renders the reply
11
+ # THERE, while a queue adapter publishes on COMMIT. So any `reply.morph`
12
+ # after an enqueue renders from a database the job has not touched yet and
13
+ # is GUARANTEED to draw the pre-job world — rows still present, buttons
14
+ # still live, counts unchanged — next to a "Queued 177 transfers" flash the
15
+ # same reply emitted. Apps worked around it with a `queued:` kwarg threaded
16
+ # into every row component plus a second render branch; ~60 lines of
17
+ # identical bookkeeping per screen, and the page still never learned the
18
+ # outcome.
19
+ #
20
+ # ## The shape
21
+ #
22
+ # `reply.pending` does three things, in order:
23
+ #
24
+ # 1. mints ONE one-shot durable stream key for the whole call (see the
25
+ # shared-key rule below) and builds a Handle describing the settle;
26
+ # 2. runs the caller's enqueue — a block, or the `job:`/`args:` sugar —
27
+ # with that Handle in a thread-local, so EVERY ActiveJob enqueued
28
+ # inside captures it through Phlex::Reactive::Settles#serialize;
29
+ # 3. records a Segment on the Response. The ENDPOINT turns it into wire
30
+ # streams after the action's transaction committed (a rolled-back
31
+ # action can never leak a directive).
32
+ #
33
+ # ## One key per call — a hard design rule
34
+ #
35
+ # A durable pgbus broadcast to a never-seen key calls `ensure_queue!` →
36
+ # `pgmq.create`, i.e. a REAL PGMQ table per key, reclaimed only by pgbus's
37
+ # hourly orphan sweep at a 24h threshold. A key per record would leave 177
38
+ # tables sitting for a day (and open 177 SSE connections). So all N settles
39
+ # of one `reply.pending` share ONE key and ONE subscription, anchored on the
40
+ # container component's id.
41
+ #
42
+ # Because the key is shared, a per-settle teardown would kill the
43
+ # subscription on the FIRST arrival. Teardown is therefore explicit — see
44
+ # Phlex::Reactive::Settle#finish? — and defaults to "finish when this call
45
+ # marked exactly one record pending".
46
+ #
47
+ # ## No pull fallback
48
+ #
49
+ # Unlike `reply.defer`, a settle has no `:fetch` lane: the client cannot
50
+ # poll "is the job done yet". So `reply.pending` needs the defer PUSH lane
51
+ # (Phlex::Reactive.settle_capable?). Without it, it DEGRADES rather than
52
+ # breaks: the enqueue still runs, no handle is installed (so
53
+ # `reactive_settle` no-ops exactly like a sweep-enqueued job), and NO
54
+ # pending markers are emitted — the UI shows the pre-job world, which is
55
+ # today's behavior, instead of a shimmer that could never resolve.
56
+ module Pending
57
+ # The settle handle: everything a job needs to reach the actor and rebuild
58
+ # the container off the request thread. It rides ActiveJob metadata, so it
59
+ # must round-trip through plain JSON.
60
+ Handle = Data.define(
61
+ :stream_key, # the shared one-shot durable pgbus key
62
+ :container_class, # the container component's class NAME
63
+ :container_payload, # its reactive_identity_payload, for from_identity
64
+ :anchor, # the container's DOM id: subscription + teardown target
65
+ :collection, # the declared reactive_collection name, or nil
66
+ :target_ids, # the DOM ids THIS handle is responsible for un-pending
67
+ :peers, # peer stream key parts, or nil (actor-only)
68
+ :connection_id # the actor's connection id, so peers exclude the echo
69
+ ) do
70
+ # How many targets this handle owns — drives the `finish: :auto` default
71
+ # (a single-target settle tears the shared subscription down; a fan-out
72
+ # waits for an explicit finish).
73
+ def count = target_ids.size
74
+
75
+ def to_h_wire
76
+ {
77
+ "key" => stream_key, "c" => container_class, "p" => container_payload,
78
+ "anchor" => anchor, "coll" => collection&.to_s, "ids" => target_ids,
79
+ "peers" => peers, "cid" => connection_id
80
+ }
81
+ end
82
+
83
+ def self.from_wire(data)
84
+ return nil unless data.is_a?(Hash) && data["key"]
85
+
86
+ new(
87
+ stream_key: data["key"], container_class: data["c"], container_payload: data["p"],
88
+ anchor: data["anchor"], collection: data["coll"]&.to_sym,
89
+ target_ids: data["ids"] || [], peers: data["peers"], connection_id: data["cid"]
90
+ )
91
+ end
92
+ end
93
+
94
+ # One recorded pending segment: the handle plus the DOM ids that were
95
+ # marked pending (the endpoint emits one marker stream per id).
96
+ Segment = Data.define(:handle, :target_ids)
97
+
98
+ # The attribute apps style. Set alongside aria-busy on every pending
99
+ # target AND on the container, so one CSS rule covers both:
100
+ #
101
+ # [data-reactive-pending] { opacity: .5; pointer-events: none; }
102
+ PENDING_ATTR = "data-reactive-pending"
103
+
104
+ # The thread/fiber-local cell holding the handle for the duration of the
105
+ # enqueue block. Mirrors Phlex::Reactive.with_connection_id exactly.
106
+ HANDLE_KEY = :phlex_reactive_settle_handle
107
+
108
+ class << self
109
+ def current_handle = Thread.current[HANDLE_KEY]
110
+
111
+ def with_handle(handle)
112
+ previous = Thread.current[HANDLE_KEY]
113
+ Thread.current[HANDLE_KEY] = handle
114
+ yield
115
+ ensure
116
+ Thread.current[HANDLE_KEY] = previous
117
+ end
118
+
119
+ # Build the Segment for one reply.pending call, running the caller's
120
+ # enqueue with the handle installed. Returns nil when the push lane is
121
+ # unavailable — the enqueue still ran, there is just nothing to settle.
122
+ def build_segment(container, records, collection:, peers:, job:, args:, enqueue:)
123
+ # Materialize ONCE. `records` may be a lazy Enumerator or a Relation,
124
+ # and the targets and the enqueue both need to walk it — enumerating
125
+ # twice either exhausts a one-shot source (jobs silently never enqueue)
126
+ # or re-queries, so a concurrent write could make the marked rows and
127
+ # the enqueued jobs disagree.
128
+ list = materialize(records)
129
+ targets = resolve_targets(container, list, collection)
130
+
131
+ unless Phlex::Reactive.settle_capable?
132
+ warn_no_lane
133
+ run_enqueue(list, job, args, enqueue, nil)
134
+ return nil
135
+ end
136
+
137
+ handle = build_handle(container, collection, targets, peers)
138
+ with_handle(handle) { run_enqueue(list, job, args, enqueue, targets) }
139
+ Segment.new(handle:, target_ids: targets)
140
+ end
141
+
142
+ # One record, or an enumerable of them, as an Array — never re-walked.
143
+ def materialize(records)
144
+ list = records.is_a?(Enumerable) && !records.is_a?(String) ? records.to_a : [records]
145
+ raise ::ArgumentError, "reply.pending needs at least one target" if list.empty?
146
+
147
+ list
148
+ end
149
+
150
+ # The wire streams for one segment, in apply order: the per-target
151
+ # pending markers FIRST (so the UI stops lying immediately), then the
152
+ # single subscription directive.
153
+ def streams_for(segment)
154
+ streams = segment.target_ids.map { marker_stream(it) }
155
+ streams << marker_stream(segment.handle.anchor)
156
+ streams << directive_stream(segment.handle)
157
+ streams
158
+ end
159
+
160
+ # The deterministic id of the shared <pgbus-stream-source>. The client
161
+ # mints it as reactive-defer-src-<anchor>; a settle removes it by this
162
+ # exact id to tear the subscription down.
163
+ def source_id(anchor) = "reactive-defer-src-#{anchor}"
164
+
165
+ # Resolve the DOM id a row component would render for `model`, WITHOUT
166
+ # rendering it (build is cheap — #id must be render-context-free, that
167
+ # is the Streamable#id contract).
168
+ def row_dom_id(definition, model)
169
+ return model if model.is_a?(String)
170
+
171
+ definition.item.send(:build, model, {}).id
172
+ end
173
+
174
+ private
175
+
176
+ # Each pending target, as a DOM id. With `in:` the rows resolve through
177
+ # the collection declaration; without it every entry must already be a
178
+ # Streamable component (its own #id is the target).
179
+ def resolve_targets(container, list, collection)
180
+ if collection
181
+ definition = Phlex::Reactive::Collections.definition!(container, collection)
182
+ return list.map { row_dom_id(definition, it) }
183
+ end
184
+
185
+ list.map do
186
+ unless it.is_a?(Phlex::Reactive::Streamable)
187
+ raise ::ArgumentError,
188
+ "reply.pending(#{it.class}) cannot resolve a DOM target — name the collection " \
189
+ "the record lives in (reply.pending(record, in: :collection_name)) or pass a " \
190
+ "Streamable component instance (its #id is the target)"
191
+ end
192
+
193
+ it.id
194
+ end
195
+ end
196
+
197
+ def build_handle(container, collection, targets, peers)
198
+ Handle.new(
199
+ stream_key: one_shot_stream_key,
200
+ container_class: container.class.name,
201
+ container_payload: container.send(:reactive_identity_payload),
202
+ anchor: container.id,
203
+ collection: collection&.to_sym,
204
+ target_ids: targets,
205
+ peers: resolve_peers(container, peers),
206
+ connection_id: Phlex::Reactive.current_connection_id
207
+ )
208
+ end
209
+
210
+ # `peers: true` means the container's own record stream (the natural
211
+ # "everyone looking at this batch"); an Array is that one key's parts,
212
+ # passed through to broadcast_to(*streamables) verbatim. The parts are
213
+ # GlobalID-serialized so they survive the trip into the job.
214
+ def resolve_peers(container, peers)
215
+ return nil unless peers
216
+
217
+ parts =
218
+ if peers == true
219
+ record = container.class.reactive_record_ivar &&
220
+ container.instance_variable_get(container.class.reactive_record_ivar)
221
+ unless record
222
+ raise ::ArgumentError,
223
+ "reply.pending(peers: true) needs a record-backed container (reactive_record) — " \
224
+ "a state-backed one has no record stream, so pass the key parts explicitly: " \
225
+ "peers: [list, :todos]"
226
+ end
227
+
228
+ [record]
229
+ else
230
+ Array(peers)
231
+ end
232
+
233
+ parts.map { it.respond_to?(:to_gid) ? { "gid" => it.to_gid.to_s } : it.to_s }
234
+ end
235
+
236
+ # Run the caller's enqueue: an explicit block wins; otherwise the
237
+ # job:/args: sugar. Neither is also fine — an action may have enqueued
238
+ # its work before calling reply.pending (the handle is then unused,
239
+ # which is a mistake we cannot detect, so it is documented, not guessed
240
+ # at).
241
+ #
242
+ # The sugar enqueues ONE job PER RECORD, so each job gets a handle
243
+ # NARROWED to that record's target id. That precision matters on the
244
+ # failure path: a job that raises (or settles with nothing but a flash)
245
+ # must clear ITS row's pending markers without un-dimming the other 176
246
+ # rows that are still legitimately working. The BLOCK form cannot be
247
+ # narrowed — the gem has no way to map an arbitrary enqueue back to a
248
+ # record — so it carries the whole target list, which is the right
249
+ # reading of "these jobs settle these targets as one unit".
250
+ def run_enqueue(list, job, args, enqueue, targets)
251
+ return enqueue.call if enqueue
252
+ return unless job
253
+
254
+ case args
255
+ when nil then each_with_narrowed_handle(list, targets) { job.perform_later(it) }
256
+ when ::Proc
257
+ each_with_narrowed_handle(list, targets) { job.perform_later(*Array(args.call(it))) }
258
+ else
259
+ if list.size > 1
260
+ raise ::ArgumentError,
261
+ "reply.pending(job:, args: [...]) is ambiguous for #{list.size} records — pass a " \
262
+ "Proc (args: ->(record) { [record.id] }) so each job gets its own arguments"
263
+ end
264
+
265
+ each_with_narrowed_handle(list, targets) { job.perform_later(*Array(args)) }
266
+ end
267
+ end
268
+
269
+ # Yield each record with the ambient handle narrowed to that record's own
270
+ # target id. `targets` is nil on the no-lane path (no handle is installed
271
+ # at all), in which case this is a plain each.
272
+ def each_with_narrowed_handle(list, targets)
273
+ return list.each { yield(it) } unless targets
274
+
275
+ handle = current_handle
276
+ list.each_with_index do |record, index|
277
+ id = targets[index]
278
+ narrowed = id ? handle.with(target_ids: [id]) : handle
279
+ with_handle(narrowed) { yield(record) }
280
+ end
281
+ end
282
+
283
+ # Mark ONE element pending: data-reactive-pending + aria-busy, via the
284
+ # existing reactive:js op lane (@root-scoped to the target), so this
285
+ # needs no client change at all.
286
+ def marker_stream(target_id)
287
+ ops = Phlex::Reactive::JS.new
288
+ .set_attr(:root, PENDING_ATTR, "true")
289
+ .set_attr(:root, "aria-busy", "true")
290
+ Phlex::Reactive::Stream.wrap(
291
+ Phlex::Reactive::Response.js_stream(ops, target: target_id),
292
+ action: "reactive:js", target: target_id, renders_root: false
293
+ )
294
+ end
295
+
296
+ # The subscription directive — the SAME reactive:defer wire the push
297
+ # lane already speaks, so the client needs nothing new. Deliberately NO
298
+ # data-reactive-defer-token: that attribute is the client's degrade-to-
299
+ # fetch path, and a settle has no fetch lane (POSTing it would render
300
+ # the pre-job component, re-creating the exact bug this fixes).
301
+ def directive_stream(handle)
302
+ src = signed_stream_src(handle.stream_key)
303
+ html = %(<turbo-stream action="#{Phlex::Reactive::Defer::DIRECTIVE_ACTION}" \
304
+ target="#{ERB::Util.html_escape(handle.anchor)}" data-reactive-defer-via="stream" \
305
+ data-reactive-defer-src="#{ERB::Util.html_escape(src)}" data-reactive-defer-since-id="0"></turbo-stream>)
306
+ Phlex::Reactive::Stream.wrap(
307
+ html.html_safe, action: Phlex::Reactive::Defer::DIRECTIVE_ACTION,
308
+ target: handle.anchor, renders_root: false
309
+ )
310
+ end
311
+
312
+ # The key + src minting are Defer's, verbatim — one implementation of
313
+ # the pgbus queue-name budget and the signed SSE src, so the two lanes
314
+ # can never disagree about either. (Private on Defer, reached the same
315
+ # way the rest of the identity internals are off-instance.)
316
+ def one_shot_stream_key = Phlex::Reactive::Defer.send(:one_shot_stream_key)
317
+ def signed_stream_src(key) = Phlex::Reactive::Defer.send(:signed_stream_src, key)
318
+
319
+ # No settle lane is a CONFIG fact, not a per-reply event — warn once per
320
+ # process (a pending reply can fire per click; per-reply spam would bury
321
+ # the signal), then degrade to a plain enqueue.
322
+ def warn_no_lane
323
+ return if @no_lane_warned
324
+
325
+ @no_lane_warned = true
326
+ return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
327
+
328
+ ::Rails.logger.warn(
329
+ "[phlex-reactive] reply.pending needs the defer PUSH lane (pgbus reactive Streams + " \
330
+ "SignedName + ActiveJob, and defer_transport not forced to :fetch) — a settle has no " \
331
+ "pull fallback. Enqueuing without a settle handle; no pending markers were emitted."
332
+ )
333
+ end
334
+ end
335
+ end
336
+ end
337
+ end
@@ -129,6 +129,43 @@ module Phlex
129
129
  Response.build_streams(@component).defer(component, placeholder:, morph:)
130
130
  end
131
131
 
132
+ # Mark targets PENDING and let the app's own job settle them (issue #248).
133
+ # For an action that ENQUEUES the work rather than doing it: the endpoint
134
+ # renders the reply inside the transaction while the queue publishes on
135
+ # commit, so a `reply.morph` after an enqueue is guaranteed to draw the
136
+ # pre-job world. This replies truthfully instead.
137
+ #
138
+ # def re_execute(transfer_id:)
139
+ # transfer = @bulk_payment.transfers.re_executable.find(transfer_id)
140
+ # reply.pending(transfer, in: :unreconcilable, job: ReExecuteJob, args: [transfer.id])
141
+ # end
142
+ #
143
+ # def restore_all # the enqueue lives in a service
144
+ # count = 0
145
+ # reply.pending(restorable, in: :declined) { count = BatchRestoreService.call(...) }
146
+ # .flash(:notice, "Putting #{count} back…")
147
+ # end
148
+ #
149
+ # `records` is one record, an enumerable of them, or Streamable component
150
+ # instances. `in:` names the reactive_collection they live in (required
151
+ # for records — it is how their row DOM ids and the count/empty-state
152
+ # bookkeeping are resolved). The enqueue is a BLOCK (anything ActiveJob
153
+ # enqueued inside it captures the settle handle — including from a service
154
+ # object) or the `job:`/`args:` sugar. `peers: true` also broadcasts each
155
+ # settle to the container's record stream, so a second operator watching
156
+ # the same batch sees it; the default is actor-only, matching reply.defer.
157
+ #
158
+ # The bound component is the CONTAINER: it owns the collection declaration,
159
+ # the size resolver, and the subscription anchor. Its token is refreshed
160
+ # but it is NOT re-rendered.
161
+ #
162
+ # See Phlex::Reactive::Settles for the job side.
163
+ def pending(records, peers: false, job: nil, args: nil, **opts, &enqueue)
164
+ Response.build_pending(
165
+ @component, records, collection: Response.pending_collection!(opts), peers:, job:, args:, enqueue:
166
+ )
167
+ end
168
+
132
169
  private
133
170
 
134
171
  # Sentinel distinguishing "keyword omitted" from an explicit nil value, so