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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +127 -2
- data/README.md +268 -16
- data/app/controllers/phlex/reactive/actions_controller.rb +15 -1
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +1 -1
- data/app/javascript/phlex/reactive/confirm.min.js +2 -2
- data/app/javascript/phlex/reactive/confirm.min.js.map +1 -1
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +2 -2
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +1 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +1 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +139 -18
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/collections.rb +144 -0
- data/lib/phlex/reactive/component/helpers.rb +5 -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,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
|
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
|