phlex-reactive 0.16.0 → 0.16.2

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +282 -145
  3. data/README.md +85 -1
  4. data/app/controllers/phlex/reactive/actions_controller.rb +10 -0
  5. data/app/javascript/phlex/reactive/core.js +1 -0
  6. data/app/javascript/phlex/reactive/core.min.js +4 -4
  7. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  8. data/app/javascript/phlex/reactive/features/bindings.js +81 -4
  9. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  10. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  11. data/app/javascript/phlex/reactive/features/drag.js +522 -0
  12. data/app/javascript/phlex/reactive/features/drag.min.js +4 -0
  13. data/app/javascript/phlex/reactive/features/drag.min.js.map +10 -0
  14. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  15. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  16. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +7 -6
  17. data/app/javascript/phlex/reactive/runtime.js +57 -23
  18. data/lib/phlex/reactive/component/css_vars_binding.rb +118 -0
  19. data/lib/phlex/reactive/component/drag.rb +270 -0
  20. data/lib/phlex/reactive/component/dsl.rb +3 -0
  21. data/lib/phlex/reactive/component/helpers.rb +5 -1
  22. data/lib/phlex/reactive/component/registry.rb +15 -5
  23. data/lib/phlex/reactive/engine.rb +14 -5
  24. data/lib/phlex/reactive/held_fields.rb +21 -1
  25. data/lib/phlex/reactive/param_schema.rb +5 -1
  26. data/lib/phlex/reactive/response.rb +55 -4
  27. data/lib/phlex/reactive/shareable.rb +18 -3
  28. data/lib/phlex/reactive/test_helpers.rb +6 -0
  29. data/lib/phlex/reactive/version.rb +1 -1
  30. data/sig/phlex/reactive/response.rbs +3 -0
  31. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b96289b4a91d050b1631c5c31663714e3aba33370f1a4e8d87d04c662fa3ed81
4
- data.tar.gz: 55c7126c343c696e66f4baf5d4c62d09a2e8fa8861c1bbe04b9b5f67c265c42c
3
+ metadata.gz: 96630c84c36efda322bafa8792e9e00bc0827d727a8d9db9c0f3191916f3a561
4
+ data.tar.gz: 2fc0570f0888b0905f04573b0ee8ecc71210ad08e5e10f430067b7141d1c468f
5
5
  SHA512:
6
- metadata.gz: c4298ac14bf00c485324d9b61ae72fb812e0c87f1afd66cc09a24c2f0dd0917b7bf40458dd37223ba79650b4cf8a3bd44745cb7bf66d74f27f19021a3969b1a5
7
- data.tar.gz: a652652cfd5a2c7439c846afb421c8d99ff3cf902276da910e05228c7313cf9c3662c8c5391cfaab1316ec4399115d96cd5441fa9d4a8dbee74e032831d419d5
6
+ metadata.gz: c8487e66e0f93336442b6edbaaaa2cd2afdf0e18c809783e9001f25b1aaaf7525baab624c8d6b20a3acc896ccb1213423123d4ef914d335dcd147a4b19ab4927
7
+ data.tar.gz: 02d982c7a73c156d52d5a707e1745ea4de5df759f8d7c9d402c51c1ddabd68800d12a8972bf18fe9f5d10e074844922c6b31eec228508ebd14acb8ccac85d58b
data/CHANGELOG.md CHANGED
@@ -6,7 +6,100 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.16.0]
9
+ ## [0.16.2] - 2026-10-17
10
+
11
+ ### Fixed
12
+
13
+ - **An app on Rails main boots in Ractor mode with phlex-reactive mounted.**
14
+ Rails main's `RouteSet#finalize!` makes the route set Ractor-shareable, the
15
+ blocks engines append included. The engine's route block had the engine as
16
+ its `self`, so with `ActiveSupport::Ractors.unshareable_proc_action = :raise`
17
+ boot raised `Ractor::IsolationError: Proc's self is not shareable`. On Ruby
18
+ 4.0+ the block is now built with `Ractor.shareable_proc`; the routes it
19
+ draws are unchanged, and older Rubies get the same block as before.
20
+
21
+ - **A warmed component's class can be read from a non-main Ractor.** Boot's
22
+ `Component::Registry.warm!` froze each class's resolution cache only at the
23
+ top level, so the action definitions in it (their `params` Hash, their
24
+ compiled `ParamSchema`) stayed mutable, and the class's own declarations
25
+ (`@reactive_own_actions`, `@reactive_own_state_keys`, …) were not frozen at
26
+ all. Every reactive render or action in a non-main Ractor raised
27
+ `Ractor::IsolationError: can not get unshareable values from instance
28
+ variables of classes/modules`. warm! now shares the cache, every
29
+ `@reactive_own_*` declaration and the render memos deeply
30
+ (`Phlex::Reactive::Shareable.share`, which learned to copy `Data` values
31
+ that keep Data's own `initialize`; one with its own is kept as it is);
32
+ a compiled `ParamSchema` is frozen with a deep-frozen copy of its schema,
33
+ and `action` keeps a frozen copy of `params:`, so the app's own Hash is
34
+ never frozen. A value no Ractor can share (a `reactive_collection size:`
35
+ lambda that assigns what it captures) is kept as it is and works on the
36
+ main Ractor; boot never raises for it. A declaration after warm! (a
37
+ development reload) writes to a fresh copy, as before. The Ractor canary
38
+ now reads a warmed component with an action that takes params.
39
+
40
+ ## [0.16.1] - 2026-10-17
41
+
42
+ ### Added
43
+
44
+ - **Draggable regions: move and resize a box, or move a point, inside a
45
+ frame, with the result in fields (#416).** `reactive_frame` marks the
46
+ coordinate space, `reactive_box(fields:, value:, aspect:, min:, action:,
47
+ busy:, messages:)` a box in it, `reactive_box_handle(edge)` an edge or corner
48
+ to resize from, and `reactive_point(fields:, value:, within:, …)` a point,
49
+ inside the frame or inside a box. The helpers render the position as
50
+ percentages from `value:`, so the first paint needs no JavaScript and a
51
+ re-render with new values moves the box. The box moves and resizes with a
52
+ mouse, a pen, a finger or the keyboard (arrows 1%, Shift 10%, Alt resizes,
53
+ Escape puts it back, each step announced in the words of `messages:`). It
54
+ stays inside the frame and never goes below `min:`. `aspect:` locks the
55
+ ratio (a Numeric, or a field such as a row of preset radios, which re-fits
56
+ the box when it changes). The fields hold fractions rounded to 4 decimals,
57
+ with `input` as the box moves (at most once per field a frame) and one
58
+ `change` on release. `action:` is sent once per release through the root's
59
+ pipeline, with `x`, `y`, `width`, `height`. Without it the binding is
60
+ client-only and works on a `ClientBindings` root. A morph that lands
61
+ mid-drag does not interrupt it. The helpers check their arguments' keys,
62
+ types and ranges at render (an undeclared action under `verbose_errors`).
63
+ Client: a new `drag` feature module (3,576 B gzipped): +2,789 B in the
64
+ default bundle, +51 B in the split core, which loads it only on a page with
65
+ a frame, a box or a point. Docs: a "Draggable regions" guide with a live demo.
66
+
67
+ - **Fields that feed CSS custom properties: `reactive_css_vars` (#417).** A
68
+ preview that follows a control (a slider that brightens an image, a select
69
+ that rotates it) needed app JavaScript or a request per input event.
70
+ `reactive_css_vars(to: "#sample", vars: { "--light" => "light",
71
+ "--turn" => { field: "turn", unit: "deg" } })` reads each field as
72
+ `reactive_show` does (a checkbox gives `1` or `0`, a radio group its checked
73
+ value), parses it as a finite number and writes `number + unit` on every
74
+ `to:` element with `style.setProperty`, on connect, on `input`/`change` and
75
+ after a morph, with no request. Anything that is not a number removes the
76
+ property, so the `var()` fallback applies: no text from a field reaches CSS.
77
+ Units come from a fixed list (`""`, `px`, `%`, `deg`, `turn`, `ms`, `s`,
78
+ `em`, `rem`, `fr`) and names match `--[a-zA-Z0-9-]+`, checked at render and in
79
+ the browser. `to:` resolves like a client op's (`:root`, inside the root,
80
+ `global: true`, an Array). It goes on the root or on any element inside it,
81
+ and works on a `ClientBindings` view. Client: +460 B gzipped in the default
82
+ bundle; +462 B in `features/bindings` and +16 B in the split core.
83
+ Docs: "Live CSS properties" on the client-ops page, with a demo.
84
+
85
+ ### Fixed
86
+
87
+ - **A reply that refuses the posted values keeps the held marks (#413).**
88
+ Every reply to a root's own request released the fields a morph held
89
+ (#370), so after an action refused a save the user's next keystroke posted
90
+ no held names and went through over the other change. Return
91
+ `reply.morph.refused` (every mark) or `reply.morph.refused("title")` (only
92
+ those fields) and the client keeps the marks: the root's next action names
93
+ them in `reactive_held_fields` again, until the action stores the field or
94
+ the user takes the other value. The endpoint sends it as the
95
+ `X-Reactive-Refused` response header; the reply stays a 200, with no error
96
+ marker and no `reactive:error`. `run_reactive(…).refused?` and
97
+ `response.refused?` / `response.refused_fields` assert it in a unit test.
98
+ Client: +65 B gzipped in the default bundle, +60 B in the split core.
99
+ Docs: "Knowing what was held" on the architecture page, the `reply` table
100
+ in the README.
101
+
102
+ ## [0.16.0] - 2026-10-16
10
103
 
11
104
  ### Upgrading from 0.15.x
12
105
 
@@ -103,8 +196,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
103
196
 
104
197
  ## [0.15.5] - 2026-10-15
105
198
 
106
- Consolidates everything since 0.14.1; tags 0.14.2–0.15.4 shipped without
107
- changelog sections, so this section spans the whole run to 0.15.5.
199
+ ### Fixed
200
+
201
+ - **A server trigger inside a client-only root reaches the component around it
202
+ (#410).** A `ClientBindings` component (a `reactive_filter` list, a
203
+ `reactive_show` wrapper, an `on_client` menu) renders a root with no token.
204
+ Rendered inside a server component, it swallowed that component's `on(...)`
205
+ triggers: Stimulus sends the action to the nearest root, which had no token,
206
+ and the outer root's field collection skipped every control under it (the
207
+ #15 rule). A tokenless client-only root is now transparent to server round
208
+ trips: `dispatch` forwards to the nearest token-bearing root above (skipping
209
+ further client-only roots, waking a dormant one), and that root's action
210
+ collects the controls under nested client-only roots. A nested server
211
+ component keeps its own fields and triggers, and client bindings stay on
212
+ their own root. With no token-bearing root above, nothing is sent, and
213
+ `verbose_errors`/debug logs one warning. Lazy and deferred shells are not
214
+ client-only. Client: +414 B gzipped in the default bundle, +415 B in the
215
+ split core.
216
+
217
+ ## [0.15.4] - 2026-10-14
108
218
 
109
219
  ### Added
110
220
 
@@ -276,6 +386,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
276
386
  anything else raises at render. Client: +27 B gzipped in the default
277
387
  bundle, +20 B in the persist module; the split core unchanged.
278
388
 
389
+ ## [0.15.3] - 2026-10-13
390
+
391
+ ### Added
392
+
279
393
  - **An image fallback that recovers — `reactive_image_fallback(hide: true, show_alt: nil)` (#383).**
280
394
  `img(**reactive_image_fallback)` marks an `<img>` whose `src` fails with
281
395
  `data-reactive-image-state="error"` and hides it; `show_alt: "#bank-name"`
@@ -296,6 +410,91 @@ changelog sections, so this section spans the whole run to 0.15.5.
296
410
  +550 B in the split core. Docs: "An image that falls back, and recovers" on
297
411
  the client-ops example.
298
412
 
413
+ ### Fixed
414
+
415
+ - **`reactive_media` waits for `take` characters before calling a key a miss
416
+ (#380).** A value shorter than `take:` sliced to a short key, missed the
417
+ pool and wrote `blank_label` while the user was still typing. It is now
418
+ *pending*: `blank_label` only names a full-length key that misses. New
419
+ options: `match:` (a Regexp, anchored to the whole key; a key it rejects is
420
+ pending too) and `pending: :clear` (default: clear the preview, empty the
421
+ label) or `:keep` (leave both alone). The preview carries
422
+ `data-reactive-media-state="pending|hit|miss"`, so a row can hide itself
423
+ with CSS. `match:` reaches the browser as a JS pattern: its `\A`/`\z`
424
+ anchors are dropped (a trailing `\Z` becomes `\n?`), and what JS reads
425
+ differently raises at render (options, escapes such as `\h`/`\p{…}`/`\s`/
426
+ `\u{…}`, POSIX and nested classes, `&&`, `(?i)`/`(?>…)`, characters above
427
+ U+FFFF). **Behaviour change:** with `take:`, a short value now leaves the
428
+ label empty instead of writing `blank_label`. Without `take:` or `match:`
429
+ nothing changes. Client: +160 B gzipped in the default bundle (+152 B on
430
+ top of #381/#382), +152 B in the bindings module, the split core unchanged. Docs: "Media
431
+ previews" on the client-ops example.
432
+
433
+ - **A pool `<img>` with an `alt` and no `src` keeps its name (#381).**
434
+ `js.copy_media` and `reactive_media` treated a source `<img>` without `src`
435
+ as a clear: the preview's `alt` was emptied and `label:` got `blank_label`,
436
+ so a known entry with no logo file lost its name. Such a source is now a hit
437
+ without an image: the target's `src` and `srcset` are removed, its `alt` is
438
+ set, and `label:` gets that `alt`. Only a pool miss, a selector that matches
439
+ nothing, or a source that is not an `<img>` clears. Client: +1 B gzipped in
440
+ the default bundle, −2 B in the split core; the bindings module is
441
+ unchanged.
442
+
443
+ - **`js.preview_file` follows an app's upload validation (#382).** A preview
444
+ ran once, on `change`, so a file the app then rejected (cleared with
445
+ `input.value = ""`, no new `change`) stayed on screen. Two pieces fix it.
446
+ `js.preview_file(…, valid: true)` treats an input whose `validity.valid` is
447
+ false (`setCustomValidity`, `required`) as empty: it never gets a preview,
448
+ and one it had is cleared and its URL revoked. And `reactive:file-changed`
449
+ is the documented event for "the app changed this input's files without a
450
+ `change` event": bind the op to it as well,
451
+ `mix(on_client(:change, op), on_client("reactive:file-changed", op))`, and
452
+ the app dispatches it after it clears or invalidates the field. That works
453
+ whichever `change` listener runs first. Without `valid:` the op behaves as
454
+ before. Client: +22 B gzipped in the default bundle, +34 B in the split
455
+ core (on top of #381). Docs: "Preview a chosen image" on the client-ops example.
456
+
457
+ ### Performance
458
+
459
+ - **Guarded and persisted forms connect in linear time.** Each `warn_unsaved`
460
+ root added its own `beforeunload` / `turbo:before-visit` pair on `window`,
461
+ and each persisted root its own `turbo:submit-end` listener on `document`.
462
+ A browser scans a target's listeners on every add and remove, so connecting
463
+ and disconnecting N of them was quadratic. One shared listener per window or
464
+ document now serves them all, added with the first root and removed with
465
+ the last; behaviour is unchanged (live dirty count, one prompt per visit,
466
+ #298). Headless Chromium, insert + connect: 4,000 guarded roots ~306 ->
467
+ ~227 ms, 4,000 persisted roots ~280 -> ~230 ms. +194 B gzipped in the
468
+ default bundle (110 B for the guard, 84 B for persist).
469
+ - **A record-backed token no longer builds a GlobalID URI.** For a record
470
+ whose `to_gid` is GlobalID's own and whose id is an Integer, the gid is
471
+ built directly as `gid://<app>/<Class>/<id>` (byte-identical, pinned by
472
+ `identity_gid_spec.rb`); anything else still calls `to_gid`. The token goes
473
+ from ~11.4 µs and 54 objects to ~5.2 µs and 20 (in-place A/B).
474
+ - **`rake bench:browser`.** Connect, disconnect and a heap soak for the
475
+ shipped client in headless Chromium. The happy-dom connect bench is
476
+ engine-relative and missed the quadratic above; this is the one to trust.
477
+ - **The doctor's authorization check is linear, not quadratic.** It rebuilt
478
+ the Inspector's whole component list (a Prism scan per action) once for
479
+ every component. It now reads it once: `doctor.checks` over the dummy app's
480
+ 90 components drops from 1,337 ms to 55 ms, and an app with a few hundred
481
+ components no longer waits tens of seconds on `rake phlex_reactive:doctor`.
482
+ - **`on()` checks for removed keywords without building arrays.** Every
483
+ trigger ran `params.keys & REMOVED_ON_KWARGS.keys`. It now returns at once
484
+ when there are no extra keywords. `on(:increment)` allocates 5 objects
485
+ instead of 8; a 7-trigger component's `render_component` 118 instead of 136
486
+ (~5% faster in an in-place A/B, a method-level win that shows on broadcast
487
+ fan-out, not request latency).
488
+ - **The request bench measures production, not a file watcher.** The dummy
489
+ app's test env left code reloading on, so every request globbed `app/`:
490
+ `rake bench:request` read ~1.7-2.0 ms and 1,272 objects per state-backed
491
+ action, against ~0.30-0.35 ms and 824 objects with it off (same machine,
492
+ interleaved). Test-env only; the gem is unchanged.
493
+
494
+ ## [0.15.2] - 2026-10-13
495
+
496
+ ### Added
497
+
299
498
  - **Media previews — `js.copy_media` and `reactive_media` (#373).**
300
499
  `js.copy_media("#logo", from: "#logo-007")` copies `src`, `srcset` and `alt`
301
500
  from an `<img>` the server rendered onto a preview `<img>`; the URL is read
@@ -385,6 +584,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
385
584
  Client: +251 B gzipped in the default bundle, +247 B in the split core.
386
585
  Docs: "Keeping client state through a morph" on the client-ops example.
387
586
 
587
+ ## [0.15.1] - 2026-10-12
588
+
589
+ ### Added
590
+
388
591
  - **Modal dialogs — `js.show_modal` / `js.close_dialog` and
389
592
  `data-reactive-modal` (#365).** `js.show_modal("#picker")` calls
390
593
  `HTMLDialogElement#showModal()`, so an `on_client` dialog gets the browser's
@@ -410,6 +613,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
410
613
  every other key alone (+134 B gzipped). `once:` raises on these keys. Existing
411
614
  chords render unchanged.
412
615
 
616
+ ## [0.15.0] - 2026-10-12
617
+
618
+ ### Added
619
+
413
620
  - **Presence — `reactive_presence`, `presence`, `js.presence` (#358).** A
414
621
  roster component declares the presence stream it shows
415
622
  (`reactive_presence :viewers, state: { selected: :integer }, ttl: 45`) and
@@ -453,6 +660,40 @@ changelog sections, so this section spans the whole run to 0.15.5.
453
660
  true` without `shortcut:` behaves exactly as before (never prevented, no
454
661
  editable guard).
455
662
 
663
+ ### Fixed
664
+
665
+ - **A stream that renders into a connected root wires what its content needs
666
+ (#356).** A root read its feature markers only at connect and after a morph
667
+ of itself, so a `reactive_show` target, select-all/count group, filter,
668
+ nested list, dirty field or paste trigger that an `append`, `prepend`,
669
+ `update`, `before`, `after` or child `replace` brought into it stayed dead
670
+ until the root reconnected. Now, once such a stream has rendered, the root
671
+ re-runs the seeds of the features it has (a new show target takes the
672
+ field's current value) and connects any feature its new content needs, in
673
+ both clients; bindings and form also wire a binding whose gate reads true
674
+ only then. As after a morph, this covers a root with an action token or a
675
+ lazy shell; a tokenless, client-only root does not re-read. On the split
676
+ client the stream's render waits for the module the content needs, as it
677
+ already did for an incoming root, so the content lands wired; the wait is
678
+ capped at 1 s, after which Turbo renders and the root connects the module
679
+ when it arrives. A draft
680
+ restore no longer marks fields `data-reactive-dirty` on a root that never
681
+ opted into dirty tracking (one that only gates a paste trigger).
682
+
683
+ - **An editable is editable in an engine without `contenteditable="plaintext-only"`,
684
+ however it reaches the page (#355).** Such an engine reads the server's
685
+ `plaintext-only` as invalid, so the element cannot even be focused. The
686
+ bindings feature switched it to `"true"` only when it connected, or after a
687
+ morph of the root, which left an editable that a stream appended, a frame
688
+ loaded or an unfocused morph rewrote uneditable. The runtime now does the
689
+ switch with one observer, installed only in such an engine (elsewhere the
690
+ probe is all that runs). Bundle: default +157 B gzip, split core +241 B,
691
+ `features/bindings` -138 B.
692
+
693
+ ## [0.14.4] - 2026-10-11
694
+
695
+ ### Added
696
+
456
697
  - **Sortable lists — `reactive_sortable`, `reactive_sortable_source`,
457
698
  `reactive_sortable_item` (#350).** Rows drag to reorder a list with a mouse,
458
699
  a finger (a 200 ms press) or the keyboard (Space picks up, arrows move,
@@ -490,104 +731,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
490
731
  client the stream that brings it starts the import). Bundle: default
491
732
  +977 B gzip, split core +412 B, `features/bindings` +524 B.
492
733
 
493
- - **`on_client(…, detail: { success: true })` — run ops only when the event's
494
- payload qualifies (#346).** A binding can require top-level `event.detail`
495
- keys to strictly equal JSON scalars, so `on_client("turbo:submit-end",
496
- js.check_group("ids[]", false), detail: { success: true })` clears a bulk
497
- selection after a successful submit and keeps it after a failed one. A
498
- missing detail or key never matches; a miss runs no confirm and no
499
- `preventDefault`. Each binding record carries its own filter, so two bindings
500
- on one element can split success and failure. A non-scalar value, an empty
501
- Hash, or `detail:` with `once:` raises at render. Records without `detail`
502
- are byte-identical.
734
+ ## [0.14.3] - 2026-10-07
503
735
 
504
736
  ### Fixed
505
737
 
506
- - **A server trigger inside a client-only root reaches the component around it
507
- (#410).** A `ClientBindings` component (a `reactive_filter` list, a
508
- `reactive_show` wrapper, an `on_client` menu) renders a root with no token.
509
- Rendered inside a server component, it swallowed that component's `on(...)`
510
- triggers: Stimulus sends the action to the nearest root, which had no token,
511
- and the outer root's field collection skipped every control under it (the
512
- #15 rule). A tokenless client-only root is now transparent to server round
513
- trips: `dispatch` forwards to the nearest token-bearing root above (skipping
514
- further client-only roots, waking a dormant one), and that root's action
515
- collects the controls under nested client-only roots. A nested server
516
- component keeps its own fields and triggers, and client bindings stay on
517
- their own root. With no token-bearing root above, nothing is sent, and
518
- `verbose_errors`/debug logs one warning. Lazy and deferred shells are not
519
- client-only. Client: +414 B gzipped in the default bundle, +415 B in the
520
- split core.
521
-
522
- - **`reactive_media` waits for `take` characters before calling a key a miss
523
- (#380).** A value shorter than `take:` sliced to a short key, missed the
524
- pool and wrote `blank_label` while the user was still typing. It is now
525
- *pending*: `blank_label` only names a full-length key that misses. New
526
- options: `match:` (a Regexp, anchored to the whole key; a key it rejects is
527
- pending too) and `pending: :clear` (default: clear the preview, empty the
528
- label) or `:keep` (leave both alone). The preview carries
529
- `data-reactive-media-state="pending|hit|miss"`, so a row can hide itself
530
- with CSS. `match:` reaches the browser as a JS pattern: its `\A`/`\z`
531
- anchors are dropped (a trailing `\Z` becomes `\n?`), and what JS reads
532
- differently raises at render (options, escapes such as `\h`/`\p{…}`/`\s`/
533
- `\u{…}`, POSIX and nested classes, `&&`, `(?i)`/`(?>…)`, characters above
534
- U+FFFF). **Behaviour change:** with `take:`, a short value now leaves the
535
- label empty instead of writing `blank_label`. Without `take:` or `match:`
536
- nothing changes. Client: +160 B gzipped in the default bundle (+152 B on
537
- top of #381/#382), +152 B in the bindings module, the split core unchanged. Docs: "Media
538
- previews" on the client-ops example.
539
-
540
- - **A pool `<img>` with an `alt` and no `src` keeps its name (#381).**
541
- `js.copy_media` and `reactive_media` treated a source `<img>` without `src`
542
- as a clear: the preview's `alt` was emptied and `label:` got `blank_label`,
543
- so a known entry with no logo file lost its name. Such a source is now a hit
544
- without an image: the target's `src` and `srcset` are removed, its `alt` is
545
- set, and `label:` gets that `alt`. Only a pool miss, a selector that matches
546
- nothing, or a source that is not an `<img>` clears. Client: +1 B gzipped in
547
- the default bundle, −2 B in the split core; the bindings module is
548
- unchanged.
549
-
550
- - **`js.preview_file` follows an app's upload validation (#382).** A preview
551
- ran once, on `change`, so a file the app then rejected (cleared with
552
- `input.value = ""`, no new `change`) stayed on screen. Two pieces fix it.
553
- `js.preview_file(…, valid: true)` treats an input whose `validity.valid` is
554
- false (`setCustomValidity`, `required`) as empty: it never gets a preview,
555
- and one it had is cleared and its URL revoked. And `reactive:file-changed`
556
- is the documented event for "the app changed this input's files without a
557
- `change` event": bind the op to it as well,
558
- `mix(on_client(:change, op), on_client("reactive:file-changed", op))`, and
559
- the app dispatches it after it clears or invalidates the field. That works
560
- whichever `change` listener runs first. Without `valid:` the op behaves as
561
- before. Client: +22 B gzipped in the default bundle, +34 B in the split
562
- core (on top of #381). Docs: "Preview a chosen image" on the client-ops example.
563
-
564
- - **A stream that renders into a connected root wires what its content needs
565
- (#356).** A root read its feature markers only at connect and after a morph
566
- of itself, so a `reactive_show` target, select-all/count group, filter,
567
- nested list, dirty field or paste trigger that an `append`, `prepend`,
568
- `update`, `before`, `after` or child `replace` brought into it stayed dead
569
- until the root reconnected. Now, once such a stream has rendered, the root
570
- re-runs the seeds of the features it has (a new show target takes the
571
- field's current value) and connects any feature its new content needs, in
572
- both clients; bindings and form also wire a binding whose gate reads true
573
- only then. As after a morph, this covers a root with an action token or a
574
- lazy shell; a tokenless, client-only root does not re-read. On the split
575
- client the stream's render waits for the module the content needs, as it
576
- already did for an incoming root, so the content lands wired; the wait is
577
- capped at 1 s, after which Turbo renders and the root connects the module
578
- when it arrives. A draft
579
- restore no longer marks fields `data-reactive-dirty` on a root that never
580
- opted into dirty tracking (one that only gates a paste trigger).
581
- - **An editable is editable in an engine without `contenteditable="plaintext-only"`,
582
- however it reaches the page (#355).** Such an engine reads the server's
583
- `plaintext-only` as invalid, so the element cannot even be focused. The
584
- bindings feature switched it to `"true"` only when it connected, or after a
585
- morph of the root, which left an editable that a stream appended, a frame
586
- loaded or an unfocused morph rewrote uneditable. The runtime now does the
587
- switch with one observer, installed only in such an engine (elsewhere the
588
- probe is all that runs). Bundle: default +157 B gzip, split core +241 B,
589
- `features/bindings` -138 B.
590
-
591
738
  - **A `checked:`-only `reactive_show_targets` entry hides at zero boxes
592
739
  (#348).** `"#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } }` was left
593
740
  visible once the owning root held no box of the group (the last ticked row
@@ -597,42 +744,20 @@ changelog sections, so this section spans the whole run to 0.15.5.
597
744
  first paint already were. A target with any value term keeps the skip when
598
745
  none of its fields is owned.
599
746
 
600
- ### Performance
747
+ ## [0.14.2] - 2026-10-07
601
748
 
602
- - **Guarded and persisted forms connect in linear time.** Each `warn_unsaved`
603
- root added its own `beforeunload` / `turbo:before-visit` pair on `window`,
604
- and each persisted root its own `turbo:submit-end` listener on `document`.
605
- A browser scans a target's listeners on every add and remove, so connecting
606
- and disconnecting N of them was quadratic. One shared listener per window or
607
- document now serves them all, added with the first root and removed with
608
- the last; behaviour is unchanged (live dirty count, one prompt per visit,
609
- #298). Headless Chromium, insert + connect: 4,000 guarded roots ~306 ->
610
- ~227 ms, 4,000 persisted roots ~280 -> ~230 ms. +194 B gzipped in the
611
- default bundle (110 B for the guard, 84 B for persist).
612
- - **A record-backed token no longer builds a GlobalID URI.** For a record
613
- whose `to_gid` is GlobalID's own and whose id is an Integer, the gid is
614
- built directly as `gid://<app>/<Class>/<id>` (byte-identical, pinned by
615
- `identity_gid_spec.rb`); anything else still calls `to_gid`. The token goes
616
- from ~11.4 µs and 54 objects to ~5.2 µs and 20 (in-place A/B).
617
- - **`rake bench:browser`.** Connect, disconnect and a heap soak for the
618
- shipped client in headless Chromium. The happy-dom connect bench is
619
- engine-relative and missed the quadratic above; this is the one to trust.
620
- - **The doctor's authorization check is linear, not quadratic.** It rebuilt
621
- the Inspector's whole component list (a Prism scan per action) once for
622
- every component. It now reads it once: `doctor.checks` over the dummy app's
623
- 90 components drops from 1,337 ms to 55 ms, and an app with a few hundred
624
- components no longer waits tens of seconds on `rake phlex_reactive:doctor`.
625
- - **`on()` checks for removed keywords without building arrays.** Every
626
- trigger ran `params.keys & REMOVED_ON_KWARGS.keys`. It now returns at once
627
- when there are no extra keywords. `on(:increment)` allocates 5 objects
628
- instead of 8; a 7-trigger component's `render_component` 118 instead of 136
629
- (~5% faster in an in-place A/B, a method-level win that shows on broadcast
630
- fan-out, not request latency).
631
- - **The request bench measures production, not a file watcher.** The dummy
632
- app's test env left code reloading on, so every request globbed `app/`:
633
- `rake bench:request` read ~1.7-2.0 ms and 1,272 objects per state-backed
634
- action, against ~0.30-0.35 ms and 824 objects with it off (same machine,
635
- interleaved). Test-env only; the gem is unchanged.
749
+ ### Added
750
+
751
+ - **`on_client(…, detail: { success: true })` — run ops only when the event's
752
+ payload qualifies (#346).** A binding can require top-level `event.detail`
753
+ keys to strictly equal JSON scalars, so `on_client("turbo:submit-end",
754
+ js.check_group("ids[]", false), detail: { success: true })` clears a bulk
755
+ selection after a successful submit and keeps it after a failed one. A
756
+ missing detail or key never matches; a miss runs no confirm and no
757
+ `preventDefault`. Each binding record carries its own filter, so two bindings
758
+ on one element can split success and failure. A non-scalar value, an empty
759
+ Hash, or `detail:` with `once:` raises at render. Records without `detail`
760
+ are byte-identical.
636
761
 
637
762
  ## [0.14.1] - 2026-10-07
638
763
 
@@ -664,7 +789,7 @@ changelog sections, so this section spans the whole run to 0.15.5.
664
789
 
665
790
  ## [0.14.0] - 2026-10-07
666
791
 
667
- This section also covers 0.10.0 through 0.13.4, which were released without
792
+ This section also covers 0.9.1 through 0.13.4, which were released without
668
793
  changelog sections of their own.
669
794
 
670
795
  ### Added
@@ -2424,7 +2549,7 @@ changelog sections of their own.
2424
2549
  (`spec/requests/deferred_latency_spec.rb`) pins that a 120 ms segment cost
2425
2550
  moves OFF the actor's reply and onto the deferred leg; it never disappears.
2426
2551
 
2427
- ## [0.9.0] - 2026-07-03
2552
+ ## [0.9.0] - 2026-07-04
2428
2553
 
2429
2554
  Consolidates everything since 0.2.6; tags 0.2.7–0.4.8 shipped without changelog
2430
2555
  sections, so this section spans the whole run to 0.9.0. The jump from 0.4.8 to
@@ -3860,7 +3985,7 @@ room for dogfood fixes.
3860
3985
  Outer flat fields and per-row reactive editing compose cleanly, with no
3861
3986
  name-disjointness workarounds.
3862
3987
 
3863
- ## [0.2.6]
3988
+ ## [0.2.6] - 2026-06-25
3864
3989
 
3865
3990
  ### Added
3866
3991
 
@@ -3887,7 +4012,7 @@ room for dogfood fixes.
3887
4012
  for non-remove/redirect responses, and never double-prepends when the action
3888
4013
  already included a self-targeted stream.
3889
4014
 
3890
- ## [0.2.5]
4015
+ ## [0.2.5] - 2026-06-25
3891
4016
 
3892
4017
  ### Fixed
3893
4018
 
@@ -3903,7 +4028,7 @@ room for dogfood fixes.
3903
4028
  bun unit test (`spec/javascript/reactive_controller.test.js`) that asserts the
3904
4029
  synchronous call and fails on the pre-fix deferred code. Closes #11.
3905
4030
 
3906
- ## [0.2.4] - 2026-06-24
4031
+ ## [0.2.4] - 2026-06-25
3907
4032
 
3908
4033
  ### Fixed
3909
4034
 
@@ -3921,6 +4046,12 @@ room for dogfood fixes.
3921
4046
  byte-identical to source by a guard spec, so the browser tests never validate
3922
4047
  stale client code again. Closes #8.
3923
4048
 
4049
+ ### Documentation
4050
+
4051
+ - Documented the combined record + state identity (the `{c, gid, s}` token
4052
+ shape): the README, `docs/architecture.md`, and `docs/security.md` no longer
4053
+ frame `reactive_record` and `reactive_state` as mutually exclusive. (#9)
4054
+
3924
4055
  ## [0.2.3] - 2026-06-24
3925
4056
 
3926
4057
  ### Fixed
@@ -3940,12 +4071,6 @@ room for dogfood fixes.
3940
4071
  round trip (only a genuinely absent value falls back to the `initialize`
3941
4072
  default). No API changes. Closes #6.
3942
4073
 
3943
- ### Documentation
3944
-
3945
- - Documented the combined record + state identity (the `{c, gid, s}` token
3946
- shape): the README, `docs/architecture.md`, and `docs/security.md` no longer
3947
- frame `reactive_record` and `reactive_state` as mutually exclusive. (#9)
3948
-
3949
4074
  ## [0.2.2] - 2026-06-24
3950
4075
 
3951
4076
  ### Fixed
@@ -4022,7 +4147,19 @@ room for dogfood fixes.
4022
4147
  scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
4023
4148
  state-backed by default or record-backed with `--record`.
4024
4149
 
4025
- [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.1...HEAD
4150
+ [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.16.2...HEAD
4151
+ [0.16.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.16.1...v0.16.2
4152
+ [0.16.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.16.0...v0.16.1
4153
+ [0.16.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.5...v0.16.0
4154
+ [0.15.5]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.4...v0.15.5
4155
+ [0.15.4]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.3...v0.15.4
4156
+ [0.15.3]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.2...v0.15.3
4157
+ [0.15.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.1...v0.15.2
4158
+ [0.15.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.0...v0.15.1
4159
+ [0.15.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.4...v0.15.0
4160
+ [0.14.4]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.3...v0.14.4
4161
+ [0.14.3]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.2...v0.14.3
4162
+ [0.14.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.1...v0.14.2
4026
4163
  [0.14.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.0...v0.14.1
4027
4164
  [0.14.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.9.0...v0.14.0
4028
4165
  [0.9.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.6...v0.9.0