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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5644272b11bc8ff62496e05c81f01a92b1925ffe2b471ab123c05df573ee1343
4
- data.tar.gz: 56d13ad853e1b6da4e40e8e239838514171b9e908a59ad6db60f6f4a4b199b0e
3
+ metadata.gz: 83d418e64d127a079e74679e4920d85803fedff629e8d8b135c2896a79b5bbdd
4
+ data.tar.gz: 16c68638d96348ea94c0fa25d411486e49c0b2dab01f07fe9e67fefba630a537
5
5
  SHA512:
6
- metadata.gz: a489e7a7cdce253735c59c4b5196c52d2051e83f7fb783e7c892cbbd8b9be98934141392d7a84b58b938e80bfd5aba6e4bfe1995888b2b322d232a3fdc207a4a
7
- data.tar.gz: b9126364a92d1be370e6db639969803c25936e61910967fe05cac00ce908216dd5fa6f6f2f42f16d3c3c23c813d4e2e1c54614babb96f162f77bd71b6e3a5f6b
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.1...HEAD
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 (record-less widgets)
214
+ ### 2. State-backed (signed instance vars)
215
215
 
216
- No database row e.g. a counter or a wizard step. The listed instance vars are
217
- signed into the token. Keep state small and JSON-serializable.
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` | State-backed identity (signed instance vars). Record-less only. |
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 (`{component, gid}` or `{component, state}`), not raw state. A
318
- tampered class, record, or state value fails signature verification 400.
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 / form navigation): the reactive
46
- // round trip replaces it.
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.2.3"
5
+ VERSION = "0.2.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.2.3
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson