phlex-reactive 0.2.2 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11c82575cc72017be4f81341c0c2f999d626fe88ed8b81a20cfde246a412fe12
4
- data.tar.gz: '0996ececdc33cd2220a7a6517d49d1a59c5c583af891b937f1df32f433189a65'
3
+ metadata.gz: cd797b375cedb73b2a6f5695bf38a7eddd0a939aca5f8602c661537f6407ad7b
4
+ data.tar.gz: 994da28bed60c495d87d01fb736e80f19439ca52417f2c70f61a17aa7d15b351
5
5
  SHA512:
6
- metadata.gz: 24b3704443cd08380e30bb2bd4340d266a75a39beb80247a4ba26327a4c6f6429b0017fc94618b4063dc4f00a7a86f49ab88074c008d9be1c2ead0b266b5c8cd
7
- data.tar.gz: 4f858ded4f109ce0350603140572b4d9c694acd981785ac1913c0cf4c48630e6927d223073cd89fbb1f6a72e16bf7cea0603079661fff7b80740a4389ccbe732
6
+ metadata.gz: 11e13357335ec795cc5d0da9b60da2ce059128fed4f355f3864d037fbf5c0dab692844d3b8dd9d10f0b386e7bccd86ae635b2d2bc17a24e4ed1988bd37ce014e
7
+ data.tar.gz: 11c0e74b06f6d55818fc65d83336d9ec77eee65422adfe477a5ebc8a83d6dda4ae6f1dad3c6fdf17a9e215cdef5bcc958bcbaa43c3e6d32b02adb15124c372e1
data/CHANGELOG.md CHANGED
@@ -8,6 +8,49 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Fixed
10
10
 
11
+ - **Reactive save silently dropped rich-text / custom-editor fields.** The
12
+ client's field auto-collection (`#collectFields`) queried only
13
+ `input[name], select[name], textarea[name]`, so a named rich-text editor
14
+ (`lexxy-editor`, `trix-editor`) or any `[contenteditable]` was skipped — a
15
+ reactive `save` posted an empty value and silently wiped the field, with no
16
+ error. `#collectFields` now also reads named custom editors and contenteditable
17
+ elements (by `name` *attribute*, since a plain element has no `name` IDL
18
+ property), reading the serialized `.value` else the contenteditable text. It
19
+ only fills a name the standard controls left absent or empty, so a hidden input
20
+ a rich editor mirrors into (e.g. Trix) still wins when populated. The vendored
21
+ client copy the system suite runs (`spec/dummy/public/vendor/...`) is now kept
22
+ byte-identical to source by a guard spec, so the browser tests never validate
23
+ stale client code again. Closes #8.
24
+
25
+ ## [0.2.3] - 2026-06-24
26
+
27
+ ### Fixed
28
+
29
+ - **Record-backed components silently lost `reactive_state` every action.** When
30
+ a component declared BOTH `reactive_record` and `reactive_state`, the state
31
+ branch was dead: `reactive_token` signed only the record GID and
32
+ `from_identity` rebuilt with only the record — so the listed instance vars
33
+ (e.g. `attribute`, `editing`) reset to their `initialize` defaults on every
34
+ action. This broke the documented `inline_edit` example (clicking "edit"
35
+ couldn't stay in edit mode; `save` wrote the wrong/blank column) and quietly
36
+ voided the docs' promise that `@attribute` is signed/tamper-proof.
37
+ `reactive_token` now signs the record GID AND the declared state into one
38
+ `MessageVerifier` payload, and `from_identity` restores both — record + state
39
+ are composable, and the state stays tamper-proof. Record-only (`{c, gid}`) and
40
+ state-only (`{c, s}`) token shapes are unchanged; a signed `false` survives the
41
+ round trip (only a genuinely absent value falls back to the `initialize`
42
+ default). No API changes. Closes #6.
43
+
44
+ ### Documentation
45
+
46
+ - Documented the combined record + state identity (the `{c, gid, s}` token
47
+ shape): the README, `docs/architecture.md`, and `docs/security.md` no longer
48
+ frame `reactive_record` and `reactive_state` as mutually exclusive. (#9)
49
+
50
+ ## [0.2.2] - 2026-06-24
51
+
52
+ ### Fixed
53
+
11
54
  - **Record-backed component built with a different init keyword by the action
12
55
  endpoint vs the broadcast path.** The click path (`Component.from_identity`)
13
56
  builds with `reactive_record_key` (the `reactive_record :name`), but the
@@ -22,7 +65,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
22
65
  an explicit `def self.model_param_name` override still wins. No API changes.
23
66
  Closes #4.
24
67
 
25
- ## [0.2.1]
68
+ ## [0.2.1] - 2026-06-24
26
69
 
27
70
  ### Fixed
28
71
 
@@ -37,7 +80,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
37
80
  the gem eager-loads cleanly. Added a regression spec that calls
38
81
  `eager_load_all`. No API changes.
39
82
 
40
- ## [0.2.0]
83
+ ## [0.2.0] - 2026-06-24
41
84
 
42
85
  ### Added
43
86
 
@@ -56,7 +99,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
56
99
  helpers. phlex-reactive still works on Action Cable without pgbus; the
57
100
  `exclude:` argument is simply ignored there.
58
101
 
59
- ## [0.1.0]
102
+ ## [0.1.0] - 2026-06-20
60
103
 
61
104
  ### Added
62
105
 
@@ -80,7 +123,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
80
123
  scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
81
124
  state-backed by default or record-backed with `--record`.
82
125
 
83
- [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.1...HEAD
126
+ [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.2...HEAD
127
+ [0.2.3]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.2...v0.2.3
128
+ [0.2.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.1...v0.2.2
84
129
  [0.2.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.0...v0.2.1
85
130
  [0.2.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.1.0...v0.2.0
86
131
  [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
@@ -122,6 +122,7 @@ export default class extends Controller {
122
122
 
123
123
  #collectFields() {
124
124
  const fields = {}
125
+ // Standard form controls.
125
126
  this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
126
127
  if (field.type === "checkbox") {
127
128
  fields[field.name] = field.checked
@@ -131,6 +132,25 @@ export default class extends Controller {
131
132
  fields[field.name] = field.value
132
133
  }
133
134
  })
135
+ // Named rich-text / custom editors (lexxy-editor, trix-editor) and bare
136
+ // [contenteditable]. These aren't input/select/textarea, so the query above
137
+ // skips them — without this, a reactive save posts an empty value and
138
+ // silently wipes the field (issue #8). Read whatever the element exposes:
139
+ // a custom editor's serialized `.value`, else its contenteditable text.
140
+ // Only fill a name the standard controls left absent or empty, so a synced
141
+ // hidden input (e.g. Trix mirrors into one) still wins when populated.
142
+ this.element
143
+ .querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])")
144
+ .forEach((el) => {
145
+ // A plain element (e.g. a <div contenteditable>) has no `name` IDL
146
+ // property — only the attribute — so read getAttribute, not el.name.
147
+ const name = el.getAttribute("name")
148
+ if (!name) return
149
+ const existing = fields[name]
150
+ if (existing == null || existing === "") {
151
+ fields[name] = el.value ?? el.textContent ?? el.innerHTML ?? ""
152
+ }
153
+ })
134
154
  return fields
135
155
  }
136
156
 
@@ -20,8 +20,12 @@ module Phlex
20
20
  # can neither forge the component class nor swap the record. State =
21
21
  # the database.
22
22
  # * State-backed (record-less, e.g. a counter): reactive_state :count
23
- # signs the listed instance variables. Use only when there is genuinely
24
- # no record to re-find.
23
+ # signs the listed instance variables. Use when there is genuinely no
24
+ # record to re-find.
25
+ # * Both (the inline_edit pattern): reactive_record :record plus
26
+ # reactive_state :attribute, :editing signs the record's GlobalID AND
27
+ # the transient mode in one token, so "which field / what mode" survives
28
+ # every action round trip and stays tamper-proof.
25
29
  #
26
30
  # Actions are DEFAULT-DENY: only methods declared with `action :name` may be
27
31
  # invoked. The signature proves the token is ours, NOT that this user may
@@ -102,17 +106,35 @@ module Phlex
102
106
 
103
107
  # Rebuild a component instance from a verified identity payload. Called
104
108
  # by the action endpoint after the token signature is verified.
109
+ #
110
+ # A component may carry a record (re-found via GlobalID), signed state
111
+ # (instance vars listed in reactive_state), or BOTH (the inline_edit
112
+ # pattern: a record plus "which field / what mode"). We assemble the
113
+ # init kwargs from whichever identity pieces are declared.
105
114
  def from_identity(payload)
115
+ kwargs = {}
116
+
106
117
  if reactive_record_key
107
118
  record = GlobalID::Locator.locate(payload.fetch("gid"))
108
119
  raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
109
120
 
110
- new(reactive_record_key => record)
111
- else
121
+ kwargs[reactive_record_key] = record
122
+ end
123
+
124
+ if reactive_state_keys.any?
112
125
  state = payload.fetch("s", {})
113
- kwargs = reactive_state_keys.to_h { |k| [k, state[k.to_s]] }.compact
114
- new(**kwargs)
126
+ reactive_state_keys.each do |key|
127
+ # Use key presence, not the value: a signed `nil` (nullable state)
128
+ # must round-trip distinctly. Only a genuinely absent key falls
129
+ # back to the component's initialize default; `false` and `nil`
130
+ # both survive.
131
+ next unless state.key?(key.to_s)
132
+
133
+ kwargs[key] = state[key.to_s]
134
+ end
115
135
  end
136
+
137
+ new(**kwargs)
116
138
  end
117
139
  end
118
140
 
@@ -164,18 +186,25 @@ module Phlex
164
186
 
165
187
  private
166
188
 
167
- # Signed { c, gid } (record-backed) or { c, s } (state-backed).
189
+ # Signed identity payload: the class name plus whichever identity pieces
190
+ # the component declares — a record GlobalID (`gid`), signed state (`s`),
191
+ # or both. Keeping them in ONE MessageVerifier payload makes the state
192
+ # (e.g. which column an inline_edit may write) tamper-proof alongside the
193
+ # record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
194
+ # unchanged.
168
195
  def reactive_token
169
- payload =
170
- if self.class.reactive_record_key
171
- record = instance_variable_get(:"@#{self.class.reactive_record_key}")
172
- {"c" => self.class.name, "gid" => record.to_gid.to_s}
173
- else
174
- state = self.class.reactive_state_keys.to_h do |k|
175
- [k.to_s, instance_variable_get(:"@#{k}").as_json]
176
- end
177
- {"c" => self.class.name, "s" => state}
196
+ payload = {"c" => self.class.name}
197
+
198
+ if self.class.reactive_record_key
199
+ record = instance_variable_get(:"@#{self.class.reactive_record_key}")
200
+ payload["gid"] = record.to_gid.to_s
201
+ end
202
+
203
+ if self.class.reactive_state_keys.any?
204
+ payload["s"] = self.class.reactive_state_keys.to_h do |k|
205
+ [k.to_s, instance_variable_get(:"@#{k}").as_json]
178
206
  end
207
+ end
179
208
 
180
209
  Phlex::Reactive.sign(payload)
181
210
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.2.2"
5
+ VERSION = "0.2.4"
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.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson