phlex-reactive 0.2.3 → 0.2.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 +4 -4
- data/CHANGELOG.md +51 -4
- data/README.md +14 -6
- data/app/javascript/phlex/reactive/reactive_controller.js +33 -7
- 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: 83d418e64d127a079e74679e4920d85803fedff629e8d8b135c2896a79b5bbdd
|
|
4
|
+
data.tar.gz: 16c68638d96348ea94c0fa25d411486e49c0b2dab01f07fe9e67fefba630a537
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cc16a212daaeb236631702db89fe846dabf936d532e18919c0a371d408f29ce94ae94028eff8cc82e3ff66f1ee25a8acb75f2df29f0b1e2ceb819e49ee3b7217
|
|
7
|
+
data.tar.gz: 0b9721189cb032d6a91ae163b15742cb1cb163ef93da4b5ced1d4382811b43da5b5ba3ea25041b5919b20fb58d2dd4d582d8c1ba1345b6d8accf7ac94cdeb978
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,40 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Fixed
|
|
10
10
|
|
|
11
|
+
- **Form submit navigated instead of running the reactive action.** A component
|
|
12
|
+
wired with `on(:save, event: "submit")` on a real `<form>` let the browser
|
|
13
|
+
submit natively (full POST to the form's `action`), because
|
|
14
|
+
`dispatch()` deferred `event.preventDefault()` into the request-queue microtask
|
|
15
|
+
— too late, since `preventDefault()` only works synchronously within the event
|
|
16
|
+
dispatch. For `click` triggers there's no default to miss, so it was invisible;
|
|
17
|
+
for `submit` the form navigated (e.g. `POST /` → routing error). `dispatch()`
|
|
18
|
+
now calls `preventDefault()` synchronously (and captures the action/params up
|
|
19
|
+
front, so the deferred work never re-reads a reset event object). Guarded by a
|
|
20
|
+
bun unit test (`spec/javascript/reactive_controller.test.js`) that asserts the
|
|
21
|
+
synchronous call and fails on the pre-fix deferred code. Closes #11.
|
|
22
|
+
|
|
23
|
+
## [0.2.4] - 2026-06-24
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Reactive save silently dropped rich-text / custom-editor fields.** The
|
|
28
|
+
client's field auto-collection (`#collectFields`) queried only
|
|
29
|
+
`input[name], select[name], textarea[name]`, so a named rich-text editor
|
|
30
|
+
(`lexxy-editor`, `trix-editor`) or any `[contenteditable]` was skipped — a
|
|
31
|
+
reactive `save` posted an empty value and silently wiped the field, with no
|
|
32
|
+
error. `#collectFields` now also reads named custom editors and contenteditable
|
|
33
|
+
elements (by `name` *attribute*, since a plain element has no `name` IDL
|
|
34
|
+
property), reading the serialized `.value` else the contenteditable text. It
|
|
35
|
+
only fills a name the standard controls left absent or empty, so a hidden input
|
|
36
|
+
a rich editor mirrors into (e.g. Trix) still wins when populated. The vendored
|
|
37
|
+
client copy the system suite runs (`spec/dummy/public/vendor/...`) is now kept
|
|
38
|
+
byte-identical to source by a guard spec, so the browser tests never validate
|
|
39
|
+
stale client code again. Closes #8.
|
|
40
|
+
|
|
41
|
+
## [0.2.3] - 2026-06-24
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
11
45
|
- **Record-backed components silently lost `reactive_state` every action.** When
|
|
12
46
|
a component declared BOTH `reactive_record` and `reactive_state`, the state
|
|
13
47
|
branch was dead: `reactive_token` signed only the record GID and
|
|
@@ -23,6 +57,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
23
57
|
round trip (only a genuinely absent value falls back to the `initialize`
|
|
24
58
|
default). No API changes. Closes #6.
|
|
25
59
|
|
|
60
|
+
### Documentation
|
|
61
|
+
|
|
62
|
+
- Documented the combined record + state identity (the `{c, gid, s}` token
|
|
63
|
+
shape): the README, `docs/architecture.md`, and `docs/security.md` no longer
|
|
64
|
+
frame `reactive_record` and `reactive_state` as mutually exclusive. (#9)
|
|
65
|
+
|
|
66
|
+
## [0.2.2] - 2026-06-24
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
26
70
|
- **Record-backed component built with a different init keyword by the action
|
|
27
71
|
endpoint vs the broadcast path.** The click path (`Component.from_identity`)
|
|
28
72
|
builds with `reactive_record_key` (the `reactive_record :name`), but the
|
|
@@ -37,7 +81,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
37
81
|
an explicit `def self.model_param_name` override still wins. No API changes.
|
|
38
82
|
Closes #4.
|
|
39
83
|
|
|
40
|
-
## [0.2.1]
|
|
84
|
+
## [0.2.1] - 2026-06-24
|
|
41
85
|
|
|
42
86
|
### Fixed
|
|
43
87
|
|
|
@@ -52,7 +96,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
52
96
|
the gem eager-loads cleanly. Added a regression spec that calls
|
|
53
97
|
`eager_load_all`. No API changes.
|
|
54
98
|
|
|
55
|
-
## [0.2.0]
|
|
99
|
+
## [0.2.0] - 2026-06-24
|
|
56
100
|
|
|
57
101
|
### Added
|
|
58
102
|
|
|
@@ -71,7 +115,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
71
115
|
helpers. phlex-reactive still works on Action Cable without pgbus; the
|
|
72
116
|
`exclude:` argument is simply ignored there.
|
|
73
117
|
|
|
74
|
-
## [0.1.0]
|
|
118
|
+
## [0.1.0] - 2026-06-20
|
|
75
119
|
|
|
76
120
|
### Added
|
|
77
121
|
|
|
@@ -95,7 +139,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
95
139
|
scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
|
|
96
140
|
state-backed by default or record-backed with `--record`.
|
|
97
141
|
|
|
98
|
-
[Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.
|
|
142
|
+
[Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.3...HEAD
|
|
143
|
+
[0.2.4]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.3...v0.2.4
|
|
144
|
+
[0.2.3]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.2...v0.2.3
|
|
145
|
+
[0.2.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.1...v0.2.2
|
|
99
146
|
[0.2.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.0...v0.2.1
|
|
100
147
|
[0.2.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.1.0...v0.2.0
|
|
101
148
|
[0.1.0]: https://github.com/mhenrixon/phlex-reactive/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -211,15 +211,21 @@ class Todos::Item < ApplicationComponent
|
|
|
211
211
|
end
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
-
### 2. State-backed (
|
|
214
|
+
### 2. State-backed (signed instance vars)
|
|
215
215
|
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
Sign small, JSON-serializable instance vars into the token. Use it **alone** for
|
|
217
|
+
a record-less widget (a counter, a wizard step), or **alongside `reactive_record`**
|
|
218
|
+
to carry transient UI state — which field, what mode — next to the row. Both the
|
|
219
|
+
record's GlobalID and the state are signed into one token and rebuilt on each
|
|
220
|
+
action. Keep state small and JSON-serializable.
|
|
218
221
|
|
|
219
222
|
```ruby
|
|
220
223
|
reactive_state :count, :step # signed; rebuilt on each action
|
|
221
224
|
```
|
|
222
225
|
|
|
226
|
+
The [inline edit example](docs/examples/inline_edit.md) combines both: a
|
|
227
|
+
`reactive_record :record` plus `reactive_state :attribute, :editing`.
|
|
228
|
+
|
|
223
229
|
---
|
|
224
230
|
|
|
225
231
|
## Concrete examples
|
|
@@ -257,7 +263,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
257
263
|
| Macro / helper | Use |
|
|
258
264
|
|---|---|
|
|
259
265
|
| `reactive_record :name` | Record-backed identity (GlobalID). State = the DB. |
|
|
260
|
-
| `reactive_state :a, :b` |
|
|
266
|
+
| `reactive_state :a, :b` | Signed instance-var identity. Standalone, or combined with `reactive_record` to sign transient UI state alongside the row. |
|
|
261
267
|
| `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
|
|
262
268
|
| `reactive_attrs` | Spread onto the root element: marks it reactive + carries the signed token. |
|
|
263
269
|
| `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
|
|
@@ -314,8 +320,10 @@ phlex-reactive is built so the easy path is the safe path — but the boundary i
|
|
|
314
320
|
real, so read this once.
|
|
315
321
|
|
|
316
322
|
- **State is never trusted from the client.** The DOM holds a `MessageVerifier`-
|
|
317
|
-
signed identity
|
|
318
|
-
|
|
323
|
+
signed identity — `{component, gid}` (record-backed), `{component, state}`
|
|
324
|
+
(state-backed), or `{component, gid, state}` when a component declares both —
|
|
325
|
+
not raw state. A tampered class, record, or state value fails signature
|
|
326
|
+
verification → 400.
|
|
319
327
|
- **Actions are default-deny.** Only methods declared with `action :name` are
|
|
320
328
|
invokable. A public method without `action` is unreachable.
|
|
321
329
|
- **You must authorize.** The signature proves the *token is yours*, not that
|
|
@@ -34,18 +34,24 @@ export default class extends Controller {
|
|
|
34
34
|
// a per-controller promise makes each dispatch wait for the previous one, so
|
|
35
35
|
// it always uses the freshest token.
|
|
36
36
|
dispatch(event) {
|
|
37
|
-
this.queue = (this.queue ?? Promise.resolve()).then(() => this.#perform(event))
|
|
38
|
-
return this.queue
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async #perform(event) {
|
|
42
37
|
const { action, params } = event.params
|
|
43
38
|
if (!action) return
|
|
44
39
|
|
|
45
|
-
// Stop native behavior (button submit /
|
|
46
|
-
//
|
|
40
|
+
// Stop native behavior (button submit / FORM NAVIGATION) HERE, synchronously
|
|
41
|
+
// within the event dispatch. preventDefault() only works while the event is
|
|
42
|
+
// still being handled — deferring it into the request-queue microtask (below)
|
|
43
|
+
// is too late: a `submit` trigger would natively POST the form and navigate
|
|
44
|
+
// before the reactive round trip runs (issue #11). For a `click` trigger
|
|
45
|
+
// there's no default to miss, so this was previously invisible.
|
|
47
46
|
event.preventDefault()
|
|
48
47
|
|
|
48
|
+
// Capture action/params now; the queued work runs in a later microtask, by
|
|
49
|
+
// which point the event object may have been reset by the browser.
|
|
50
|
+
this.queue = (this.queue ?? Promise.resolve()).then(() => this.#perform(action, params))
|
|
51
|
+
return this.queue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async #perform(action, params) {
|
|
49
55
|
// Auto-collect named field values inside this component so a button-
|
|
50
56
|
// triggered action still receives sibling inputs (Livewire-style).
|
|
51
57
|
// Explicit params (data-reactive-params-param) win over collected fields.
|
|
@@ -122,6 +128,7 @@ export default class extends Controller {
|
|
|
122
128
|
|
|
123
129
|
#collectFields() {
|
|
124
130
|
const fields = {}
|
|
131
|
+
// Standard form controls.
|
|
125
132
|
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
126
133
|
if (field.type === "checkbox") {
|
|
127
134
|
fields[field.name] = field.checked
|
|
@@ -131,6 +138,25 @@ export default class extends Controller {
|
|
|
131
138
|
fields[field.name] = field.value
|
|
132
139
|
}
|
|
133
140
|
})
|
|
141
|
+
// Named rich-text / custom editors (lexxy-editor, trix-editor) and bare
|
|
142
|
+
// [contenteditable]. These aren't input/select/textarea, so the query above
|
|
143
|
+
// skips them — without this, a reactive save posts an empty value and
|
|
144
|
+
// silently wipes the field (issue #8). Read whatever the element exposes:
|
|
145
|
+
// a custom editor's serialized `.value`, else its contenteditable text.
|
|
146
|
+
// Only fill a name the standard controls left absent or empty, so a synced
|
|
147
|
+
// hidden input (e.g. Trix mirrors into one) still wins when populated.
|
|
148
|
+
this.element
|
|
149
|
+
.querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])")
|
|
150
|
+
.forEach((el) => {
|
|
151
|
+
// A plain element (e.g. a <div contenteditable>) has no `name` IDL
|
|
152
|
+
// property — only the attribute — so read getAttribute, not el.name.
|
|
153
|
+
const name = el.getAttribute("name")
|
|
154
|
+
if (!name) return
|
|
155
|
+
const existing = fields[name]
|
|
156
|
+
if (existing == null || existing === "") {
|
|
157
|
+
fields[name] = el.value ?? el.textContent ?? el.innerHTML ?? ""
|
|
158
|
+
}
|
|
159
|
+
})
|
|
134
160
|
return fields
|
|
135
161
|
}
|
|
136
162
|
|