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.
@@ -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
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The job-side reply builder (issue #248) — what `reactive_settle` yields.
6
+ #
7
+ # reactive_settle do |s|
8
+ # if result.success?
9
+ # s.remove(transfer, from: :unreconcilable)
10
+ # else
11
+ # s.replace(transfer)
12
+ # s.flash(:alert, result.error)
13
+ # end
14
+ # end
15
+ #
16
+ # The verbs mirror `reply.*` and, critically, route through the SAME
17
+ # Phlex::Reactive::Collections decisions — so a job-side append emits the
18
+ # row AND the count companion AND the 0<->1 empty-state toggle, instead of
19
+ # the bare row `broadcast_to(append:)` gives you. That shared bookkeeping is
20
+ # the whole point: it is what the app can no longer get subtly wrong.
21
+ #
22
+ # Unlike Response, Settle is a MUTABLE accumulator, not a value object: a
23
+ # settle block is imperative (branch on the work's result, add a flash) and
24
+ # every verb returns self so the calls can also be chained.
25
+ #
26
+ # It collects streams; Phlex::Reactive::Settles owns the delivery — one
27
+ # durable message to the actor's shared one-shot stream, plus the optional
28
+ # peers broadcast.
29
+ class Settle
30
+ attr_reader :streams
31
+
32
+ # The rebuilt container — Settles reads it for the peers broadcast (it
33
+ # carries the collection declaration and the size resolver).
34
+ attr_reader :container
35
+
36
+ # `container` is the rebuilt container component (from_identity, off the
37
+ # request thread); `handle` is the Pending::Handle the enqueue captured.
38
+ def initialize(container, handle)
39
+ @container = container
40
+ @handle = handle
41
+ @streams = []
42
+ @peer_ops = []
43
+ end
44
+
45
+ # Re-render ONE row in place. The work ran and the row is simply different
46
+ # now (a failed re-execution going back to actionable, say). No count or
47
+ # empty-state stream: a replace cannot change the size.
48
+ #
49
+ # Accepts a record (resolved through the collection's row component) or a
50
+ # built Streamable component (its own #id is the target).
51
+ def replace(model, morph: false, effect: nil, **row_kwargs)
52
+ if model.is_a?(Phlex::Reactive::Streamable)
53
+ @streams << model.to_stream_replace(morph:, effect:)
54
+ # A built component carries its own identity, so peers can be handed
55
+ # the same instance — no definition needed.
56
+ @peer_ops << [nil, :replace, model, {}] if @handle.peers
57
+ return self
58
+ end
59
+
60
+ definition = definition!(nil)
61
+ @streams.concat(Phlex::Reactive::Collections.replace_streams(definition, model, effect:, **row_kwargs))
62
+ # A replace is NOT a collection delta (it moves no boundary), but peers
63
+ # still need it: without this, a failed re-execution goes back to
64
+ # actionable for the actor while every other operator keeps the stale
65
+ # row. It rides the ordinary row broadcast, not broadcast_collection_to.
66
+ peer(definition, :replace, model, row_kwargs)
67
+ self
68
+ end
69
+
70
+ # Remove a row: the row + the count companion + the empty-state restore at
71
+ # the 1->0 boundary. `from:` defaults to the collection reply.pending
72
+ # named, so the common case reads `s.remove(transfer)`.
73
+ def remove(model, from: nil, effect: nil)
74
+ definition = definition!(from)
75
+ @streams.concat(Phlex::Reactive::Collections.remove_streams(definition, @container, model, effect:))
76
+ peer(definition, :remove, model, {})
77
+ self
78
+ end
79
+
80
+ # Add a row: the row + the count companion + the empty-state clear at the
81
+ # 0->1 boundary.
82
+ def append(model, to: nil, effect: nil, **row_kwargs)
83
+ add(:append, model, to, effect, row_kwargs)
84
+ end
85
+
86
+ def prepend(model, to: nil, effect: nil, **row_kwargs)
87
+ add(:prepend, model, to, effect, row_kwargs)
88
+ end
89
+
90
+ # The case that motivated the whole issue: the work moved a record between
91
+ # two lists. Ordered remove-then-append so the size resolvers run against
92
+ # the post-move world in the order a reader expects, and so a row can
93
+ # never be momentarily present in both containers.
94
+ def move(model, from:, to:, effect: nil, **row_kwargs)
95
+ remove(model, from:, effect:)
96
+ append(model, to:, effect:, **row_kwargs)
97
+ end
98
+
99
+ # Refresh ONLY the collection's count companion — for a settle that
100
+ # changed the size without adding or removing a visible row. `name`
101
+ # defaults to the collection reply.pending named.
102
+ def count(name = nil)
103
+ @streams.concat(Phlex::Reactive::Collections.count_streams(definition!(name), @container))
104
+ self
105
+ end
106
+
107
+ # Tell the operator what happened. The job is where the OUTCOME is known,
108
+ # so this is how the page stops saying "Queued" forever.
109
+ def flash(level, content, target: Phlex::Reactive.flash_target, dismiss_after: nil)
110
+ @streams << Phlex::Reactive::Response.send(:flash_stream, level, content, target:, dismiss_after:)
111
+ self
112
+ end
113
+
114
+ # Server-pushed client DOM ops, same vocabulary as reply.js. Defaults to
115
+ # the container's id so self-scoped ops just work.
116
+ def js(ops, target: :__default)
117
+ resolved = target == :__default ? @handle.anchor : target
118
+ @streams << Phlex::Reactive::Response.js_stream(ops, target: resolved)
119
+ self
120
+ end
121
+
122
+ # Escape hatch: raw <turbo-stream> strings, exactly like reply.streams.
123
+ def streams!(*more)
124
+ @streams.concat(more.flatten)
125
+ self
126
+ end
127
+
128
+ # The peer deltas recorded alongside the actor's streams (issue #248).
129
+ # Collected rather than broadcast inline so Settles can send them AFTER
130
+ # the actor's message — the actor paid for the click and should not wait
131
+ # behind a fan-out of channel calls.
132
+ attr_reader :peer_ops
133
+
134
+ private
135
+
136
+ def add(action, model, name, effect, row_kwargs)
137
+ definition = definition!(name)
138
+ @streams.concat(
139
+ Phlex::Reactive::Collections.add_streams(definition, @container, model, action, row_kwargs, effect:)
140
+ )
141
+ peer(definition, action, model, row_kwargs)
142
+ self
143
+ end
144
+
145
+ # Resolve the collection: the explicit keyword, else the one reply.pending
146
+ # named. A settle with neither is a call-site mistake — fail loudly rather
147
+ # than silently emit nothing.
148
+ def definition!(name)
149
+ resolved = name || @handle.collection
150
+ unless resolved
151
+ raise Phlex::Reactive::Error,
152
+ "this settle has no collection to work on — name it (s.remove(record, from: :items)) " \
153
+ "or pass in: to reply.pending so the settle inherits it"
154
+ end
155
+
156
+ Phlex::Reactive::Collections.definition!(@container, resolved)
157
+ end
158
+
159
+ # Record a peer delta (only when reply.pending asked for peers:). The row
160
+ # kwargs ride along: a peer whose row component has a required init kwarg
161
+ # would otherwise raise, and one with an optional kwarg would render
162
+ # different markup than the actor got.
163
+ def peer(definition, action, model, row_kwargs)
164
+ return unless @handle.peers
165
+
166
+ @peer_ops << [definition.name, action, model, row_kwargs]
167
+ end
168
+ end
169
+ end
170
+ end