phlex-reactive 0.2.5 → 0.2.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83d418e64d127a079e74679e4920d85803fedff629e8d8b135c2896a79b5bbdd
4
- data.tar.gz: 16c68638d96348ea94c0fa25d411486e49c0b2dab01f07fe9e67fefba630a537
3
+ metadata.gz: 479ff63041e0a402939303aec9ea5fc8064dc0b01f5e63c8c58cfe989e5173b3
4
+ data.tar.gz: b81323095162d6b0662ff18b91b0c8cb34a8e2000028353c695e255ffa13093f
5
5
  SHA512:
6
- metadata.gz: cc16a212daaeb236631702db89fe846dabf936d532e18919c0a371d408f29ce94ae94028eff8cc82e3ff66f1ee25a8acb75f2df29f0b1e2ceb819e49ee3b7217
7
- data.tar.gz: 0b9721189cb032d6a91ae163b15742cb1cb163ef93da4b5ced1d4382811b43da5b5ba3ea25041b5919b20fb58d2dd4d582d8c1ba1345b6d8accf7ac94cdeb978
6
+ metadata.gz: 80070c0e2868530f8499008a603e6f32c4815916fa34c4fccc050b3fb0772c0501ea27e569ee88ada9acb68d7f3eca5fc7333201fc3b9e4358780abd9b6e5d6a
7
+ data.tar.gz: 6412dc6000663fd888193bf9a1b071b9d2f1984692cac474217ebd4955f302e963103bb4bd5ad5c3407b351dfe69a5c2a7079fcdd04b65d73dfed34092c60765
data/CHANGELOG.md CHANGED
@@ -6,6 +6,69 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Added
10
+
11
+ - **Nested & array param types (issue #16).** Action param schemas can now
12
+ declare arrays and nested hashes, not just scalars: wrap a type in an array
13
+ (`bank_account_ids: [:integer]`) for an array param, or wrap a hash schema in
14
+ an array (`invoice_items_attributes: [{ id: :integer, quantity: :float,
15
+ _destroy: :boolean }]`) for Rails-style nested attributes. Coercion recurses
16
+ per field, drops undeclared nested keys (no mass assignment), and accepts an
17
+ array as either a JSON array or a Rails index hash (`{ "0" => …, "1" => … }`).
18
+ A malformed (present-but-non-array) value for an array param is dropped — not
19
+ coerced to `[]` — so a bad payload can't read as an explicit "clear all" on an
20
+ `update!(declared_array:)`; a real empty array still passes through as `[]`.
21
+ A reactive form can now mirror a normal nested-attributes update in one action
22
+ instead of being forced into a per-row component architecture.
23
+ - **Debounce option on `on(...)` (issue #17).** A trigger can declare
24
+ `on(:update, event: "input", debounce: 300)` (milliseconds) to coalesce rapid
25
+ events — typically keystrokes — into a SINGLE action round trip fired after the
26
+ quiet period, instead of one POST per keystroke. A `blur` flushes a pending
27
+ dispatch so the last edit is never dropped, `preventDefault` still fires
28
+ synchronously (a debounced `submit` won't navigate), and the debounced round
29
+ trip still goes through the per-component queue so token threading holds.
30
+ Omitting `debounce:` keeps the immediate-dispatch default.
31
+
32
+ ### Fixed
33
+
34
+ - **Nested reactive roots no longer leak fields (issue #15).** When a reactive
35
+ component is rendered inside another (both are `data-controller="reactive"`
36
+ roots), an action on the outer root previously swept *every* descendant named
37
+ input — including the nested roots' inputs — into its own params. Field
38
+ collection now stops at nested reactive roots: an action collects only the
39
+ inputs whose nearest `[data-controller~="reactive"]` ancestor is its own root.
40
+ Outer flat fields and per-row reactive editing compose cleanly, with no
41
+ name-disjointness workarounds.
42
+
43
+ ## [0.2.6]
44
+
45
+ ### Added
46
+
47
+ - **Action response control via `Phlex::Reactive::Response`.** An action MAY now
48
+ return a `Response` to govern the actor's HTTP reply, instead of only the
49
+ implicit single re-render. Returning anything else keeps the legacy default
50
+ (re-render the component in place), so existing actions are unaffected.
51
+ - `Response.replace(self)` / `.update(self)` — explicit re-render.
52
+ - `Response.replace(self).flash(:error, msg_or_component)` — surface a
53
+ validation error / notice (the `#1` driver). `.flash` is additive on a
54
+ self-replace, so the component's signed token always refreshes. Flash
55
+ content is supplied explicitly (the render context is off-request — there is
56
+ no Rails `flash`); pass a string or a Phlex component. Target container is
57
+ `Phlex::Reactive.flash_target` (default `flash`).
58
+ - `Response.remove(self)` — drop the element (e.g. a moderation queue). New
59
+ instance helper `Streamable#to_stream_remove` backs it.
60
+ - `Response.redirect(url)` — client-side `Turbo.visit` for when the current
61
+ URL is dead (e.g. a slug rename). Rides a 200 turbo-stream carrying a
62
+ `reactive:visit` custom action (registered in the client), **not** an HTTP
63
+ 3xx (which the client bails on). Pass a `*_url`.
64
+ - `Response.with(*streams)` / `#stream(*more)` — multi-stream (replace self +
65
+ a sibling component).
66
+ - The endpoint guarantees the component's own replace is present (token refresh)
67
+ for non-remove/redirect responses, and never double-prepends when the action
68
+ already included a self-targeted stream.
69
+
70
+ ## [0.2.5]
71
+
9
72
  ### Fixed
10
73
 
11
74
  - **Form submit navigated instead of running the reactive action.** A component
data/README.md CHANGED
@@ -149,7 +149,8 @@ for broadcasting.
149
149
  │ verify signed token (no state trusted)
150
150
  │ rebuild component (record from DB)
151
151
  │ run the whitelisted action
152
- │ re-render → <turbo-stream replace id>
152
+ │ re-render → <turbo-stream replace id> (default; an action
153
+ │ may return a Response — see "Controlling the action's reply")
153
154
  └──────── Turbo morphs it in ◀───────────────────────────────┘
154
155
 
155
156
  ...and for OTHER tabs/users:
@@ -254,7 +255,7 @@ The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
254
255
  | `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
255
256
  | `.broadcast_replace_to(*streamables, model:)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable) |
256
257
  | `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |
257
- | `#to_stream_replace` / `#to_stream_update` | Stream the *already-built* instance (used internally after an action) |
258
+ | `#to_stream_replace` / `#to_stream_update` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `Response`) |
258
259
 
259
260
  Use in controllers: `render turbo_stream: Counter.replace(counter)`.
260
261
 
@@ -267,10 +268,50 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
267
268
  | `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
268
269
  | `reactive_attrs` | Spread onto the root element: marks it reactive + carries the signed token. |
269
270
  | `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
271
+ | `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
270
272
 
271
273
  Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
272
274
  in the schema is dropped before reaching your method.
273
275
 
276
+ **Array & nested params.** Wrap a type in an array for an array param, or a hash
277
+ schema in an array for Rails-style nested attributes — so one reactive action can
278
+ mirror a normal nested-attributes update instead of forcing a per-row component:
279
+
280
+ ```ruby
281
+ action :save, params: {
282
+ date: :string,
283
+ bank_account_ids: [:integer], # array of scalar
284
+ invoice_items_attributes: [ # array of hash
285
+ { id: :integer, quantity: :float, price: :float, _destroy: :boolean }
286
+ ]
287
+ }
288
+
289
+ def save(date:, bank_account_ids:, invoice_items_attributes:)
290
+ @invoice.update!(date:, bank_account_ids:, invoice_items_attributes:)
291
+ end
292
+ ```
293
+
294
+ Nested coercion recurses per field, drops undeclared nested keys, and accepts an
295
+ array as either a JSON array or a Rails index hash (`{ "0" => …, "1" => … }`).
296
+
297
+ **Nested reactive components compose.** A reactive component rendered inside
298
+ another is its own root — field collection stops at nested
299
+ `data-controller="reactive"` roots, so an outer action collects only *its own*
300
+ named inputs, never a nested component's. An invoice editor's `save` sees its
301
+ flat fields; each line-item row's `quantity`/`price` belong to that row's own
302
+ action. No name-disjointness workarounds required.
303
+
304
+ **Debounced triggers (live-as-you-type).** Pass `debounce:` (milliseconds) to
305
+ coalesce rapid events — typically keystrokes on an `"input"` trigger — into a
306
+ single action round trip fired after the quiet period, instead of one POST per
307
+ keystroke. A blur flushes a pending dispatch so the last edit is never dropped.
308
+ Omit `debounce:` for the immediate-dispatch default.
309
+
310
+ ```ruby
311
+ # Recompute a total live as the user types, without hammering the endpoint.
312
+ input(**mix(on(:update, event: "input", debounce: 300), name: "quantity", value: @item.quantity))
313
+ ```
314
+
274
315
  **Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
275
316
  a hash that includes a `data:` key. Spreading them *and* passing another `data:`
276
317
  (or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge:
@@ -284,6 +325,41 @@ div(**mix(reactive_attrs, id:, class: "card")) { ... }
284
325
  button(**on(:increment), data: { testid: "inc" }) { "+" }
285
326
  ```
286
327
 
328
+ ### `Phlex::Reactive::Response` — controlling the action's reply
329
+
330
+ By default an action re-renders its component in place. **Return** a
331
+ `Phlex::Reactive::Response` to do more (it governs only the actor's HTTP reply —
332
+ cross-tab updates still use `broadcast_*_to(..., exclude: reactive_connection_id)`).
333
+ Returning anything else keeps the default, so existing actions are unaffected.
334
+
335
+ The snippets below alias the constant for brevity (`Response.replace(self)` won't
336
+ resolve to `Phlex::Reactive::Response` inside a namespaced component — fully
337
+ qualify it, or add the alias shown):
338
+
339
+ ```ruby
340
+ Response = Phlex::Reactive::Response # or qualify each call below
341
+
342
+ def rename(title:)
343
+ return Response.replace(self).flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
344
+ Response.replace(self)
345
+ end
346
+
347
+ def approve = (@row.approve!; Response.remove(self)) # drop the element
348
+ def publish = (@article.publish!; Response.redirect(article_url(@article))) # slug changed → Turbo.visit
349
+ def add(item:) = Response.replace(self).stream(Totals.update(@order)) # multi-stream
350
+ ```
351
+
352
+ | Builder | Reply |
353
+ |---|---|
354
+ | `Response.replace(self)` / `.update(self)` | re-render in place (explicit default) |
355
+ | `.flash(level, content, target: …)` | append a flash; `content` is a string or Phlex component (off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
356
+ | `Response.remove(self)` | remove the element (backed by `Streamable#to_stream_remove`) |
357
+ | `Response.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
358
+ | `Response.with(*streams)` / `#stream(*more)` | multi-stream |
359
+
360
+ `.flash`/`.stream` are additive on a self-replace, so the component's signed
361
+ token always refreshes.
362
+
287
363
  ### Configuration (`config/initializers/phlex_reactive.rb`)
288
364
 
289
365
  ```ruby
@@ -36,9 +36,9 @@ module Phlex
36
36
  component = component_class.from_identity(payload)
37
37
  coerced = coerce_params(action_def.params)
38
38
 
39
- run_action(component, action_def, coerced)
39
+ result = run_action(component, action_def, coerced)
40
40
 
41
- render turbo_stream: component.to_stream_replace
41
+ render turbo_stream: response_streams(result, component)
42
42
  rescue Phlex::Reactive::InvalidToken
43
43
  head :bad_request
44
44
  rescue ActiveRecord::RecordNotFound
@@ -69,6 +69,39 @@ module Phlex
69
69
  end
70
70
  end
71
71
 
72
+ # Turn the action's return value into the turbo-stream(s) to render for
73
+ # the actor. A Phlex::Reactive::Response is honored explicitly; any other
74
+ # value (the legacy contract — return value ignored) falls back to the
75
+ # implicit single replace, so existing actions are unaffected.
76
+ def response_streams(result, component)
77
+ return [component.to_stream_replace] unless result.is_a?(Phlex::Reactive::Response)
78
+ return [redirect_stream(result.redirect_url)] if result.redirect?
79
+
80
+ streams = result.streams
81
+ # Guarantee the component's signed identity token is refreshed unless the
82
+ # Response opted out (remove/redirect navigate away — handled above). The
83
+ # client reads the next token from the response body (#extractToken), so
84
+ # the real invariant is "a fresh data-reactive-token-value is present",
85
+ # NOT "some stream targets self". Checking the token directly is correct
86
+ # for replace AND update of self (both re-render the root via
87
+ # render_component, carrying the token), and still adds the fallback
88
+ # replace when a hand-built `with(...)` stream omits it. Idempotent: a
89
+ # Response.replace(self)/update(self) already carries the token, so we
90
+ # don't double the self-render.
91
+ if result.render_self? && streams.none? { |s| s.include?("data-reactive-token-value") }
92
+ streams = [component.to_stream_replace, *streams]
93
+ end
94
+ streams
95
+ end
96
+
97
+ # A 200 turbo-stream carrying a namespaced custom action the client turns
98
+ # into Turbo.visit — NOT an HTTP 3xx, which the client hard-bails on
99
+ # (response.redirected). The matching client handler is registered in
100
+ # reactive_controller.js.
101
+ def redirect_stream(url)
102
+ %(<turbo-stream action="reactive:visit" data-url="#{ERB::Util.html_escape(url)}"></turbo-stream>)
103
+ end
104
+
72
105
  def transaction_wrapper(&block)
73
106
  if defined?(::ActiveRecord::Base)
74
107
  ::ActiveRecord::Base.transaction(&block)
@@ -90,18 +123,63 @@ module Phlex
90
123
 
91
124
  # Coerce client params against the action's declared schema. Anything not
92
125
  # in the schema is dropped — no raw mass assignment reaches the component.
126
+ # The top-level params arrive as an ActionController::Parameters; coerce
127
+ # them against the action's hash schema (same recursion as nested hashes).
93
128
  def coerce_params(schema)
94
129
  return {} if schema.blank?
95
130
 
96
- raw = params.fetch(:params, {})
131
+ coerce_hash(params.fetch(:params, {}), schema)
132
+ end
133
+
134
+ # Sentinel: a declared key whose value can't be coerced to its type is
135
+ # DROPPED (not assigned), so the method's keyword default applies — exactly
136
+ # as if the client had omitted the key. Distinct from a coerced nil/[].
137
+ DROP = Object.new
138
+ private_constant :DROP
139
+
140
+ # Coerce a value against a declared type. A type is one of:
141
+ # * a scalar symbol (:string/:integer/:float/:boolean)
142
+ # * a Hash schema ({ id: :integer, ... }) — nested object
143
+ # * a one-element Array ([:integer] / [{ ... }]) — array of that
144
+ # Arrays accept both a real JSON array and a Rails-style index hash
145
+ # ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
146
+ def coerce(value, type)
147
+ if type.is_a?(Array)
148
+ coerce_array(value, type.first)
149
+ elsif type.is_a?(Hash)
150
+ coerce_hash(value, type)
151
+ else
152
+ coerce_scalar(value, type)
153
+ end
154
+ end
155
+
156
+ # A real array (or Rails index hash) coerces element-wise. A malformed
157
+ # present-but-non-array value returns DROP rather than [] — coercing a stray
158
+ # scalar to an empty array would let a bad payload read as an explicit
159
+ # "clear everything" on update!(declared_array:).
160
+ def coerce_array(value, element_type)
161
+ values = array_values(value)
162
+ return DROP if values.nil?
163
+
164
+ values.map { |element| coerce(element, element_type) }
165
+ end
166
+
167
+ # Keep declared keys only (drop undeclared — no mass assignment), recursing
168
+ # for nested hash/array element types. Symbolizes keys to splat as kwargs.
169
+ # A key whose value coerces to DROP is skipped (keyword default applies).
170
+ def coerce_hash(value, schema)
171
+ hash = to_param_hash(value)
97
172
  schema.each_with_object({}) do |(key, type), out|
98
- next unless raw.key?(key.to_s)
173
+ next unless hash.key?(key.to_s)
99
174
 
100
- out[key.to_sym] = coerce(raw[key.to_s], type)
175
+ coerced = coerce(hash[key.to_s], type)
176
+ next if coerced.equal?(DROP)
177
+
178
+ out[key.to_sym] = coerced
101
179
  end
102
180
  end
103
181
 
104
- def coerce(value, type)
182
+ def coerce_scalar(value, type)
105
183
  case type
106
184
  when :integer then value.to_i
107
185
  when :float then value.to_f
@@ -110,6 +188,27 @@ module Phlex
110
188
  end
111
189
  end
112
190
 
191
+ # Normalize an array param: a real array passes through; a Rails index hash
192
+ # ({ "0" => ..., "1" => ... }) becomes its values in index order. Anything
193
+ # else (a stray scalar, nil) is malformed → nil, so the caller drops the
194
+ # param rather than fabricating an empty array.
195
+ def array_values(value)
196
+ return value.to_a if value.is_a?(Array)
197
+
198
+ if value.respond_to?(:to_unsafe_h) || value.is_a?(Hash)
199
+ to_param_hash(value).sort_by { |k, _| k.to_i }.map(&:last)
200
+ end
201
+ end
202
+
203
+ # Unwrap ActionController::Parameters (or a plain Hash) to a string-keyed
204
+ # Hash so coercion can index it uniformly.
205
+ def to_param_hash(value)
206
+ return value.to_unsafe_h.stringify_keys if value.respond_to?(:to_unsafe_h)
207
+ return value.stringify_keys if value.is_a?(Hash)
208
+
209
+ {}
210
+ end
211
+
113
212
  # Only components that opt into Reactive may be resolved. The signature
114
213
  # already gates this; defense in depth against constant injection.
115
214
  def resolve_component(name)
@@ -17,6 +17,25 @@ import { Controller } from "@hotwired/stimulus"
17
17
  // .broadcast_* methods — so a click and a background broadcast converge on
18
18
  // one re-render unit.
19
19
  //
20
+ // Custom turbo-stream action: the server tells the actor to full-navigate
21
+ // (e.g. the record's slug changed and the current URL is now dead). It rides a
22
+ // 200 turbo-stream — NOT an HTTP 3xx — so it never trips the response.redirected
23
+ // bail below (which still correctly catches real auth/CSRF redirects). Registered
24
+ // once on the Turbo global (no @hotwired/turbo import — the gem uses window.Turbo
25
+ // everywhere, and a named import is unreliable under importmap/esbuild).
26
+ export function registerReactiveVisit() {
27
+ const actions = window.Turbo?.StreamActions
28
+ if (!actions || actions["reactive:visit"]) return
29
+ actions["reactive:visit"] = function () {
30
+ const url = this.getAttribute("data-url")
31
+ if (url) window.Turbo.visit(url, { action: "advance" })
32
+ }
33
+ }
34
+ if (typeof window !== "undefined") {
35
+ if (window.Turbo) registerReactiveVisit()
36
+ else document.addEventListener("turbo:load", registerReactiveVisit, { once: true })
37
+ }
38
+
20
39
  // Register this controller eagerly (not lazily) so a click immediately after
21
40
  // page load is never missed. The phlex-reactive engine auto-pins it with
22
41
  // preload: true for importmap apps; see the README for esbuild/webpack.
@@ -26,6 +45,15 @@ export default class extends Controller {
26
45
  }
27
46
 
28
47
  #tokenCache // freshest token, threaded synchronously across queued requests
48
+ #debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
49
+
50
+ // Tear down any pending debounce timers when the controller leaves the DOM
51
+ // (Turbo morph/navigation removes the element). Otherwise a timer that hasn't
52
+ // fired yet would later call #enqueue on a disconnected controller — a round
53
+ // trip against a detached element / stale token (issue #17 follow-up).
54
+ disconnect() {
55
+ this.#clearAllDebounces()
56
+ }
29
57
 
30
58
  // Serialize requests per component. Each round trip rewrites the signed
31
59
  // token in the DOM (state lives in the token, not the client). If events
@@ -34,7 +62,7 @@ export default class extends Controller {
34
62
  // a per-controller promise makes each dispatch wait for the previous one, so
35
63
  // it always uses the freshest token.
36
64
  dispatch(event) {
37
- const { action, params } = event.params
65
+ const { action, params, debounce } = event.params
38
66
  if (!action) return
39
67
 
40
68
  // Stop native behavior (button submit / FORM NAVIGATION) HERE, synchronously
@@ -42,15 +70,57 @@ export default class extends Controller {
42
70
  // still being handled — deferring it into the request-queue microtask (below)
43
71
  // is too late: a `submit` trigger would natively POST the form and navigate
44
72
  // before the reactive round trip runs (issue #11). For a `click` trigger
45
- // there's no default to miss, so this was previously invisible.
73
+ // there's no default to miss, so this was previously invisible. This holds
74
+ // for debounced triggers too — the round trip is deferred, but the native
75
+ // default must still be prevented now.
46
76
  event.preventDefault()
47
77
 
78
+ // Debounced trigger (e.g. on(:update, event: "input", debounce: 300)):
79
+ // coalesce rapid events into ONE round trip after a quiet period, instead of
80
+ // one POST per keystroke (issue #17). A blur flushes a pending dispatch.
81
+ const ms = Number(debounce) || 0
82
+ if (ms > 0) return this.#debounceDispatch(event.target, ms, action, params)
83
+
48
84
  // Capture action/params now; the queued work runs in a later microtask, by
49
85
  // which point the event object may have been reset by the browser.
86
+ return this.#enqueue(action, params)
87
+ }
88
+
89
+ #enqueue(action, params) {
50
90
  this.queue = (this.queue ?? Promise.resolve()).then(() => this.#perform(action, params))
51
91
  return this.queue
52
92
  }
53
93
 
94
+ // Reset a per-element timer; only enqueue the round trip after `ms` of quiet.
95
+ // Also flush immediately on blur so leaving the field never drops the last
96
+ // edit (a long debounce shouldn't swallow a value the user tabbed away from).
97
+ #debounceDispatch(target, ms, action, params) {
98
+ this.#clearDebounce(target)
99
+
100
+ const flush = () => {
101
+ this.#clearDebounce(target)
102
+ this.#enqueue(action, params)
103
+ }
104
+ const timer = setTimeout(flush, ms)
105
+ target?.addEventListener?.("blur", flush, { once: true })
106
+ this.#debounceTimers.set(target, { timer, flush })
107
+ }
108
+
109
+ #clearDebounce(target) {
110
+ const pending = this.#debounceTimers.get(target)
111
+ if (!pending) return
112
+ clearTimeout(pending.timer)
113
+ target?.removeEventListener?.("blur", pending.flush)
114
+ this.#debounceTimers.delete(target)
115
+ }
116
+
117
+ // Clear every pending debounce timer (used on disconnect). Reuses
118
+ // #clearDebounce so all timer/listener teardown stays in one place. Snapshot
119
+ // the keys first — #clearDebounce mutates the map as it goes.
120
+ #clearAllDebounces() {
121
+ for (const target of [...this.#debounceTimers.keys()]) this.#clearDebounce(target)
122
+ }
123
+
54
124
  async #perform(action, params) {
55
125
  // Auto-collect named field values inside this component so a button-
56
126
  // triggered action still receives sibling inputs (Livewire-style).
@@ -126,10 +196,21 @@ export default class extends Controller {
126
196
  return match?.[1]
127
197
  }
128
198
 
199
+ // True when `el` is collected by THIS reactive root and not by a nested one.
200
+ // A reactive component can be rendered inside another (both are
201
+ // data-controller="reactive" roots). querySelectorAll() descends into nested
202
+ // roots, so without this guard an outer action would sweep the inner roots'
203
+ // inputs into its own params (issue #15). An element belongs to this root iff
204
+ // its nearest [data-controller~="reactive"] ancestor is this.element.
205
+ #ownsField(el) {
206
+ return el.closest('[data-controller~="reactive"]') === this.element
207
+ }
208
+
129
209
  #collectFields() {
130
210
  const fields = {}
131
- // Standard form controls.
211
+ // Standard form controls owned by THIS root (not a nested reactive root).
132
212
  this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
213
+ if (!this.#ownsField(field)) return
133
214
  if (field.type === "checkbox") {
134
215
  fields[field.name] = field.checked
135
216
  } else if (field.type === "radio") {
@@ -148,6 +229,7 @@ export default class extends Controller {
148
229
  this.element
149
230
  .querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])")
150
231
  .forEach((el) => {
232
+ if (!this.#ownsField(el)) return // skip editors owned by a nested reactive root (issue #15)
151
233
  // A plain element (e.g. a <div contenteditable>) has no `name` IDL
152
234
  // property — only the attribute — so read getAttribute, not el.name.
153
235
  const name = el.getAttribute("name")
@@ -87,7 +87,21 @@ module Phlex
87
87
  # Declare a client-invokable action with an optional param schema.
88
88
  # action :increment
89
89
  # action :rename, params: { title: :string }
90
- # Param types: :string (default), :integer, :float, :boolean.
90
+ #
91
+ # Param types are coerced server-side; anything not in the schema is
92
+ # dropped before reaching your method (no mass assignment):
93
+ # * Scalars — :string (default), :integer, :float, :boolean
94
+ # * Array of scalar — wrap the type in an array: [:integer]
95
+ # * Array of hash (Rails nested attributes) — wrap a hash schema:
96
+ # action :save, params: {
97
+ # date: :string,
98
+ # bank_account_ids: [:integer],
99
+ # invoice_items_attributes: [
100
+ # { id: :integer, quantity: :float, price: :float, _destroy: :boolean }
101
+ # ]
102
+ # }
103
+ # Array params accept BOTH a JSON array and a Rails-style index hash
104
+ # ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
91
105
  def action(name, params: {})
92
106
  reactive_actions[name.to_sym] = Action.new(name: name.to_sym, params: params)
93
107
  end
@@ -168,11 +182,18 @@ module Phlex
168
182
  # Attributes for an element that triggers an action.
169
183
  # button(**on(:toggle)) { "○" }
170
184
  # form(**on(:save, event: "submit")) { ... }
185
+ # input(**on(:update, event: "input", debounce: 300)) # live-as-you-type
171
186
  #
172
187
  # Extra keyword args become explicit params merged over collected form
173
188
  # fields. For click triggers we force type="button" so a bare button
174
189
  # inside a <form> can't submit it and cause a full-page navigation.
175
- def on(action_name, event: "click", **params)
190
+ #
191
+ # `debounce:` (milliseconds) coalesces rapid events (e.g. keystrokes on an
192
+ # "input" trigger) into ONE round trip fired after the quiet period — so
193
+ # live-update-as-you-type doesn't POST per keystroke. A blur flushes a
194
+ # pending dispatch so the last edit is never dropped. Omit it for the
195
+ # immediate-dispatch default.
196
+ def on(action_name, event: "click", debounce: nil, **params)
176
197
  attrs = {
177
198
  data: {
178
199
  action: "#{event}->reactive#dispatch",
@@ -180,6 +201,7 @@ module Phlex
180
201
  reactive_params_param: params.to_json
181
202
  }
182
203
  }
204
+ attrs[:data][:reactive_debounce_param] = debounce if debounce
183
205
  attrs[:type] = "button" if event == "click"
184
206
  attrs
185
207
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # An explicit, immutable description of the ACTOR's HTTP response to a
6
+ # reactive action. An action MAY return one; if it returns anything else
7
+ # (the legacy contract — return value ignored), the endpoint falls back to
8
+ # the implicit single component.to_stream_replace.
9
+ #
10
+ # A Response governs ONLY the actor's HTTP reply. Cross-tab updates still go
11
+ # through Streamable's broadcast_*_to(..., exclude: reactive_connection_id).
12
+ #
13
+ # Response.replace(self) # re-render in place (the default, explicit)
14
+ # Response.replace(self).flash(:error, msg) # surface a validation error
15
+ # Response.remove(self) # drop the element (e.g. moderation queue)
16
+ # Response.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
17
+ # Response.replace(self).stream(Totals.update(@order)) # multi-stream
18
+ class Response
19
+ attr_reader :streams, :redirect_url
20
+
21
+ class << self
22
+ # Re-render the component in place (explicit form of today's default).
23
+ def replace(component) = new(streams: [component.to_stream_replace])
24
+
25
+ # Morph only inner HTML (preserves the root element + its token attr).
26
+ def update(component) = new(streams: [component.to_stream_update])
27
+
28
+ # Remove the component's element from the DOM. Uses the instance
29
+ # to_stream_remove (the component already knows its own #id — no
30
+ # class-builder reconstruction; works for record- and state-backed).
31
+ def remove(component) = new(streams: [component.to_stream_remove], render_self: false)
32
+
33
+ # Client-side full navigation (Turbo.visit). Use when the current URL
34
+ # is dead (slug rename) or the outcome belongs on another page. Pass a
35
+ # *_url (the off-request render context has no request host for *_path).
36
+ def redirect(url) = new(redirect_url: url, render_self: false)
37
+
38
+ # Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
39
+ def with(*strings) = new(streams: strings.flatten)
40
+
41
+ # Build a flash turbo-stream that appends `content` into a host-app
42
+ # container. `content` is a Phlex component instance (rendered through
43
+ # the configured renderer so t()/url_for work) or a ready HTML string —
44
+ # supplied by the caller because the render context is off-request
45
+ # (there is no Rails `flash`).
46
+ def flash_stream(_level, content, target:)
47
+ html = content.is_a?(::Phlex::SGML) ? Phlex::Reactive.render(content) : content.to_s
48
+ Phlex::Reactive.flash_builder.append(target, html: html)
49
+ end
50
+ end
51
+
52
+ # render_self: when true (default for replace/update/with), the endpoint
53
+ # GUARANTEES the component's own replace is present so its
54
+ # data-reactive-token-value refreshes (the client extracts the next token
55
+ # from the response HTML). remove/redirect set it false (nothing stays).
56
+ def initialize(streams: [], redirect_url: nil, render_self: true)
57
+ @streams = streams.freeze
58
+ @redirect_url = redirect_url
59
+ @render_self = render_self
60
+ freeze
61
+ end
62
+
63
+ # Append extra turbo-stream strings (a sibling component, a flash).
64
+ # Returns a NEW Response (immutable).
65
+ def stream(*more)
66
+ self.class.new(
67
+ streams: @streams + more.flatten,
68
+ redirect_url: @redirect_url,
69
+ render_self: @render_self
70
+ )
71
+ end
72
+
73
+ # Append a flash turbo-stream into a host-app container (default
74
+ # <div id="flash">, configurable via Phlex::Reactive.flash_target).
75
+ def flash(level, content, target: Phlex::Reactive.flash_target)
76
+ stream(self.class.flash_stream(level, content, target:))
77
+ end
78
+
79
+ def redirect? = !@redirect_url.nil?
80
+ def render_self? = @render_self
81
+ end
82
+ end
83
+ end
@@ -187,6 +187,13 @@ module Phlex
187
187
  def to_stream_update
188
188
  self.class.turbo_stream_builder.update(id, html: self.class.render_component(self))
189
189
  end
190
+
191
+ # Render THIS instance as a remove stream. The component already knows its
192
+ # own #id, so no record/class reconstruction is needed (works for record-
193
+ # and state-backed components alike). Used by Response.remove.
194
+ def to_stream_remove
195
+ self.class.turbo_stream_builder.remove(id)
196
+ end
190
197
  end
191
198
  end
192
199
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.2.5"
5
+ VERSION = "0.2.7"
6
6
  end
7
7
  end
@@ -78,6 +78,26 @@ module Phlex
78
78
  @renderer ||= defined?(::ActionController::Base) ? ::ActionController::Base : nil
79
79
  end
80
80
 
81
+ # DOM id of the host-app container a Response#flash appends into.
82
+ # Default "flash"; override to match your layout's flash region.
83
+ def flash_target
84
+ @flash_target ||= "flash"
85
+ end
86
+
87
+ attr_writer :flash_target
88
+
89
+ # Render a Phlex component to HTML with a full (off-request) view context.
90
+ def render(component)
91
+ renderer.render(component, layout: false)
92
+ end
93
+
94
+ # A Turbo::Streams::TagBuilder bound to an off-request view context, used
95
+ # to build standalone streams (e.g. a Response flash append) not tied to a
96
+ # specific component's id.
97
+ def flash_builder
98
+ ::Turbo::Streams::TagBuilder.new(renderer.new.view_context)
99
+ end
100
+
81
101
  def base_controller_name
82
102
  @base_controller_name ||= "ActionController::Base"
83
103
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.2.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -129,6 +129,7 @@ files:
129
129
  - lib/phlex/reactive.rb
130
130
  - lib/phlex/reactive/component.rb
131
131
  - lib/phlex/reactive/engine.rb
132
+ - lib/phlex/reactive/response.rb
132
133
  - lib/phlex/reactive/streamable.rb
133
134
  - lib/phlex/reactive/version.rb
134
135
  homepage: https://github.com/mhenrixon/phlex-reactive