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.
@@ -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
@@ -119,87 +119,64 @@ module Phlex
119
119
  # `effect:` (issue #215) stamps the ROW stream only — the count
120
120
  # companion and the empty-state toggle are bookkeeping, not the thing
121
121
  # entering/leaving.
122
+ # Issue #248: the bookkeeping BODIES moved to Phlex::Reactive::Collections
123
+ # so the job-side settle and the peers broadcast run the SAME code. These
124
+ # builders keep their exact behaviour by delegating.
122
125
  def build_collection_append(component, name, model, effect: nil, **row_kwargs)
123
- definition = collection_def!(component, name)
124
- new(streams: collection_add_streams(definition, component, model, :append, row_kwargs, effect:),
125
- render_self: false, token_component: component)
126
+ definition = Phlex::Reactive::Collections.definition!(component, name)
127
+ new(streams: Phlex::Reactive::Collections.add_streams(definition, component, model, :append, row_kwargs,
128
+ effect:), render_self: false, token_component: component)
126
129
  end
127
130
 
128
131
  def build_collection_prepend(component, name, model, effect: nil, **row_kwargs)
129
- definition = collection_def!(component, name)
130
- new(streams: collection_add_streams(definition, component, model, :prepend, row_kwargs, effect:),
131
- render_self: false, token_component: component)
132
+ definition = Phlex::Reactive::Collections.definition!(component, name)
133
+ new(streams: Phlex::Reactive::Collections.add_streams(definition, component, model, :prepend, row_kwargs,
134
+ effect:), render_self: false, token_component: component)
132
135
  end
133
136
 
134
137
  def build_collection_remove(component, name, model, effect: nil)
135
- definition = collection_def!(component, name)
136
- new(streams: collection_remove_streams(definition, component, model, effect:),
138
+ definition = Phlex::Reactive::Collections.definition!(component, name)
139
+ new(streams: Phlex::Reactive::Collections.remove_streams(definition, component, model, effect:),
137
140
  render_self: false, token_component: component)
138
141
  end
139
142
 
140
- private
141
-
142
- # Resolve the declaration off the container's class, raising a clear error
143
- # for an undeclared name (a typo'd collection should fail loudly, not
144
- # silently emit an empty Response).
145
- def collection_def!(component, name)
146
- component.class.reactive_collections[name.to_sym] ||
147
- raise(Phlex::Reactive::Error, "undeclared reactive_collection :#{name} on #{component.class}")
148
- end
149
-
150
- # Row add (append/prepend) + count + empty-state clear. The empty-state is
151
- # removed only when the list just crossed 0->1 (size == 1) — appending to
152
- # an already-populated list leaves it untouched.
153
- def collection_add_streams(definition, component, model, action, row_kwargs = {}, effect: nil)
154
- # row_kwargs (issue #186) thread to the row component's init via the class
155
- # stream builder's **options passthrough (ItemRow.new(model:, **row_kwargs)).
156
- streams = [definition.item.public_send(action, target: definition.container, model:, effect:, **row_kwargs)]
157
- append_count_stream(streams, definition, component)
158
-
159
- size = definition.size_for(component)
160
- streams << definition.empty.new.to_stream_remove if definition.empty && size == 1
161
- streams
162
- end
163
-
164
- # Row remove + count + empty-state restore. The empty-state is appended
165
- # back into the container only when the list just emptied (size == 0).
166
- def collection_remove_streams(definition, component, model, effect: nil)
167
- streams = [collection_row_remove(definition, model, effect)]
168
- append_count_stream(streams, definition, component)
169
-
170
- size = definition.size_for(component)
171
- if definition.empty && size&.zero?
172
- # Render the empty-state and append it INTO the container (not its
173
- # own id) — restoring "No items yet" when the last row was removed.
174
- # model: nil builds it argument-free (an empty-state is a static view).
175
- streams << definition.empty.append(target: definition.container, model: nil)
143
+ # --- Async-action lifecycle (issue #248) ---
144
+ # Mark targets pending and hand the fulfilment to the app's own job. The
145
+ # reply deliberately does NOT re-render the container: re-rendering it
146
+ # here would draw the PRE-JOB world (the endpoint renders inside the
147
+ # transaction; the queue publishes on commit) — the exact bug this verb
148
+ # exists to fix. render_self is therefore false, with the container as
149
+ # token_component so the signed token still rolls forward (cosmos#1939 —
150
+ # without it the list is act-once-only).
151
+ #
152
+ # The Response only RECORDS the segment; the ENDPOINT turns it into the
153
+ # marker + directive streams after the transaction committed.
154
+ # Pull `in:` out of reply.pending's **opts and REFUSE anything left over.
155
+ # `in` is a Ruby keyword, so it cannot be a named parameter — which means
156
+ # every other keyword lands in **opts and would otherwise be silently
157
+ # dropped. A typo (`jbo:` for `job:`) would then mark the rows pending
158
+ # and enqueue NOTHING: a permanently shimmering row, the exact failure
159
+ # this feature exists to prevent. So it fails at the call site instead.
160
+ def pending_collection!(opts)
161
+ collection = opts.delete(:in)
162
+ unless opts.empty?
163
+ raise ArgumentError,
164
+ "reply.pending got unknown keyword(s) #{opts.keys.map(&:inspect).join(", ")} — " \
165
+ "it takes in:, job:, args:, peers: and a block. A dropped keyword would mark the " \
166
+ "targets pending with nothing to settle them."
176
167
  end
177
- streams
178
- end
179
168
 
180
- # Remove the row by its DOM id. Accepts the record (so dom_id is derived)
181
- # or an already-built dom-id string (e.g. the value the row used as #id).
182
- def collection_row_remove(definition, model, effect = nil)
183
- if model.is_a?(String)
184
- Phlex::Reactive::Effects.annotate(Phlex::Reactive.stream_builder.remove(model), effect)
185
- else
186
- definition.item.remove(model, effect:)
187
- end
169
+ collection
188
170
  end
189
171
 
190
- # Append the count companion's update stream when a count id + a size
191
- # resolver are both declared. The size is a number, HTML-escaped by Turbo.
192
- def append_count_stream(streams, definition, component)
193
- return unless definition.count
194
-
195
- size = definition.size_for(component)
196
- return if size.nil?
197
-
198
- streams << update_stream(definition.count, size.to_s)
172
+ def build_pending(component, records, collection:, peers:, job:, args:, enqueue:)
173
+ segment = Phlex::Reactive::Pending.build_segment(
174
+ component, records, collection:, peers:, job:, args:, enqueue:
175
+ )
176
+ new(streams: [], render_self: false, token_component: component,
177
+ pending_segments: segment ? [segment] : NO_SEGMENTS)
199
178
  end
200
179
 
201
- public
202
-
203
180
  # Partial / per-field update with a TOKEN-ONLY refresh (issue #30). Emits
204
181
  # EXACTLY the given streams — no forced full-self replace — but binds
205
182
  # `component` so the endpoint appends its tiny `to_stream_token` stream.
@@ -376,13 +353,14 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
376
353
  NO_SEGMENTS = [].freeze
377
354
 
378
355
  def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil,
379
- subject_component: nil, deferred_segments: NO_SEGMENTS)
356
+ subject_component: nil, deferred_segments: NO_SEGMENTS, pending_segments: NO_SEGMENTS)
380
357
  @streams = streams.freeze
381
358
  @redirect_url = redirect_url
382
359
  @render_self = render_self
383
360
  @token_component = token_component
384
361
  @subject_component = subject_component
385
362
  @deferred_segments = deferred_segments.freeze
363
+ @pending_segments = pending_segments.freeze
386
364
  freeze
387
365
  end
388
366
 
@@ -394,6 +372,13 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
394
372
 
395
373
  def deferred? = !@deferred_segments.empty?
396
374
 
375
+ # The recorded reply.pending segments (issue #248), in call order. Same
376
+ # contract as deferred_segments: recorded here, turned into wire streams
377
+ # by the endpoint AFTER the transaction committed.
378
+ attr_reader :pending_segments
379
+
380
+ def pending? = !@pending_segments.empty?
381
+
397
382
  # Append extra turbo-stream strings (a sibling component, a flash).
398
383
  # Returns a NEW Response (immutable).
399
384
  def stream(*more)
@@ -403,7 +388,8 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
403
388
  render_self: @render_self,
404
389
  token_component: @token_component,
405
390
  subject_component: @subject_component,
406
- deferred_segments: @deferred_segments
391
+ deferred_segments: @deferred_segments,
392
+ pending_segments: @pending_segments
407
393
  )
408
394
  end
409
395
 
@@ -434,7 +420,52 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
434
420
  token_component: @token_component,
435
421
  subject_component: @subject_component,
436
422
  deferred_segments: @deferred_segments +
437
- [Phlex::Reactive::Defer::Segment.new(component:, placeholder:, morph:)]
423
+ [Phlex::Reactive::Defer::Segment.new(component:, placeholder:, morph:)],
424
+ pending_segments: @pending_segments
425
+ )
426
+ end
427
+
428
+ # Chain reply.pending onto an existing reply (issue #248), so an action can
429
+ # do one synchronous thing AND mark other targets pending:
430
+ #
431
+ # reply.replace.pending(rows, in: :declined, job: RestoreJob)
432
+ #
433
+ # Dead on a redirect, exactly like #defer: the client is navigating away,
434
+ # so the settle could never land.
435
+ def pending(records, peers: false, job: nil, args: nil, **opts, &enqueue)
436
+ if redirect?
437
+ raise Phlex::Reactive::Error,
438
+ "reply.pending on a redirect reply is dead — the client is navigating away, " \
439
+ "so the settle could never land"
440
+ end
441
+
442
+ subject = pending_subject!
443
+ # ONE subscription per anchor. Every pending segment on the same
444
+ # container emits a directive targeting that container's id, and the
445
+ # client keys its in-flight subscriptions BY TARGET — so a second
446
+ # directive supersedes the first, silently orphaning the jobs the first
447
+ # call enqueued. Two pending calls on one container in one reply is a
448
+ # call-site mistake; make it a loud one.
449
+ if @pending_segments.any? { it.handle.anchor == subject.id }
450
+ raise Phlex::Reactive::Error,
451
+ "reply.pending was called twice for #{subject.class} (##{subject.id}) — the second " \
452
+ "subscription would supersede the first on the client, so the first call's jobs could " \
453
+ "never settle. Mark every target in ONE reply.pending call (the settle names its own " \
454
+ "collection: s.remove(record, from: :name))."
455
+ end
456
+
457
+ built = self.class.build_pending(
458
+ subject, records, collection: self.class.pending_collection!(opts),
459
+ peers:, job:, args:, enqueue:
460
+ )
461
+ self.class.new(
462
+ streams: @streams,
463
+ redirect_url: @redirect_url,
464
+ render_self: @render_self,
465
+ token_component: @token_component || built.token_component,
466
+ subject_component: @subject_component,
467
+ deferred_segments: @deferred_segments,
468
+ pending_segments: @pending_segments + built.pending_segments
438
469
  )
439
470
  end
440
471
 
@@ -537,6 +568,16 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
537
568
 
538
569
  private
539
570
 
571
+ # The container a chained .pending marks: the component this reply is
572
+ # already bound to. A subject-free reply (reply.with) has none — chaining
573
+ # .pending onto it is a call-site mistake, so it fails loudly.
574
+ def pending_subject!
575
+ (@subject_component || @token_component) ||
576
+ raise(Phlex::Reactive::Error,
577
+ "reply.pending needs a bound component (the container that owns the collection) — " \
578
+ "chain it off a component verb (reply.replace.pending(...)) or call reply.pending(...) directly")
579
+ end
580
+
540
581
  # The default `target` for #js: the bound component's id when this reply is
541
582
  # component-scoped (replace/morph/update set subject_component; .streams and
542
583
  # collections set token_component), else nil — a subject-free reply.with is