phlex-reactive 0.4.3 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +20 -7
- data/app/javascript/phlex/reactive/reactive_controller.js +16 -0
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +2 -2
- data/lib/phlex/reactive/component.rb +20 -0
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68b6f765fc61e5be0b5c338effc94c2c4b283aa64d54f916e44767b4aecf62b9
|
|
4
|
+
data.tar.gz: e8c2e1c676b6d40d2d89e6acada741c2712746fd9e1bd5b9f54770c730a679b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 23c24dcacc6defa4d9479c82aa4cd01778e7a7e02cbe2893ef18539becab491de69195d7da50a5876001633e8f3bfe73a582298a7b4161f2d3f4d5c882caef76
|
|
7
|
+
data.tar.gz: a2373220f92ad0a9b84ef2ab555b0d6453e889792a334cb89a46d221acd4d6bce64864aa0b7b32e493461613d7fce076f46eb1fe3565a6eb91a7b19b47f01b36
|
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(
|
|
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(
|
|
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
|
-
| `
|
|
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(**
|
|
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(
|
|
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(
|
|
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
|
|
@@ -24,7 +24,7 @@ class <%= class_name %> < ApplicationComponent
|
|
|
24
24
|
<% end -%>
|
|
25
25
|
|
|
26
26
|
def view_template
|
|
27
|
-
div(
|
|
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(
|
|
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")) { ... }
|