phlex-reactive 0.4.3 → 0.4.5

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: 63629dadef998637b9b65f885a00d54297821f405599a64c103e3b482e991fdc
4
- data.tar.gz: 4826a5cf276f46cdf99575cb624169bacbbc3bf85115ca1256542e4c7aeb6f6b
3
+ metadata.gz: 781352f05403f22ccf974061e325f0066b9809c20de36a881279fcea424c55a5
4
+ data.tar.gz: dfd1747be189e0c451f390f396d535aa5c36f7e0fd8d95f31a331a584537976c
5
5
  SHA512:
6
- metadata.gz: 7561f6119d33b19334078c64570bb2fc7e6e9064b0ee90c4bcbac21dbbe2e07d5ccdb0854d146848efee66bb084c895eb89a853c3931de4c7fd115a0594163d9
7
- data.tar.gz: 5220bfda32b47381507fed0d3a9a9d6620f06e9a10dbd8eb877fc07a0f420a8a1e3ff2dd06805059b0b0b7f550bb76cac81a7899f258bb85508643ef61dc8e35
6
+ metadata.gz: 14e5f543ebb18929407b21a90f6851fcbc685954d23c2ef9aa6a37ed770c85bdf7d477bb430799f81837d04a31154ea85343406e6767f128862fecec82aca401
7
+ data.tar.gz: 55d32359caa8bb5aa7be4b8db1c55cdf58651b08361dd4bd5e87e4b7bf50dd4cdb32d3aaede40be35966e28cf994ff8047006c085f17fc9002bc26f9cc3cf4d7
data/CHANGELOG.md CHANGED
@@ -8,6 +8,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **`reactive_root` helper — the whole reactive root in one spread (#48).**
12
+ `reactive_attrs` doesn't emit `id:`, so an app could put `id:` on a *child*
13
+ element and leave the controller root's `id` empty — which silently re-opened
14
+ the #46 add-once-only bug (the client falls back to the first token in the
15
+ response, the next action POSTs a foreign token, and the endpoint 403s).
16
+ `div(**reactive_root)` emits the component `id` **and** `reactive_attrs` on one
17
+ element, so the id can't land on the wrong node; `reactive_root(class:, data:)`
18
+ deep-merges via `mix`, and an explicit `id:` override still wins. `reactive_attrs`
19
+ is unchanged — existing `div(id:, **reactive_attrs)` components keep working. The
20
+ generic Stimulus controller now also `console.warn`s on `connect()` when a
21
+ reactive root has an empty `id`, so the failure surfaces on page load (a one-line
22
+ hint) instead of on the second click. README/docs promote `div(**reactive_root)`.
23
+
11
24
  - **File / multipart params in a reactive action (#34).** An action can now accept
12
25
  an uploaded file: declare `params: { file: :file }` (or `[:file]` for multiple).
13
26
  When the reactive root holds a populated `<input type="file">`, the client sends
data/README.md CHANGED
@@ -26,7 +26,7 @@ class Counter < ApplicationComponent
26
26
  def decrement = @count -= 1
27
27
 
28
28
  def view_template
29
- div(id:, **reactive_attrs) do
29
+ div(**reactive_root) do
30
30
  button(**on(:decrement)) { "−" }
31
31
  span { @count }
32
32
  button(**on(:increment)) { "+" }
@@ -245,7 +245,7 @@ class Todos::Item < ApplicationComponent
245
245
  end
246
246
 
247
247
  def view_template
248
- li(id:, **reactive_attrs, class: ("done" if @todo.done?)) do
248
+ li(**reactive_root(class: ("done" if @todo.done?))) do
249
249
  button(**on(:toggle)) { @todo.done? ? "✓" : "○" }
250
250
  span { @todo.title }
251
251
  end
@@ -307,7 +307,8 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
307
307
  | `reactive_record :name` | Record-backed identity (GlobalID). State = the DB. |
308
308
  | `reactive_state :a, :b` | Signed instance-var identity. Standalone, or combined with `reactive_record` to sign transient UI state alongside the row. |
309
309
  | `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
310
- | `reactive_attrs` | Spread onto the root element: marks it reactive + carries the signed token. |
310
+ | `reactive_root(**overrides)` | Spread onto the root element: emits the component `id` **and** `reactive_attrs` together, so the controller root always carries `#id`. Preferred over `id:` + `reactive_attrs`. `**overrides` (`class:`/`data:`) deep-merge. |
311
+ | `reactive_attrs` | Marks an element reactive + carries the signed token (no `id`). Spread alongside `id:` on the **same** element: `div(id:, **reactive_attrs)`. Prefer `reactive_root`, which can't split them. |
311
312
  | `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
312
313
  | `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
313
314
  | `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
@@ -413,17 +414,29 @@ input(**mix(on(:update, event: "input", debounce: 300), name: "quantity", value:
413
414
 
414
415
  **Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
415
416
  a hash that includes a `data:` key. Spreading them *and* passing another `data:`
416
- (or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge:
417
+ (or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge. For the
418
+ **root**, prefer `reactive_root`, which already `mix`es id + token for you:
417
419
 
418
420
  ```ruby
419
421
  # ✅ merges cleanly (data-action survives, your data-testid/class are added)
420
422
  button(**mix(on(:increment), class: "btn", data: { testid: "inc" })) { "+" }
421
- div(**mix(reactive_attrs, id:, class: "card")) { ... }
423
+ div(**reactive_root(class: "card", data: { testid: "root" })) { ... } # id + token + your attrs
422
424
 
423
425
  # ❌ the extra data: overwrites on()'s data:, so the action never binds
424
426
  button(**on(:increment), data: { testid: "inc" }) { "+" }
425
427
  ```
426
428
 
429
+ > **The reactive root must carry `#id` (issue #48).** The server targets your
430
+ > component's `#id` and the client self-matches its next signed token by the root
431
+ > element's `id`. `reactive_attrs` does **not** emit the id — so if you put `id:`
432
+ > on a **child** instead of the `**reactive_attrs` element, the root's id is empty,
433
+ > token threading falls back to the first token in the response, and the *next*
434
+ > action silently fails with **HTTP 403**. Use `div(**reactive_root)` (it emits id
435
+ > + token on one element) so the id can't land on the wrong node; if you spread
436
+ > `reactive_attrs` directly, keep `id:` on the **same** element
437
+ > (`div(id:, **reactive_attrs)`). The controller `console.warn`s on connect when a
438
+ > reactive root has no id.
439
+
427
440
  **Binding inputs to action params (drop the magic `name:`).** A field's value
428
441
  travels with an action only if its `name` equals the param. Hand-writing
429
442
  `name: "value"` on every input is easy to forget — the action then silently gets
@@ -435,7 +448,7 @@ mode):
435
448
  action :save, params: { value: :string, status: :string }
436
449
 
437
450
  def view_template
438
- span(id:, **reactive_attrs) do
451
+ span(**reactive_root) do
439
452
  reactive_input(:value, value: @record.name) # <input name="value" …>
440
453
  reactive_select(:status) do # <select name="status">…</select>
441
454
  %w[open closed].each { |s| option(value: s, selected: s == @record.status) { s } }
@@ -719,7 +732,7 @@ class Counter < ApplicationComponent
719
732
  def decrement = @counter.decrement!(:value)
720
733
 
721
734
  def view_template
722
- div(id:, **reactive_attrs) do
735
+ div(**reactive_root) do
723
736
  button(**on(:decrement)) { "−" }
724
737
  span { @counter.value }
725
738
  button(**on(:increment)) { "+" }
@@ -135,6 +135,22 @@ export default class extends Controller {
135
135
  // guard above knows the controller was registered (issue #26 part 2).
136
136
  connect() {
137
137
  reactiveConnected = true
138
+
139
+ // Root-id guard (issue #48). The token round trip assumes the reactive root
140
+ // element's id == component.id: the server targets component.id and the client
141
+ // self-matches its NEXT token by this.element.id (#extractToken, issue #46).
142
+ // If `id:` landed on a CHILD instead of the `**reactive_attrs` root, this id is
143
+ // "" — #extractToken falls back to the FIRST token in the response (a child's),
144
+ // so the next action POSTs a foreign token → endpoint default-deny → silent 403.
145
+ // Warn NOW (on connect) so the failure surfaces on page load, not on click 2.
146
+ if (this.element.id === "") {
147
+ console.warn(
148
+ "[phlex-reactive] a reactive root has no id; its next-action token can't self-match " +
149
+ "and may fall back to the first token in the response → a silent HTTP 403 on the NEXT action. " +
150
+ "Put id: on the SAME element as reactive_attrs — use div(**reactive_root) (emits id + token together), " +
151
+ "or div(id:, **reactive_attrs). The id: must NOT be on a child. See the README."
152
+ )
153
+ }
138
154
  }
139
155
 
140
156
  // Tear down any pending debounce timers when the controller leaves the DOM
@@ -152,9 +168,22 @@ export default class extends Controller {
152
168
  // a per-controller promise makes each dispatch wait for the previous one, so
153
169
  // it always uses the freshest token.
154
170
  dispatch(event) {
155
- const { action, params, debounce } = event.params
171
+ const { action, params, debounce, confirm } = event.params
156
172
  if (!action) return
157
173
 
174
+ // Confirmation gate (issue #52). A destructive reactive trigger can't use
175
+ // Hotwire's data-turbo-confirm — this controller preempts the event — so a
176
+ // `confirm:` message threads a data-reactive-confirm-param and we prompt
177
+ // HERE, BEFORE any preventDefault/enqueue/debounce. On decline we still
178
+ // preventDefault (so a `submit`/click can't natively navigate on cancel)
179
+ // and bail — nothing is enqueued, no timer is scheduled. window.confirm is
180
+ // synchronous + screen-reader friendly and keeps the no-dependency default;
181
+ // a richer/async dialog can be layered on additively later.
182
+ if (confirm && !window.confirm(confirm)) {
183
+ event.preventDefault()
184
+ return
185
+ }
186
+
158
187
  // Stop native behavior (button submit / FORM NAVIGATION) HERE, synchronously
159
188
  // within the event dispatch. preventDefault() only works while the event is
160
189
  // still being handled — deferring it into the request-queue microtask (below)
@@ -24,7 +24,7 @@ class <%= class_name %> < ApplicationComponent
24
24
  <% end -%>
25
25
 
26
26
  def view_template
27
- div(id:, **reactive_attrs) do
27
+ div(**reactive_root) do
28
28
  <% action_names.each do |a| -%>
29
29
  button(**on(:<%= a %>)) { "<%= a.to_s.humanize %>" }
30
30
  <% end -%>
@@ -51,7 +51,7 @@ class <%= class_name %> < ApplicationComponent
51
51
  <% end -%>
52
52
 
53
53
  def view_template
54
- div(id:, **reactive_attrs) do
54
+ div(**reactive_root) do
55
55
  <% state_keys.each do |k| -%>
56
56
  span(data: { <%= file_name %>_<%= k %>: true }) { @<%= k %>.to_s }
57
57
  <% end -%>
@@ -270,6 +270,26 @@ module Phlex
270
270
  }
271
271
  end
272
272
 
273
+ # The WHOLE reactive root in one spread (issue #48). reactive_attrs alone
274
+ # doesn't emit `id:`, so `id:` and `data-controller="reactive"` can land on
275
+ # DIFFERENT elements — putting `id:` on a child leaves the controller root's
276
+ # `id` empty, which silently breaks token threading (the client self-matches
277
+ # its next token by `this.element.id`) and 403s on the next action.
278
+ #
279
+ # reactive_root binds the id to the SAME element as reactive_attrs, so the
280
+ # footgun is unbuildable:
281
+ # div(**reactive_root) { ... } # id + controller + token
282
+ # div(**reactive_root(class: "card")) { ... } # add your own attrs
283
+ #
284
+ # mix deep-merges, so overrides add `class:`/`data:` without clobbering the
285
+ # controller/token data: (a bare data: would). The id is resolved separately
286
+ # (an explicit override wins as a clean replace, not a `mix` string-concat —
287
+ # mix would join two String ids into "default override").
288
+ def reactive_root(**overrides)
289
+ root_id = overrides.delete(:id) || id
290
+ mix({ **reactive_attrs }, overrides, { id: root_id })
291
+ end
292
+
273
293
  # Attributes for an element that triggers an action.
274
294
  # button(**on(:toggle)) { "○" }
275
295
  # form(**on(:save, event: "submit")) { ... }
@@ -284,13 +304,22 @@ module Phlex
284
304
  # live-update-as-you-type doesn't POST per keystroke. A blur flushes a
285
305
  # pending dispatch so the last edit is never dropped. Omit it for the
286
306
  # immediate-dispatch default.
307
+ #
308
+ # `confirm:` (a message string) gates the action behind a confirmation
309
+ # prompt (issue #52). Destructive reactive triggers can't use Hotwire's
310
+ # `data-turbo-confirm` — the reactive controller calls preventDefault and
311
+ # enqueues the POST itself, so Turbo's confirm handling never runs. The
312
+ # client shows window.confirm(message) FIRST and bails before any
313
+ # enqueue/debounce if the user declines (and prevents the native default so
314
+ # a `submit` trigger can't navigate on cancel). Omit it for no prompt.
315
+ # button(**on(:destroy, confirm: "Really delete this item?")) { "Delete" }
287
316
  # The verbatim JSON for an empty explicit-params payload. The common
288
317
  # trigger (on(:increment), no params) hits this on EVERY render — skipping
289
318
  # params.to_json (which re-serializes {} to the same "{}" each time) avoids
290
319
  # a per-render allocation while keeping the wire format byte-identical.
291
320
  EMPTY_PARAMS_JSON = "{}"
292
321
 
293
- def on(action_name, event: "click", debounce: nil, **params)
322
+ def on(action_name, event: "click", debounce: nil, confirm: nil, **params)
294
323
  attrs = {
295
324
  data: {
296
325
  action: "#{event}->reactive#dispatch",
@@ -299,6 +328,7 @@ module Phlex
299
328
  }
300
329
  }
301
330
  attrs[:data][:reactive_debounce_param] = debounce if debounce
331
+ attrs[:data][:reactive_confirm_param] = confirm if confirm
302
332
  attrs[:type] = "button" if event == "click"
303
333
  attrs
304
334
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.4.3"
5
+ VERSION = "0.4.5"
6
6
  end
7
7
  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.4.3
4
+ version: 0.4.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson