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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ec8a731ff4ae0e729288d805070e04c8860b7a51591a69b946ed42bd4996d97
4
- data.tar.gz: '018dafe55b68a05a46ef52c4e896803a9933beecf50e87f1d428073065fd3205'
3
+ metadata.gz: 68b6f765fc61e5be0b5c338effc94c2c4b283aa64d54f916e44767b4aecf62b9
4
+ data.tar.gz: e8c2e1c676b6d40d2d89e6acada741c2712746fd9e1bd5b9f54770c730a679b9
5
5
  SHA512:
6
- metadata.gz: 949549bc92c39def933aea8d83cf61db41ad686c8184c8a284657bdd99e1178dece129258e0e874ec1dd157748f69fcd11dc77fe140557c20d7dab217500a46b
7
- data.tar.gz: ab245aa3bf1a4f65f392ae2c4b3e98cf20a6d45064375a2c2168b2c7214ed4d27bc78c6cd0f069d2ef82cad771ee233376742de6e22636f5b0efb81390f5b8ea
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(id:, **reactive_attrs) do
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(id:, **reactive_attrs, class: ("done" if @todo.done?)) do
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
- | `reactive_attrs` | Spread onto the root element: marks it reactive + carries the signed token. |
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(**mix(reactive_attrs, id:, class: "card")) { ... }
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(id:, **reactive_attrs) do
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(id:, **reactive_attrs) do
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 match = html.match(/data-reactive-token-value="([^"]+)"/)
291
- return match?.[1]
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(id:, **reactive_attrs) do
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(id:, **reactive_attrs) do
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")) { ... }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.4.2"
5
+ VERSION = "0.4.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.4.2
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson