phlex-reactive 0.3.0 → 0.4.0

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.
@@ -8,9 +8,12 @@ import { Controller } from "@hotwired/stimulus"
8
8
  // (replace by default; method="morph" — Response.morph — preserves focus).
9
9
  //
10
10
  // Wire format (client -> server), POST <action path>, turbo-stream Accept:
11
- // { token: "<signed identity>", act: "<action>", params: {...} }
11
+ // { token: "<signed identity>", act: "<action>", params: {...} } (JSON)
12
12
  // (`act`, not `action`: `action` is a reserved Rails routing param.)
13
13
  // The token is a MessageVerifier-signed { component, gid } — NO state is sent.
14
+ // When the root holds a chosen <input type="file">, the SAME payload is sent as
15
+ // multipart FormData instead (token/act flat, params bracketed, files appended)
16
+ // so an upload reaches the action (issue #34) — only the encoding differs.
14
17
  // The response is a <turbo-stream> that replaces the component by its id.
15
18
  //
16
19
  // Server -> client live updates use the SAME element id, pushed over the
@@ -119,6 +122,7 @@ export default class extends Controller {
119
122
 
120
123
  #tokenCache // freshest token, threaded synchronously across queued requests
121
124
  #debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
125
+ #actionPathCache // page-stable action path, resolved once per controller
122
126
 
123
127
  // Mark that a reactive controller actually connected, so the registration
124
128
  // guard above knows the controller was registered (issue #26 part 2).
@@ -202,24 +206,34 @@ export default class extends Controller {
202
206
 
203
207
  async #perform(action, params) {
204
208
  // Auto-collect named field values inside this component so a button-
205
- // triggered action still receives sibling inputs (Livewire-style).
206
- // Explicit params (data-reactive-params-param) win over collected fields.
207
- const fieldParams = this.#collectFields()
208
-
209
- const body = JSON.stringify({
210
- token: this.#currentToken,
211
- act: action,
212
- params: { ...fieldParams, ...this.#parseParams(params) },
213
- })
209
+ // triggered action still receives sibling inputs (Livewire-style), plus any
210
+ // chosen file inputs in the SAME walk. Explicit params
211
+ // (data-reactive-params-param) win over collected fields.
212
+ const { fields, files } = this.#collectFields()
213
+ const allParams = { ...fields, ...this.#parseParams(params) }
214
+ const token = this.#currentToken
215
+
216
+ // File/multipart path (issue #34): if THIS root has a populated
217
+ // <input type="file">, the action can't be JSON (JSON.stringify drops the
218
+ // File). Send FormData instead — token + act + scalar params as fields, each
219
+ // chosen file appended. The morph/token machinery downstream is identical;
220
+ // only the request ENCODING differs when files are present.
221
+ const multipart = files.length > 0
222
+ const body = multipart
223
+ ? this.#buildFormData(token, action, allParams, files)
224
+ : JSON.stringify({ token, act: action, params: allParams })
214
225
 
215
226
  this.element.setAttribute("aria-busy", "true")
216
227
 
217
228
  try {
218
229
  const headers = {
219
- "Content-Type": "application/json",
220
230
  Accept: "text/vnd.turbo-stream.html",
221
231
  "X-CSRF-Token": this.#csrfToken(),
222
232
  }
233
+ // For JSON we declare the content type; for multipart we must NOT — the
234
+ // browser sets `multipart/form-data; boundary=…` itself, and overriding it
235
+ // would strip the boundary and corrupt the body server-side.
236
+ if (!multipart) headers["Content-Type"] = "application/json"
223
237
  // Send the pgbus SSE connection id (if subscribed) so the server can
224
238
  // exclude this connection from its own broadcast echo — the actor
225
239
  // already gets the action's HTTP response. Harmless without pgbus.
@@ -287,12 +301,22 @@ export default class extends Controller {
287
301
  return el.closest('[data-controller~="reactive"]') === this.element
288
302
  }
289
303
 
304
+ // One walk over THIS root's named controls (not a nested reactive root's),
305
+ // returning both the scalar `fields` and any chosen `files`. A file input's
306
+ // `.value` is the useless "C:\fakepath\…" string, never a scalar — so its
307
+ // chosen files are collected separately (honoring `multiple`) and it adds no
308
+ // phantom blank value (issue #34). An empty `files` keeps the JSON path.
290
309
  #collectFields() {
291
310
  const fields = {}
292
- // Standard form controls owned by THIS root (not a nested reactive root).
311
+ const files = []
293
312
  this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
294
313
  if (!this.#ownsField(field)) return
295
- if (field.type === "checkbox") {
314
+ if (field.type === "file") {
315
+ // Carry the input's `multiple` flag so #buildFormData keeps the array
316
+ // shape (params[name][]) even when the user picked exactly one file —
317
+ // otherwise a [:file] schema would see a lone scalar upload and drop it.
318
+ for (const file of field.files ?? []) files.push({ name: field.name, file, multiple: field.multiple })
319
+ } else if (field.type === "checkbox") {
296
320
  fields[field.name] = field.checked
297
321
  } else if (field.type === "radio") {
298
322
  if (field.checked) fields[field.name] = field.value
@@ -320,7 +344,71 @@ export default class extends Controller {
320
344
  fields[name] = el.value ?? el.textContent ?? el.innerHTML ?? ""
321
345
  }
322
346
  })
323
- return fields
347
+ return { fields, files }
348
+ }
349
+
350
+ // Build the multipart body (issue #34). `token`/`act` are flat fields the
351
+ // endpoint reads from params[:token]/params[:act]; scalar params nest under
352
+ // params[<key>] (Rails parses the bracket into params[:params]); each file is
353
+ // appended under params[<name>] (single) — a second file with the same name
354
+ // (a `multiple` picker, several inputs sharing a name) is sent as
355
+ // params[<name>][] so Rails coerces it to an array for a [:file] schema.
356
+ #buildFormData(token, action, params, files) {
357
+ const fd = new FormData()
358
+ fd.append("token", token)
359
+ fd.append("act", action)
360
+ for (const [key, value] of Object.entries(params)) {
361
+ this.#appendField(fd, `params[${key}]`, value)
362
+ }
363
+ const multiNames = this.#multiFileNames(files)
364
+ for (const { name, file, multiple } of files) {
365
+ // params[name][] when the input is `multiple` (array shape even for one
366
+ // file) OR the name repeats across inputs; otherwise a lone scalar file.
367
+ const asArray = multiple || multiNames.has(name)
368
+ const key = asArray ? `params[${name}][]` : `params[${name}]`
369
+ fd.append(key, file, file.name)
370
+ }
371
+ return fd
372
+ }
373
+
374
+ // Append a param leaf to FormData under its bracketed key. FormData carries
375
+ // only strings, so a NON-scalar param (a nested object or an array) is
376
+ // bracket-EXPANDED into params[key][sub] / params[key][index][...] fields —
377
+ // the SAME Rails-form shape the server's expand_bracket_keys / array_values
378
+ // already parse, so a JSON body and a multipart body coerce identically
379
+ // (issue #39). Previously a non-scalar was JSON.stringify'd into one
380
+ // params[key]='<json>' field, which the server received as an un-decodable
381
+ // String leaf and DROPPED (nested hash -> {}, array -> key removed).
382
+ //
383
+ // Arrays use NUMERIC indices (params[key][0], params[key][1]) — required for
384
+ // an array-of-hash so each element's sub-keys stay grouped and the server's
385
+ // index-hash sort rebuilds order; params[key][] would collapse them. A scalar
386
+ // (string/number/boolean) is one string field, mirroring the JSON wire shape
387
+ // (the server's :boolean/:integer casts read "true"/"42"). null/undefined is
388
+ // an empty field. An EMPTY array/object emits NOTHING — FormData can't carry
389
+ // []/{}, so the key is omitted and the action's keyword default applies (this
390
+ // differs from the JSON path, where an explicit [] coerces to an empty array).
391
+ #appendField(fd, key, value) {
392
+ if (value == null) {
393
+ fd.append(key, "")
394
+ } else if (Array.isArray(value)) {
395
+ value.forEach((element, index) => this.#appendField(fd, `${key}[${index}]`, element))
396
+ } else if (typeof value === "object") {
397
+ for (const [subKey, subValue] of Object.entries(value)) {
398
+ this.#appendField(fd, `${key}[${subKey}]`, subValue)
399
+ }
400
+ } else {
401
+ fd.append(key, String(value))
402
+ }
403
+ }
404
+
405
+ // Names that appear more than once across the chosen files (a `multiple`
406
+ // picker, or several file inputs sharing a name) — those go to params[name][]
407
+ // so the server sees an array; a lone file stays params[name].
408
+ #multiFileNames(files) {
409
+ const counts = new Map()
410
+ for (const { name } of files) counts.set(name, (counts.get(name) ?? 0) + 1)
411
+ return new Set([...counts].filter(([, n]) => n > 1).map(([name]) => name))
324
412
  }
325
413
 
326
414
  #parseParams(raw) {
@@ -332,13 +420,21 @@ export default class extends Controller {
332
420
  }
333
421
  }
334
422
 
423
+ // The action path comes from a <meta> tag that is fixed for the page's life,
424
+ // so resolve it once per controller and cache it — avoids a querySelector on
425
+ // every dispatch (this runs on the request hot path, once per click/keystroke
426
+ // round trip). Cached on the instance, so a fresh connect() (after a Turbo
427
+ // navigation swaps the element) re-reads it.
335
428
  #actionPath() {
336
- return (
429
+ return (this.#actionPathCache ??=
337
430
  document.querySelector('meta[name="phlex-reactive-action-path"]')?.content ||
338
- "/reactive/actions"
339
- )
431
+ "/reactive/actions")
340
432
  }
341
433
 
434
+ // CSRF token and connection id are read LIVE (not cached) on purpose: Rails
435
+ // can rotate the CSRF token, and the pgbus connection id changes on an SSE
436
+ // reconnect — caching either would send a stale value. A single querySelector
437
+ // per request is cheap next to the round trip itself.
342
438
  #csrfToken() {
343
439
  return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
344
440
  }
@@ -29,8 +29,8 @@ module Phlex
29
29
  index = stimulus_index_path
30
30
 
31
31
  unless index
32
- say_status :skip, "no Stimulus controllers entrypoint found — register manually:\n" \
33
- " #{IMPORT_LINE}\n #{REGISTER_LINE}", :yellow
32
+ say_status :skip, "no Stimulus controllers entrypoint found — register manually:\n " \
33
+ "#{IMPORT_LINE}\n #{REGISTER_LINE}", :yellow
34
34
  return
35
35
  end
36
36
 
@@ -44,9 +44,9 @@ module Phlex
44
44
  end
45
45
 
46
46
  # Fallback: if the standard import line wasn't found, append both lines.
47
- unless File.read(index).include?(REGISTER_LINE)
48
- append_to_file index, "\n#{IMPORT_LINE}\n#{REGISTER_LINE}\n"
49
- end
47
+ return if File.read(index).include?(REGISTER_LINE)
48
+
49
+ append_to_file index, "\n#{IMPORT_LINE}\n#{REGISTER_LINE}\n"
50
50
  end
51
51
 
52
52
  def show_post_install
@@ -69,7 +69,7 @@ module Phlex
69
69
  %w[
70
70
  app/javascript/controllers/index.js
71
71
  app/javascript/controllers/application.js
72
- ].map { |p| File.join(destination_root, p) }.find { |p| File.exist?(p) }
72
+ ].map { File.join(destination_root, it) }.find { File.exist?(it) }
73
73
  end
74
74
 
75
75
  def relative(path)
@@ -62,12 +62,31 @@ module Phlex
62
62
  # A declared, client-invokable action and its param schema.
63
63
  Action = Data.define(:name, :params)
64
64
 
65
+ # A declared add/remove-row collection (issue #35): the list contract tied
66
+ # into one unit — the per-row item component, the container DOM id rows
67
+ # live in, an optional companion count id, an optional empty-state
68
+ # component, and a size resolver (a proc evaluated against the container
69
+ # instance) used to re-render the count and toggle the empty-state at the
70
+ # 0<->1 boundary. count/empty/size are nil when not declared, and the
71
+ # corresponding stream is simply omitted — so a list with just rows still
72
+ # works.
73
+ CollectionDefinition = Data.define(:name, :item, :container, :count, :empty, :size) do
74
+ # The collection's current size, evaluated against the bound container
75
+ # instance (instance_exec so the proc reads the component's ivars /
76
+ # association). nil when no resolver was declared — callers skip the
77
+ # count/empty streams that need a number.
78
+ def size_for(component)
79
+ size && component.instance_exec(&size)
80
+ end
81
+ end
82
+
65
83
  class_methods do
66
84
  # Declare the ActiveRecord (GlobalID-able) record this component is
67
85
  # rebuilt from. The signed token carries its GlobalID; the server
68
86
  # re-finds it on each action. State lives in the DB.
69
87
  def reactive_record(name)
70
88
  @reactive_record_key = name.to_sym
89
+ remove_instance_variable(:@reactive_record_ivar) if defined?(@reactive_record_ivar)
71
90
  end
72
91
 
73
92
  def reactive_record_key
@@ -80,6 +99,7 @@ module Phlex
80
99
  # reactive_state :count, :open
81
100
  def reactive_state(*names)
82
101
  reactive_state_keys.concat(names.map(&:to_sym))
102
+ @reactive_state_ivars = nil # rebuild the cached [key, ivar] pairs
83
103
  end
84
104
 
85
105
  def reactive_state_keys
@@ -120,6 +140,59 @@ module Phlex
120
140
  reactive_actions.key?(name.to_sym)
121
141
  end
122
142
 
143
+ # Declare an add/remove-row collection (issue #35) — the list contract
144
+ # in one place, so actions append/prepend/remove a row WITHOUT
145
+ # re-deriving the container id, count, and empty-state in every action:
146
+ #
147
+ # reactive_collection :items,
148
+ # item: ItemRowComponent, # the per-row Streamable component
149
+ # container: "items-list", # the DOM id rows live in
150
+ # count: "items-count", # optional companion id (the size badge)
151
+ # empty: ItemsEmptyComponent, # optional empty-state component
152
+ # size: -> { @record.items.size } # resolves the live size
153
+ #
154
+ # An action then governs the reply with one call:
155
+ # reply.append(:items, item) # row append + count + clear empty-state
156
+ # reply.remove(:items, id) # row remove + count + restore empty-state
157
+ #
158
+ # count/empty/size are optional: a list with just rows omits them and the
159
+ # corresponding stream isn't emitted. See Phlex::Reactive::Reply and the
160
+ # README "Reactive collections" section.
161
+ def reactive_collection(name, item:, container:, count: nil, empty: nil, size: nil)
162
+ reactive_collections[name.to_sym] =
163
+ CollectionDefinition.new(name: name.to_sym, item:, container:, count:, empty:, size:)
164
+ end
165
+
166
+ def reactive_collections
167
+ @reactive_collections ||= superclass.respond_to?(:reactive_collections) ? superclass.reactive_collections.dup : {}
168
+ end
169
+
170
+ def reactive_collection_def(name)
171
+ reactive_collections[name.to_sym]
172
+ end
173
+
174
+ def reactive_collection?(name)
175
+ reactive_collections.key?(name.to_sym)
176
+ end
177
+
178
+ # The record's instance-variable symbol (e.g. :@todo), computed once.
179
+ # reactive_token reads it on every render; interpolating :"@#{key}" each
180
+ # time would allocate a symbol per render. Nil when record-less. Memoized
181
+ # per class; reset alongside reactive_record so it can't go stale.
182
+ def reactive_record_ivar
183
+ return @reactive_record_ivar if defined?(@reactive_record_ivar)
184
+
185
+ @reactive_record_ivar = reactive_record_key ? :"@#{reactive_record_key}" : nil
186
+ end
187
+
188
+ # [string_key, ivar_symbol] pairs for the signed state, computed once.
189
+ # reactive_token walks these every render; precomputing the "count"/:@count
190
+ # forms avoids a String + Symbol allocation per key per render. Memoized
191
+ # per class; reset when reactive_state adds a key.
192
+ def reactive_state_ivars
193
+ @reactive_state_ivars ||= reactive_state_keys.map { [it.to_s, :"@#{it}"] }
194
+ end
195
+
123
196
  # Rebuild a component instance from a verified identity payload. Called
124
197
  # by the action endpoint after the token signature is verified.
125
198
  #
@@ -139,14 +212,14 @@ module Phlex
139
212
 
140
213
  if reactive_state_keys.any?
141
214
  state = payload.fetch("s", {})
142
- reactive_state_keys.each do |key|
215
+ reactive_state_keys.each do
143
216
  # Use key presence, not the value: a signed `nil` (nullable state)
144
217
  # must round-trip distinctly. Only a genuinely absent key falls
145
218
  # back to the component's initialize default; `false` and `nil`
146
219
  # both survive.
147
- next unless state.key?(key.to_s)
220
+ next unless state.key?(it.to_s)
148
221
 
149
- kwargs[key] = state[key.to_s]
222
+ kwargs[it] = state[it.to_s]
150
223
  end
151
224
  end
152
225
 
@@ -211,12 +284,18 @@ module Phlex
211
284
  # live-update-as-you-type doesn't POST per keystroke. A blur flushes a
212
285
  # pending dispatch so the last edit is never dropped. Omit it for the
213
286
  # immediate-dispatch default.
287
+ # The verbatim JSON for an empty explicit-params payload. The common
288
+ # trigger (on(:increment), no params) hits this on EVERY render — skipping
289
+ # params.to_json (which re-serializes {} to the same "{}" each time) avoids
290
+ # a per-render allocation while keeping the wire format byte-identical.
291
+ EMPTY_PARAMS_JSON = "{}"
292
+
214
293
  def on(action_name, event: "click", debounce: nil, **params)
215
294
  attrs = {
216
295
  data: {
217
296
  action: "#{event}->reactive#dispatch",
218
297
  reactive_action_param: action_name.to_s,
219
- reactive_params_param: params.to_json
298
+ reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
220
299
  }
221
300
  }
222
301
  attrs[:data][:reactive_debounce_param] = debounce if debounce
@@ -234,7 +313,7 @@ module Phlex
234
313
  # hatch). The trigger (on(:save)) stays on the button, not the field — so
235
314
  # focusing the input doesn't dispatch and collapse edit mode.
236
315
  def reactive_field(param, **attrs)
237
- {name: param.to_s, **attrs}
316
+ { name: param.to_s, **attrs }
238
317
  end
239
318
 
240
319
  # Render an <input> already bound to an action param (issue #23). Sugar for
@@ -248,8 +327,8 @@ module Phlex
248
327
  # is the element's content, so the awkward FormBuilder positional split
249
328
  # (where name: lands after the options/html-options args) goes away:
250
329
  # reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
251
- def reactive_select(param, **attrs, &block)
252
- select(**reactive_field(param, **attrs), &block)
330
+ def reactive_select(param, **attrs, &)
331
+ select(**reactive_field(param, **attrs), &)
253
332
  end
254
333
 
255
334
  # Map a declared nested param onto Rails' <assoc>_attributes, carrying the
@@ -265,7 +344,7 @@ module Phlex
265
344
  existing = reactive_record_for_nested.public_send(association)
266
345
  merged[:id] = existing.id if existing
267
346
 
268
- {"#{association}_attributes": merged}
347
+ { "#{association}_attributes": merged }
269
348
  end
270
349
 
271
350
  # Map a nested param onto <assoc>_attributes (with id preservation) AND
@@ -283,9 +362,7 @@ module Phlex
283
362
  # record-backed component).
284
363
  def reactive_record_for_nested
285
364
  key = self.class.reactive_record_key
286
- unless key
287
- raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes"
288
- end
365
+ raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes" unless key
289
366
 
290
367
  instance_variable_get(:"@#{key}")
291
368
  end
@@ -297,17 +374,18 @@ module Phlex
297
374
  # record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
298
375
  # unchanged.
299
376
  def reactive_token
300
- payload = {"c" => self.class.name}
377
+ klass = self.class
378
+ payload = { "c" => klass.name }
301
379
 
302
- if self.class.reactive_record_key
303
- record = instance_variable_get(:"@#{self.class.reactive_record_key}")
304
- payload["gid"] = record.to_gid.to_s
380
+ if (record_ivar = klass.reactive_record_ivar)
381
+ payload["gid"] = instance_variable_get(record_ivar).to_gid.to_s
305
382
  end
306
383
 
307
- if self.class.reactive_state_keys.any?
308
- payload["s"] = self.class.reactive_state_keys.to_h do |k|
309
- [k.to_s, instance_variable_get(:"@#{k}").as_json]
310
- end
384
+ state_ivars = klass.reactive_state_ivars
385
+ unless state_ivars.empty?
386
+ state = {}
387
+ state_ivars.each { |key, ivar| state[key] = instance_variable_get(ivar).as_json }
388
+ payload["s"] = state
311
389
  end
312
390
 
313
391
  Phlex::Reactive.sign(payload)
@@ -13,27 +13,27 @@ module Phlex
13
13
 
14
14
  # Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create.
15
15
  # Apps can change the path with Phlex::Reactive.action_path before boot.
16
- initializer "phlex_reactive.routes" do |app|
17
- app.routes.append do
16
+ initializer "phlex_reactive.routes" do
17
+ it.routes.append do
18
18
  post Phlex::Reactive.action_path, to: "phlex/reactive/actions#create", as: :phlex_reactive_action
19
19
  end
20
20
  end
21
21
 
22
22
  # Make reactive_controller.js available to Propshaft/Sprockets so it can
23
23
  # be included via the asset pipeline or pinned in importmap.
24
- initializer "phlex_reactive.assets" do |app|
25
- if app.config.respond_to?(:assets)
26
- app.config.assets.paths << root.join("app/javascript").to_s
27
- app.config.assets.precompile += %w[phlex/reactive/reactive_controller.js]
24
+ initializer "phlex_reactive.assets" do
25
+ if it.config.respond_to?(:assets)
26
+ it.config.assets.paths << root.join("app/javascript").to_s
27
+ it.config.assets.precompile += %w[phlex/reactive/reactive_controller.js]
28
28
  end
29
29
  end
30
30
 
31
31
  # Auto-pin the client controller for importmap apps so it loads without
32
32
  # manual configuration. Apps that don't use importmap include it via the
33
33
  # asset pipeline instead (see README).
34
- initializer "phlex_reactive.importmap", after: "importmap" do |app|
35
- if defined?(::Importmap::Map) && app.respond_to?(:importmap)
36
- app.importmap.pin(
34
+ initializer "phlex_reactive.importmap", after: "importmap" do
35
+ if defined?(::Importmap::Map) && it.respond_to?(:importmap)
36
+ it.importmap.pin(
37
37
  "phlex/reactive/reactive_controller",
38
38
  to: "phlex/reactive/reactive_controller.js",
39
39
  preload: true
@@ -41,6 +41,15 @@ module Phlex
41
41
  end
42
42
  end
43
43
 
44
+ # Flush memoized off-request view contexts on every code reload (dev) so a
45
+ # reloaded renderer controller class is never served from a stale memo. In
46
+ # production to_prepare runs once (eager load), so the cache simply builds
47
+ # fresh after boot. See Streamable.reset_all_view_contexts!.
48
+ config.to_prepare do
49
+ Phlex::Reactive::Streamable.reset_all_view_contexts!
50
+ Phlex::Reactive.reset_flash_builder!
51
+ end
52
+
44
53
  # Boot-time guard (issue #26): warn if the action path doesn't resolve to
45
54
  # the gem controller. Runs after_initialize so the host's full route set
46
55
  # (including a bottom-of-file catch-all that would shadow our appended
@@ -25,6 +25,12 @@ module Phlex
25
25
  # Response remains the public value object (it's what the endpoint reads); it
26
26
  # is simply an internal detail you rarely name directly now.
27
27
  class Reply
28
+ # Distinguishes `reply.remove` (no args — remove the bound component
29
+ # itself) from `reply.remove(:items, model)` (remove a collection row).
30
+ # A sentinel, not nil, so `reply.remove(:items, nil)` stays unambiguous.
31
+ UNSET = Object.new
32
+ private_constant :UNSET
33
+
28
34
  def initialize(component)
29
35
  @component = component
30
36
  end
@@ -47,9 +53,30 @@ module Phlex
47
53
  Response.update(@component)
48
54
  end
49
55
 
50
- # Remove the component's element from the DOM (render_self false).
51
- def remove
52
- Response.remove(@component)
56
+ # Reactive collections (issue #35) add/remove a row in a declared
57
+ # reactive_collection, emitting the row stream + the count companion +
58
+ # the empty-state toggle as ONE Response. The bound component is the
59
+ # container (it carries the declaration + size resolver).
60
+ #
61
+ # def add_item(...) = (item = @list.items.create!(...); reply.append(:items, item))
62
+ # def remove_item(id:) = (@list.items.find(id).destroy!; reply.remove(:items, id))
63
+ #
64
+ # `model` is the row's record; remove also accepts the row's dom-id string.
65
+ def append(name, model)
66
+ Response.collection_append(@component, name, model)
67
+ end
68
+
69
+ def prepend(name, model)
70
+ Response.collection_prepend(@component, name, model)
71
+ end
72
+
73
+ # Two forms:
74
+ # reply.remove # remove the bound component's own element
75
+ # reply.remove(:items, model) # remove a collection row + count + empty
76
+ def remove(name = UNSET, model = UNSET)
77
+ return Response.remove(@component) if name.equal?(UNSET)
78
+
79
+ Response.collection_remove(@component, name, model)
53
80
  end
54
81
 
55
82
  # Subject-free builders — pass straight through so they read naturally.
@@ -51,6 +51,104 @@ module Phlex
51
51
  # Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
52
52
  def with(*strings) = new(streams: strings.flatten)
53
53
 
54
+ # --- Reactive collections (issue #35) ---
55
+ # Add/remove a row in a declared reactive_collection, emitting the row
56
+ # stream + the count companion update + the empty-state toggle as ONE
57
+ # Response. `component` is the bound CONTAINER (it carries the
58
+ # declaration and the size resolver); `model` is the row's record (or, for
59
+ # remove, a model OR a dom-id string). count/empty/size are optional — a
60
+ # stream is emitted only for the pieces declared.
61
+ #
62
+ # render_self is false: the row append/prepend/remove IS the update, so
63
+ # we must NOT also replace the whole container (that would re-render every
64
+ # row and clobber the just-streamed delta).
65
+ #
66
+ # token_component is the CONTAINER (cosmos#1939): a reply that does NOT
67
+ # re-render self must STILL refresh self's signed token, or the list is
68
+ # add-once-only — correct on the first click, then every subsequent dispatch
69
+ # from the list root is rejected (its token went stale) with no error. The
70
+ # container owns the add/remove trigger, so the endpoint appends its inert
71
+ # `reactive:token` stream (the same #30 machinery reply.streams uses) to roll
72
+ # the token forward without re-rendering the rows.
73
+ def collection_append(component, name, model)
74
+ definition = collection_def!(component, name)
75
+ new(streams: collection_add_streams(definition, component, model, :append),
76
+ render_self: false, token_component: component)
77
+ end
78
+
79
+ def collection_prepend(component, name, model)
80
+ definition = collection_def!(component, name)
81
+ new(streams: collection_add_streams(definition, component, model, :prepend),
82
+ render_self: false, token_component: component)
83
+ end
84
+
85
+ def collection_remove(component, name, model)
86
+ definition = collection_def!(component, name)
87
+ new(streams: collection_remove_streams(definition, component, model),
88
+ render_self: false, token_component: component)
89
+ end
90
+
91
+ private
92
+
93
+ # Resolve the declaration off the container's class, raising a clear error
94
+ # for an undeclared name (a typo'd collection should fail loudly, not
95
+ # silently emit an empty Response).
96
+ def collection_def!(component, name)
97
+ component.class.reactive_collection_def(name) ||
98
+ raise(Phlex::Reactive::Error, "undeclared reactive_collection :#{name} on #{component.class}")
99
+ end
100
+
101
+ # Row add (append/prepend) + count + empty-state clear. The empty-state is
102
+ # removed only when the list just crossed 0->1 (size == 1) — appending to
103
+ # an already-populated list leaves it untouched.
104
+ def collection_add_streams(definition, component, model, action)
105
+ streams = [definition.item.public_send(action, target: definition.container, model:)]
106
+ append_count_stream(streams, definition, component)
107
+
108
+ size = definition.size_for(component)
109
+ streams << definition.empty.new.to_stream_remove if definition.empty && size == 1
110
+ streams
111
+ end
112
+
113
+ # Row remove + count + empty-state restore. The empty-state is appended
114
+ # back into the container only when the list just emptied (size == 0).
115
+ def collection_remove_streams(definition, component, model)
116
+ streams = [collection_row_remove(definition, model)]
117
+ append_count_stream(streams, definition, component)
118
+
119
+ size = definition.size_for(component)
120
+ if definition.empty && size&.zero?
121
+ # Render the empty-state and append it INTO the container (not its
122
+ # own id) — restoring "No items yet" when the last row was removed.
123
+ # model: nil builds it argument-free (an empty-state is a static view).
124
+ streams << definition.empty.append(target: definition.container, model: nil)
125
+ end
126
+ streams
127
+ end
128
+
129
+ # Remove the row by its DOM id. Accepts the record (so dom_id is derived)
130
+ # or an already-built dom-id string (e.g. the value the row used as #id).
131
+ def collection_row_remove(definition, model)
132
+ if model.is_a?(String)
133
+ Phlex::Reactive.flash_builder.remove(model)
134
+ else
135
+ definition.item.remove(model)
136
+ end
137
+ end
138
+
139
+ # Append the count companion's update stream when a count id + a size
140
+ # resolver are both declared. The size is a number, HTML-escaped by Turbo.
141
+ def append_count_stream(streams, definition, component)
142
+ return unless definition.count
143
+
144
+ size = definition.size_for(component)
145
+ return if size.nil?
146
+
147
+ streams << update_stream(definition.count, size.to_s)
148
+ end
149
+
150
+ public
151
+
54
152
  # Partial / per-field update with a TOKEN-ONLY refresh (issue #30). Emits
55
153
  # EXACTLY the given streams — no forced full-self replace — but binds
56
154
  # `component` so the endpoint appends its tiny `to_stream_token` stream.