phlex-reactive 0.4.2 → 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 +35 -0
- data/README.md +20 -7
- data/app/javascript/phlex/reactive/reactive_controller.js +62 -2
- 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
|
|
@@ -72,6 +85,28 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
72
85
|
|
|
73
86
|
### Fixed
|
|
74
87
|
|
|
88
|
+
- **Client mirror of #44: collections of *reactive* rows were STILL add-once-only in
|
|
89
|
+
the browser — `#extractToken` read the FIRST token in the response, not this
|
|
90
|
+
controller's own (#46).** The server fix in 0.4.2 (#44) made the `add` response
|
|
91
|
+
correct — the container's fresh `reactive:token` stream is present — but the
|
|
92
|
+
client still broke. After each action the reactive controller stores the
|
|
93
|
+
response's fresh token for its NEXT dispatch; `#extractToken` did
|
|
94
|
+
`html.match(/data-reactive-token-value="([^"]+)"/)` — the FIRST match in the whole
|
|
95
|
+
body. On a collection of reactive rows the response is, in body order: the
|
|
96
|
+
appended/prepended ROW (carrying its OWN token, since a reactive row is itself a
|
|
97
|
+
`data-controller="reactive"` root) FIRST, then the container's `reactive:token`
|
|
98
|
+
refresh LAST. So the list controller stored the ROW's token; its second dispatch
|
|
99
|
+
sent a row token → failed verification → the second add silently did nothing. The
|
|
100
|
+
fix reads the token that RE-RENDERS this controller's own element id — preferring
|
|
101
|
+
the dedicated `reactive:token` stream targeting `this.element.id`, then a self
|
|
102
|
+
`replace`/`update` of it — and otherwise keeps the existing token (never adopting
|
|
103
|
+
a child row's or a sibling component's token). This is the exact client analogue
|
|
104
|
+
of the #44 server rule: a stream "carries the token for C" only when it re-renders
|
|
105
|
+
C itself, never when it inserts children into C. A request-level test can't catch
|
|
106
|
+
this (the server response is already correct); covered by a bun unit test on the
|
|
107
|
+
token selection AND a real two-click browser test (a collection of reactive rows →
|
|
108
|
+
three rows after three adds) under Puma + Falcon. Refs cosmos#1939, #44, #30.
|
|
109
|
+
|
|
75
110
|
- **Collections of *reactive* rows were still add-once-only — a prepended/appended
|
|
76
111
|
row's OWN token suppressed the container's token refresh (#44).** When an action
|
|
77
112
|
appended/prepended a *reactive* child row into a container whose `#id` equals the
|
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)) { "+" }
|
|
@@ -64,6 +64,13 @@ export function registerReactiveActions() {
|
|
|
64
64
|
registerReactiveToken()
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Escape a DOM id for safe interpolation into a RegExp (an id can legally contain
|
|
68
|
+
// regex metacharacters like `.`/`:` — e.g. an `escape:`-namespaced or dotted id).
|
|
69
|
+
// Used by #extractToken to match the stream that re-renders THIS element by id.
|
|
70
|
+
export function escapeRegExp(string) {
|
|
71
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
72
|
+
}
|
|
73
|
+
|
|
67
74
|
if (typeof window !== "undefined") {
|
|
68
75
|
if (window.Turbo) registerReactiveActions()
|
|
69
76
|
else document.addEventListener("turbo:load", registerReactiveActions, { once: true })
|
|
@@ -128,6 +135,22 @@ export default class extends Controller {
|
|
|
128
135
|
// guard above knows the controller was registered (issue #26 part 2).
|
|
129
136
|
connect() {
|
|
130
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
|
+
}
|
|
131
154
|
}
|
|
132
155
|
|
|
133
156
|
// Tear down any pending debounce timers when the controller leaves the DOM
|
|
@@ -286,9 +309,46 @@ export default class extends Controller {
|
|
|
286
309
|
this.#tokenCache = value
|
|
287
310
|
}
|
|
288
311
|
|
|
312
|
+
// Read the next token for THIS controller — the one that re-renders THIS
|
|
313
|
+
// element's id, never just the first token in the body (issue #46). On a
|
|
314
|
+
// collection of REACTIVE rows the prepended/appended ROW carries its OWN
|
|
315
|
+
// data-reactive-token-value and it sorts FIRST in the response; the list's own
|
|
316
|
+
// fresh token rides a trailing `reactive:token` stream targeting the container.
|
|
317
|
+
// Grabbing the first match stored the ROW's token, so the list's SECOND
|
|
318
|
+
// dispatch sent a row token → failed verification → add-once-only. This mirrors
|
|
319
|
+
// the server's carries_token_for? (#44): a stream carries OUR token only when it
|
|
320
|
+
// RE-RENDERS our id (reactive:token / replace / update of `this.element.id`) —
|
|
321
|
+
// append/prepend insert children and never count. Returns undefined when no
|
|
322
|
+
// stream re-renders our id, so #currentToken keeps its existing value.
|
|
289
323
|
#extractToken(html) {
|
|
290
|
-
const
|
|
291
|
-
|
|
324
|
+
const id = this.element.id
|
|
325
|
+
if (!id) {
|
|
326
|
+
// No id to self-match (shouldn't happen for a reactive root). Fall back to
|
|
327
|
+
// the legacy first-token behavior so a single-component response still works.
|
|
328
|
+
return html.match(/data-reactive-token-value="([^"]+)"/)?.[1]
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// The dedicated token-only refresh for THIS element (partial updates / the
|
|
332
|
+
// collection container) — an attribute on the <turbo-stream> itself.
|
|
333
|
+
const tokenStream = html.match(
|
|
334
|
+
new RegExp(
|
|
335
|
+
`<turbo-stream\\b[^>]*\\baction="reactive:token"[^>]*\\btarget="${escapeRegExp(id)}"[^>]*\\bdata-reactive-token-value="([^"]+)"`,
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
if (tokenStream) return tokenStream[1]
|
|
339
|
+
|
|
340
|
+
// A full self re-render: a replace/update of THIS element whose template root
|
|
341
|
+
// carries the fresh token. Scope the token search to that one stream so a
|
|
342
|
+
// sibling/child token elsewhere in the body can't leak in.
|
|
343
|
+
const selfStream = html.match(
|
|
344
|
+
new RegExp(
|
|
345
|
+
`<turbo-stream\\b[^>]*\\baction="(?:replace|update)"[^>]*\\btarget="${escapeRegExp(id)}"[^>]*>([\\s\\S]*?)</turbo-stream>`,
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
if (selfStream) return selfStream[1].match(/data-reactive-token-value="([^"]+)"/)?.[1]
|
|
349
|
+
|
|
350
|
+
// Nothing re-rendered our id — keep the current token.
|
|
351
|
+
return undefined
|
|
292
352
|
}
|
|
293
353
|
|
|
294
354
|
// True when `el` is collected by THIS reactive root and not by a nested one.
|
|
@@ -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")) { ... }
|