phlex-reactive 0.13.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -7
- data/README.md +237 -5
- data/app/controllers/phlex/reactive/actions_controller.rb +15 -1
- data/lib/phlex/reactive/collections.rb +144 -0
- data/lib/phlex/reactive/pending.rb +337 -0
- data/lib/phlex/reactive/reply.rb +37 -0
- data/lib/phlex/reactive/response.rb +109 -68
- data/lib/phlex/reactive/settle.rb +170 -0
- data/lib/phlex/reactive/settles.rb +272 -0
- data/lib/phlex/reactive/streamable.rb +146 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +35 -0
- metadata +5 -1
|
@@ -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
|
data/lib/phlex/reactive/reply.rb
CHANGED
|
@@ -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 =
|
|
124
|
-
new(streams:
|
|
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 =
|
|
130
|
-
new(streams:
|
|
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 =
|
|
136
|
-
new(streams:
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|