phlex-reactive 0.16.0 → 0.16.1

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: b96289b4a91d050b1631c5c31663714e3aba33370f1a4e8d87d04c662fa3ed81
4
- data.tar.gz: 55c7126c343c696e66f4baf5d4c62d09a2e8fa8861c1bbe04b9b5f67c265c42c
3
+ metadata.gz: 860b397593c4d0a2d05d2485345bfc78564e784942c8413db1b67fbbacea6b41
4
+ data.tar.gz: '059b1148284859d6a69afdd4f62693f4dbedb0eab41370af843e30a4f3d27e4f'
5
5
  SHA512:
6
- metadata.gz: c4298ac14bf00c485324d9b61ae72fb812e0c87f1afd66cc09a24c2f0dd0917b7bf40458dd37223ba79650b4cf8a3bd44745cb7bf66d74f27f19021a3969b1a5
7
- data.tar.gz: a652652cfd5a2c7439c846afb421c8d99ff3cf902276da910e05228c7313cf9c3662c8c5391cfaab1316ec4399115d96cd5441fa9d4a8dbee74e032831d419d5
6
+ metadata.gz: 0500ccbf1d82ea1039cf078dbe62e67efd6da9c9a2ecbeaace46d7d316f4a6c62ef479ea8b1a81e263ad1f738c25d9aa64f0dc53fc7af7a1bbadd6dab49e5021
7
+ data.tar.gz: eae4009ae39fba74bc6219c4b90df31589bed31ed70a457c7f240075ffd50b84662a39af475d878bc339d2e538611e248a287e11a5bbdacd52cb47d1bf73d45e
data/CHANGELOG.md CHANGED
@@ -6,7 +6,67 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.16.0]
9
+ ### Added
10
+
11
+ - **Draggable regions: move and resize a box, or move a point, inside a
12
+ frame, with the result in fields (#416).** `reactive_frame` marks the
13
+ coordinate space, `reactive_box(fields:, value:, aspect:, min:, action:,
14
+ busy:, messages:)` a box in it, `reactive_box_handle(edge)` an edge or corner
15
+ to resize from, and `reactive_point(fields:, value:, within:, …)` a point,
16
+ inside the frame or inside a box. The helpers render the position as
17
+ percentages from `value:`, so the first paint needs no JavaScript and a
18
+ re-render with new values moves the box. The box moves and resizes with a
19
+ mouse, a pen, a finger or the keyboard (arrows 1%, Shift 10%, Alt resizes,
20
+ Escape puts it back, each step announced in the words of `messages:`). It
21
+ stays inside the frame and never goes below `min:`. `aspect:` locks the
22
+ ratio (a Numeric, or a field such as a row of preset radios, which re-fits
23
+ the box when it changes). The fields hold fractions rounded to 4 decimals,
24
+ with `input` as the box moves (at most once per field a frame) and one
25
+ `change` on release. `action:` is sent once per release through the root's
26
+ pipeline, with `x`, `y`, `width`, `height`. Without it the binding is
27
+ client-only and works on a `ClientBindings` root. A morph that lands
28
+ mid-drag does not interrupt it. The helpers check their arguments' keys,
29
+ types and ranges at render (an undeclared action under `verbose_errors`).
30
+ Client: a new `drag` feature module (3,576 B gzipped): +2,789 B in the
31
+ default bundle, +51 B in the split core, which loads it only on a page with
32
+ a frame, a box or a point. Docs: a "Draggable regions" guide with a live demo.
33
+
34
+ - **Fields that feed CSS custom properties: `reactive_css_vars` (#417).** A
35
+ preview that follows a control (a slider that brightens an image, a select
36
+ that rotates it) needed app JavaScript or a request per input event.
37
+ `reactive_css_vars(to: "#sample", vars: { "--light" => "light",
38
+ "--turn" => { field: "turn", unit: "deg" } })` reads each field as
39
+ `reactive_show` does (a checkbox gives `1` or `0`, a radio group its checked
40
+ value), parses it as a finite number and writes `number + unit` on every
41
+ `to:` element with `style.setProperty`, on connect, on `input`/`change` and
42
+ after a morph, with no request. Anything that is not a number removes the
43
+ property, so the `var()` fallback applies: no text from a field reaches CSS.
44
+ Units come from a fixed list (`""`, `px`, `%`, `deg`, `turn`, `ms`, `s`,
45
+ `em`, `rem`, `fr`) and names match `--[a-zA-Z0-9-]+`, checked at render and in
46
+ the browser. `to:` resolves like a client op's (`:root`, inside the root,
47
+ `global: true`, an Array). It goes on the root or on any element inside it,
48
+ and works on a `ClientBindings` view. Client: +460 B gzipped in the default
49
+ bundle; +462 B in `features/bindings` and +16 B in the split core.
50
+ Docs: "Live CSS properties" on the client-ops page, with a demo.
51
+
52
+ ### Fixed
53
+
54
+ - **A reply that refuses the posted values keeps the held marks (#413).**
55
+ Every reply to a root's own request released the fields a morph held
56
+ (#370), so after an action refused a save the user's next keystroke posted
57
+ no held names and went through over the other change. Return
58
+ `reply.morph.refused` (every mark) or `reply.morph.refused("title")` (only
59
+ those fields) and the client keeps the marks: the root's next action names
60
+ them in `reactive_held_fields` again, until the action stores the field or
61
+ the user takes the other value. The endpoint sends it as the
62
+ `X-Reactive-Refused` response header; the reply stays a 200, with no error
63
+ marker and no `reactive:error`. `run_reactive(…).refused?` and
64
+ `response.refused?` / `response.refused_fields` assert it in a unit test.
65
+ Client: +65 B gzipped in the default bundle, +60 B in the split core.
66
+ Docs: "Knowing what was held" on the architecture page, the `reply` table
67
+ in the README.
68
+
69
+ ## [0.16.0] - 2026-10-16
10
70
 
11
71
  ### Upgrading from 0.15.x
12
72
 
@@ -103,8 +163,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
103
163
 
104
164
  ## [0.15.5] - 2026-10-15
105
165
 
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.
166
+ ### Fixed
167
+
168
+ - **A server trigger inside a client-only root reaches the component around it
169
+ (#410).** A `ClientBindings` component (a `reactive_filter` list, a
170
+ `reactive_show` wrapper, an `on_client` menu) renders a root with no token.
171
+ Rendered inside a server component, it swallowed that component's `on(...)`
172
+ triggers: Stimulus sends the action to the nearest root, which had no token,
173
+ and the outer root's field collection skipped every control under it (the
174
+ #15 rule). A tokenless client-only root is now transparent to server round
175
+ trips: `dispatch` forwards to the nearest token-bearing root above (skipping
176
+ further client-only roots, waking a dormant one), and that root's action
177
+ collects the controls under nested client-only roots. A nested server
178
+ component keeps its own fields and triggers, and client bindings stay on
179
+ their own root. With no token-bearing root above, nothing is sent, and
180
+ `verbose_errors`/debug logs one warning. Lazy and deferred shells are not
181
+ client-only. Client: +414 B gzipped in the default bundle, +415 B in the
182
+ split core.
183
+
184
+ ## [0.15.4] - 2026-10-14
108
185
 
109
186
  ### Added
110
187
 
@@ -276,6 +353,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
276
353
  anything else raises at render. Client: +27 B gzipped in the default
277
354
  bundle, +20 B in the persist module; the split core unchanged.
278
355
 
356
+ ## [0.15.3] - 2026-10-13
357
+
358
+ ### Added
359
+
279
360
  - **An image fallback that recovers — `reactive_image_fallback(hide: true, show_alt: nil)` (#383).**
280
361
  `img(**reactive_image_fallback)` marks an `<img>` whose `src` fails with
281
362
  `data-reactive-image-state="error"` and hides it; `show_alt: "#bank-name"`
@@ -296,6 +377,91 @@ changelog sections, so this section spans the whole run to 0.15.5.
296
377
  +550 B in the split core. Docs: "An image that falls back, and recovers" on
297
378
  the client-ops example.
298
379
 
380
+ ### Fixed
381
+
382
+ - **`reactive_media` waits for `take` characters before calling a key a miss
383
+ (#380).** A value shorter than `take:` sliced to a short key, missed the
384
+ pool and wrote `blank_label` while the user was still typing. It is now
385
+ *pending*: `blank_label` only names a full-length key that misses. New
386
+ options: `match:` (a Regexp, anchored to the whole key; a key it rejects is
387
+ pending too) and `pending: :clear` (default: clear the preview, empty the
388
+ label) or `:keep` (leave both alone). The preview carries
389
+ `data-reactive-media-state="pending|hit|miss"`, so a row can hide itself
390
+ with CSS. `match:` reaches the browser as a JS pattern: its `\A`/`\z`
391
+ anchors are dropped (a trailing `\Z` becomes `\n?`), and what JS reads
392
+ differently raises at render (options, escapes such as `\h`/`\p{…}`/`\s`/
393
+ `\u{…}`, POSIX and nested classes, `&&`, `(?i)`/`(?>…)`, characters above
394
+ U+FFFF). **Behaviour change:** with `take:`, a short value now leaves the
395
+ label empty instead of writing `blank_label`. Without `take:` or `match:`
396
+ nothing changes. Client: +160 B gzipped in the default bundle (+152 B on
397
+ top of #381/#382), +152 B in the bindings module, the split core unchanged. Docs: "Media
398
+ previews" on the client-ops example.
399
+
400
+ - **A pool `<img>` with an `alt` and no `src` keeps its name (#381).**
401
+ `js.copy_media` and `reactive_media` treated a source `<img>` without `src`
402
+ as a clear: the preview's `alt` was emptied and `label:` got `blank_label`,
403
+ so a known entry with no logo file lost its name. Such a source is now a hit
404
+ without an image: the target's `src` and `srcset` are removed, its `alt` is
405
+ set, and `label:` gets that `alt`. Only a pool miss, a selector that matches
406
+ nothing, or a source that is not an `<img>` clears. Client: +1 B gzipped in
407
+ the default bundle, −2 B in the split core; the bindings module is
408
+ unchanged.
409
+
410
+ - **`js.preview_file` follows an app's upload validation (#382).** A preview
411
+ ran once, on `change`, so a file the app then rejected (cleared with
412
+ `input.value = ""`, no new `change`) stayed on screen. Two pieces fix it.
413
+ `js.preview_file(…, valid: true)` treats an input whose `validity.valid` is
414
+ false (`setCustomValidity`, `required`) as empty: it never gets a preview,
415
+ and one it had is cleared and its URL revoked. And `reactive:file-changed`
416
+ is the documented event for "the app changed this input's files without a
417
+ `change` event": bind the op to it as well,
418
+ `mix(on_client(:change, op), on_client("reactive:file-changed", op))`, and
419
+ the app dispatches it after it clears or invalidates the field. That works
420
+ whichever `change` listener runs first. Without `valid:` the op behaves as
421
+ before. Client: +22 B gzipped in the default bundle, +34 B in the split
422
+ core (on top of #381). Docs: "Preview a chosen image" on the client-ops example.
423
+
424
+ ### Performance
425
+
426
+ - **Guarded and persisted forms connect in linear time.** Each `warn_unsaved`
427
+ root added its own `beforeunload` / `turbo:before-visit` pair on `window`,
428
+ and each persisted root its own `turbo:submit-end` listener on `document`.
429
+ A browser scans a target's listeners on every add and remove, so connecting
430
+ and disconnecting N of them was quadratic. One shared listener per window or
431
+ document now serves them all, added with the first root and removed with
432
+ the last; behaviour is unchanged (live dirty count, one prompt per visit,
433
+ #298). Headless Chromium, insert + connect: 4,000 guarded roots ~306 ->
434
+ ~227 ms, 4,000 persisted roots ~280 -> ~230 ms. +194 B gzipped in the
435
+ default bundle (110 B for the guard, 84 B for persist).
436
+ - **A record-backed token no longer builds a GlobalID URI.** For a record
437
+ whose `to_gid` is GlobalID's own and whose id is an Integer, the gid is
438
+ built directly as `gid://<app>/<Class>/<id>` (byte-identical, pinned by
439
+ `identity_gid_spec.rb`); anything else still calls `to_gid`. The token goes
440
+ from ~11.4 µs and 54 objects to ~5.2 µs and 20 (in-place A/B).
441
+ - **`rake bench:browser`.** Connect, disconnect and a heap soak for the
442
+ shipped client in headless Chromium. The happy-dom connect bench is
443
+ engine-relative and missed the quadratic above; this is the one to trust.
444
+ - **The doctor's authorization check is linear, not quadratic.** It rebuilt
445
+ the Inspector's whole component list (a Prism scan per action) once for
446
+ every component. It now reads it once: `doctor.checks` over the dummy app's
447
+ 90 components drops from 1,337 ms to 55 ms, and an app with a few hundred
448
+ components no longer waits tens of seconds on `rake phlex_reactive:doctor`.
449
+ - **`on()` checks for removed keywords without building arrays.** Every
450
+ trigger ran `params.keys & REMOVED_ON_KWARGS.keys`. It now returns at once
451
+ when there are no extra keywords. `on(:increment)` allocates 5 objects
452
+ instead of 8; a 7-trigger component's `render_component` 118 instead of 136
453
+ (~5% faster in an in-place A/B, a method-level win that shows on broadcast
454
+ fan-out, not request latency).
455
+ - **The request bench measures production, not a file watcher.** The dummy
456
+ app's test env left code reloading on, so every request globbed `app/`:
457
+ `rake bench:request` read ~1.7-2.0 ms and 1,272 objects per state-backed
458
+ action, against ~0.30-0.35 ms and 824 objects with it off (same machine,
459
+ interleaved). Test-env only; the gem is unchanged.
460
+
461
+ ## [0.15.2] - 2026-10-13
462
+
463
+ ### Added
464
+
299
465
  - **Media previews — `js.copy_media` and `reactive_media` (#373).**
300
466
  `js.copy_media("#logo", from: "#logo-007")` copies `src`, `srcset` and `alt`
301
467
  from an `<img>` the server rendered onto a preview `<img>`; the URL is read
@@ -385,6 +551,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
385
551
  Client: +251 B gzipped in the default bundle, +247 B in the split core.
386
552
  Docs: "Keeping client state through a morph" on the client-ops example.
387
553
 
554
+ ## [0.15.1] - 2026-10-12
555
+
556
+ ### Added
557
+
388
558
  - **Modal dialogs — `js.show_modal` / `js.close_dialog` and
389
559
  `data-reactive-modal` (#365).** `js.show_modal("#picker")` calls
390
560
  `HTMLDialogElement#showModal()`, so an `on_client` dialog gets the browser's
@@ -410,6 +580,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
410
580
  every other key alone (+134 B gzipped). `once:` raises on these keys. Existing
411
581
  chords render unchanged.
412
582
 
583
+ ## [0.15.0] - 2026-10-12
584
+
585
+ ### Added
586
+
413
587
  - **Presence — `reactive_presence`, `presence`, `js.presence` (#358).** A
414
588
  roster component declares the presence stream it shows
415
589
  (`reactive_presence :viewers, state: { selected: :integer }, ttl: 45`) and
@@ -453,6 +627,40 @@ changelog sections, so this section spans the whole run to 0.15.5.
453
627
  true` without `shortcut:` behaves exactly as before (never prevented, no
454
628
  editable guard).
455
629
 
630
+ ### Fixed
631
+
632
+ - **A stream that renders into a connected root wires what its content needs
633
+ (#356).** A root read its feature markers only at connect and after a morph
634
+ of itself, so a `reactive_show` target, select-all/count group, filter,
635
+ nested list, dirty field or paste trigger that an `append`, `prepend`,
636
+ `update`, `before`, `after` or child `replace` brought into it stayed dead
637
+ until the root reconnected. Now, once such a stream has rendered, the root
638
+ re-runs the seeds of the features it has (a new show target takes the
639
+ field's current value) and connects any feature its new content needs, in
640
+ both clients; bindings and form also wire a binding whose gate reads true
641
+ only then. As after a morph, this covers a root with an action token or a
642
+ lazy shell; a tokenless, client-only root does not re-read. On the split
643
+ client the stream's render waits for the module the content needs, as it
644
+ already did for an incoming root, so the content lands wired; the wait is
645
+ capped at 1 s, after which Turbo renders and the root connects the module
646
+ when it arrives. A draft
647
+ restore no longer marks fields `data-reactive-dirty` on a root that never
648
+ opted into dirty tracking (one that only gates a paste trigger).
649
+
650
+ - **An editable is editable in an engine without `contenteditable="plaintext-only"`,
651
+ however it reaches the page (#355).** Such an engine reads the server's
652
+ `plaintext-only` as invalid, so the element cannot even be focused. The
653
+ bindings feature switched it to `"true"` only when it connected, or after a
654
+ morph of the root, which left an editable that a stream appended, a frame
655
+ loaded or an unfocused morph rewrote uneditable. The runtime now does the
656
+ switch with one observer, installed only in such an engine (elsewhere the
657
+ probe is all that runs). Bundle: default +157 B gzip, split core +241 B,
658
+ `features/bindings` -138 B.
659
+
660
+ ## [0.14.4] - 2026-10-11
661
+
662
+ ### Added
663
+
456
664
  - **Sortable lists — `reactive_sortable`, `reactive_sortable_source`,
457
665
  `reactive_sortable_item` (#350).** Rows drag to reorder a list with a mouse,
458
666
  a finger (a 200 ms press) or the keyboard (Space picks up, arrows move,
@@ -490,104 +698,10 @@ changelog sections, so this section spans the whole run to 0.15.5.
490
698
  client the stream that brings it starts the import). Bundle: default
491
699
  +977 B gzip, split core +412 B, `features/bindings` +524 B.
492
700
 
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.
701
+ ## [0.14.3] - 2026-10-07
503
702
 
504
703
  ### Fixed
505
704
 
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
705
  - **A `checked:`-only `reactive_show_targets` entry hides at zero boxes
592
706
  (#348).** `"#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } }` was left
593
707
  visible once the owning root held no box of the group (the last ticked row
@@ -597,42 +711,20 @@ changelog sections, so this section spans the whole run to 0.15.5.
597
711
  first paint already were. A target with any value term keeps the skip when
598
712
  none of its fields is owned.
599
713
 
600
- ### Performance
714
+ ## [0.14.2] - 2026-10-07
601
715
 
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.
716
+ ### Added
717
+
718
+ - **`on_client(…, detail: { success: true })` — run ops only when the event's
719
+ payload qualifies (#346).** A binding can require top-level `event.detail`
720
+ keys to strictly equal JSON scalars, so `on_client("turbo:submit-end",
721
+ js.check_group("ids[]", false), detail: { success: true })` clears a bulk
722
+ selection after a successful submit and keeps it after a failed one. A
723
+ missing detail or key never matches; a miss runs no confirm and no
724
+ `preventDefault`. Each binding record carries its own filter, so two bindings
725
+ on one element can split success and failure. A non-scalar value, an empty
726
+ Hash, or `detail:` with `once:` raises at render. Records without `detail`
727
+ are byte-identical.
636
728
 
637
729
  ## [0.14.1] - 2026-10-07
638
730
 
@@ -664,7 +756,7 @@ changelog sections, so this section spans the whole run to 0.15.5.
664
756
 
665
757
  ## [0.14.0] - 2026-10-07
666
758
 
667
- This section also covers 0.10.0 through 0.13.4, which were released without
759
+ This section also covers 0.9.1 through 0.13.4, which were released without
668
760
  changelog sections of their own.
669
761
 
670
762
  ### Added
@@ -2424,7 +2516,7 @@ changelog sections of their own.
2424
2516
  (`spec/requests/deferred_latency_spec.rb`) pins that a 120 ms segment cost
2425
2517
  moves OFF the actor's reply and onto the deferred leg; it never disappears.
2426
2518
 
2427
- ## [0.9.0] - 2026-07-03
2519
+ ## [0.9.0] - 2026-07-04
2428
2520
 
2429
2521
  Consolidates everything since 0.2.6; tags 0.2.7–0.4.8 shipped without changelog
2430
2522
  sections, so this section spans the whole run to 0.9.0. The jump from 0.4.8 to
@@ -3860,7 +3952,7 @@ room for dogfood fixes.
3860
3952
  Outer flat fields and per-row reactive editing compose cleanly, with no
3861
3953
  name-disjointness workarounds.
3862
3954
 
3863
- ## [0.2.6]
3955
+ ## [0.2.6] - 2026-06-25
3864
3956
 
3865
3957
  ### Added
3866
3958
 
@@ -3887,7 +3979,7 @@ room for dogfood fixes.
3887
3979
  for non-remove/redirect responses, and never double-prepends when the action
3888
3980
  already included a self-targeted stream.
3889
3981
 
3890
- ## [0.2.5]
3982
+ ## [0.2.5] - 2026-06-25
3891
3983
 
3892
3984
  ### Fixed
3893
3985
 
@@ -3903,7 +3995,7 @@ room for dogfood fixes.
3903
3995
  bun unit test (`spec/javascript/reactive_controller.test.js`) that asserts the
3904
3996
  synchronous call and fails on the pre-fix deferred code. Closes #11.
3905
3997
 
3906
- ## [0.2.4] - 2026-06-24
3998
+ ## [0.2.4] - 2026-06-25
3907
3999
 
3908
4000
  ### Fixed
3909
4001
 
@@ -3921,6 +4013,12 @@ room for dogfood fixes.
3921
4013
  byte-identical to source by a guard spec, so the browser tests never validate
3922
4014
  stale client code again. Closes #8.
3923
4015
 
4016
+ ### Documentation
4017
+
4018
+ - Documented the combined record + state identity (the `{c, gid, s}` token
4019
+ shape): the README, `docs/architecture.md`, and `docs/security.md` no longer
4020
+ frame `reactive_record` and `reactive_state` as mutually exclusive. (#9)
4021
+
3924
4022
  ## [0.2.3] - 2026-06-24
3925
4023
 
3926
4024
  ### Fixed
@@ -3940,12 +4038,6 @@ room for dogfood fixes.
3940
4038
  round trip (only a genuinely absent value falls back to the `initialize`
3941
4039
  default). No API changes. Closes #6.
3942
4040
 
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
4041
  ## [0.2.2] - 2026-06-24
3950
4042
 
3951
4043
  ### Fixed
@@ -4022,7 +4114,17 @@ room for dogfood fixes.
4022
4114
  scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
4023
4115
  state-backed by default or record-backed with `--record`.
4024
4116
 
4025
- [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.1...HEAD
4117
+ [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.16.0...HEAD
4118
+ [0.16.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.5...v0.16.0
4119
+ [0.15.5]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.4...v0.15.5
4120
+ [0.15.4]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.3...v0.15.4
4121
+ [0.15.3]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.2...v0.15.3
4122
+ [0.15.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.1...v0.15.2
4123
+ [0.15.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.15.0...v0.15.1
4124
+ [0.15.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.4...v0.15.0
4125
+ [0.14.4]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.3...v0.14.4
4126
+ [0.14.3]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.2...v0.14.3
4127
+ [0.14.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.1...v0.14.2
4026
4128
  [0.14.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.0...v0.14.1
4027
4129
  [0.14.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.9.0...v0.14.0
4028
4130
  [0.9.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.6...v0.9.0
data/README.md CHANGED
@@ -299,9 +299,10 @@ imports one the first time a root on the page needs it:
299
299
  | `phlex/reactive/features/defer` (`reply.defer`, `reactive_lazy`) | 2.8 KB | inside the one file | fetched when a root is a `reactive_lazy` shell, a morph turns one into a shell, or a `reply.defer` arrives |
300
300
  | `phlex/reactive/features/form` (dirty tracking, `warn_unsaved`, the paste-trigger gate) | 1.0 KB | inside the one file | fetched when a root tracks dirty fields or holds a `paste_into` trigger |
301
301
  | `phlex/reactive/features/effects` (`reactive_effects`, `dismiss_after`) | 1.7 KB | inside the one file | fetched when a root declares an effect, or when the first stream that needs it arrives: one with an effect, or with a dismissing flash |
302
- | `phlex/reactive/features/bindings` (`reactive_show`, show targets, `reactive_on_complete`, `reactive_filter`, `reactive_tags`, nested rows, the conditional `confirm:`) | 5.4 KB | inside the one file | fetched when a root declares one of them, or holds a trigger with a conditional confirm |
302
+ | `phlex/reactive/features/bindings` (`reactive_show`, show targets, `reactive_on_complete`, `reactive_filter`, `reactive_tags`, `reactive_css_vars`, nested rows, the conditional `confirm:`) | 8.2 KB | inside the one file | fetched when a root declares one of them, or holds a trigger with a conditional confirm |
303
303
  | `phlex/reactive/features/compute` (`reactive_compute`, `reactive_text`) | 2.1 KB | inside the one file | fetched when a root carries a compute binding |
304
304
  | `phlex/reactive/features/sortable` (`reactive_sortable` drag and drop) | 3.1 KB | inside the one file | fetched when a root is or holds a sortable or source list |
305
+ | `phlex/reactive/features/drag` (`reactive_box`, `reactive_point` draggable regions) | 3.5 KB | inside the one file | fetched when a root is or holds a frame, a box or a point |
305
306
  | `phlex/reactive/features/countdown` (`reactive_countdown`) | 1.0 KB | inside the one file | fetched when a root is or holds a countdown |
306
307
  | `phlex/reactive/features/hints` (`optimistic:`, `busy:`) | 1.0 KB | inside the one file | fetched when a root holds a trigger that declares a hint |
307
308
  | `phlex/reactive/features/devtools` (the latency simulator, the zero-target warnings, the debug trace) | 1.6 KB | inside the one file | fetched with the core on a page that carries `<meta name="phlex-reactive-env" content="development">` or while a delay is stored for the tab; at connect for a root in debug mode; otherwise the first time a verbose root has something to warn about. In production with none of these, never |
@@ -693,6 +694,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
693
694
  | `reactive_media(from:, pool:, take:, match:, when:, pending:, label:, blank_label:)` | A preview `<img>` that **follows a field** (#373): the field's value (its first `take:` characters, or all of it) is a key into `pool`, whose values are **selectors** for `<img>` elements the server rendered — never URLs. A hit copies that image's `src`/`srcset`/`alt` like `js.copy_media` (a pool `<img>` with an `alt` and no `src` is a hit without an image, #381); a miss clears the preview. `label:` gets the `alt` (or `blank_label`) as `textContent`. Below `take:` characters, or while `match:` (a JavaScript-compatible Regexp over the whole key) rejects the key, the binding is **pending** (#380) — never `blank_label` (Regexp options or Ruby-only syntax raise at render); `when:` (same rules) tests the **whole field** before the pool lookup while the key stays the prefix, so `take: 3, when: /\A\d{3,24}\z/` keeps `007bad` pending (#390); `pending: :clear` (default) clears the preview and label, `:keep` leaves them. The preview's `data-reactive-media-state` reads `pending`/`hit`/`miss`, so CSS can hide its row. See [Media previews](https://phlex-reactive.zoolutions.llc/docs/example-client-ops). |
694
695
  | `reactive_image_fallback(hide: true, show_alt: nil, wrap: nil)` | An `<img>` that **falls back and recovers** (#383): on `error` it gets `data-reactive-image-state="error"`; `hide:` (default `true`) also hides it, and `show_alt:`, independently, writes its `alt` into that node and shows it; `wrap:` (#392) hides the box around it (marked `data-reactive-wrap-hidden`, shown again only when it carries that mark, its text never written); `load` sets `"loaded"`. A later `src` (from `copy_media`, `reactive_media`, `preview_file`, a morph, or any `src` that loads) shows it again; removing the `src` is not an error. Composes with `reactive_media(…, **reactive_image_fallback)`. See [Client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops). |
695
696
  | `reactive_countdown(until:, format: :ms, waiting:, done:)` | A server **deadline counted down** on the client (#400): `until:` is an ISO8601 date-time with a zone (or a `Time`); the text is the time left from `Date.now` (`1:30`), as `textContent`, once a second and again when a hidden tab is shown. At zero (or already past on connect) it reads `0:00`, `data-reactive-countdown-state="done"`, `waiting:` hides, `done:` shows and `reactive:countdown-done` fires **once per deadline** (a morph that keeps `until:` does not fire again; a new `until:` restarts). The server renders the first text and visibility. No request, no frame reload. See [Client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops). |
697
+ | `reactive_css_vars(to:, vars:, global: false)` | Fields that **feed CSS custom properties** on an element (#417), for a preview that follows a control with no request: `vars: { "--light" => "light", "--turn" => { field: "turn", unit: "deg" } }`. Each field is read as `reactive_show` reads one (a checkbox gives `1`/`0`, a radio group its checked value) and parsed as a **finite number**; the client writes `number + unit` with `style.setProperty`, and anything else (empty, text) removes the property so the `var()` fallback applies. No text from a field reaches CSS. Units: `""`, `px`, `%`, `deg`, `turn`, `ms`, `s`, `em`, `rem`, `fr`; names match `--[a-zA-Z0-9-]+`; a bad name or unit or a blank field raises at render. Runs on connect, on `input`/`change` and after a morph. `to:` resolves like a client op's (`:root`, inside the root, `global: true`, an Array). Render the same properties inline for the first paint. See [Client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops). |
696
698
  | `reactive_enable(if:/if_any:/unless:)` | The sibling of `reactive_show` that flips the element's **own `disabled`** (a button, a fieldset) instead of `hidden` — same conditions, same `reactive_values` first paint. `button(**reactive_enable(if: { "ids[]" => { checked: 1.. } }))`. See [Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable). |
697
699
  | `reactive_select_all("ids[]")` / `reactive_count("ids[]")` | A checkbox **group**'s header box (ticks/unticks every owned box; shows checked / indeterminate / unchecked) and its ticked count as text — re-synced on every change and when rows are appended or removed. `under: "a.b"` makes a nested header over the leaves whose `data-reactive-group` is in that path. See [Bulk selection](#bulk-selection-reactive_select_all-reactive_count-reactive_enable). |
698
700
  | `reactive_group_targets("ids[]", count: "#c", enable: { "#b" => 1.. })` | Spread on the root that **owns** a checkbox group: drives a count and an enable on outside, id-allowlisted elements (a bulk bar the page renders). `reactive_group_target_attrs` gives that outside markup its first paint. See [A bulk bar outside the list's root](#a-bulk-bar-outside-the-lists-root-reactive_group_targets). |
@@ -1900,6 +1902,87 @@ list; a reply that morphs the list puts the rows in the server's order.
1900
1902
 
1901
1903
  Full guide: [Sortable lists](https://phlex-reactive.zoolutions.llc/docs/sortable-lists).
1902
1904
 
1905
+ ### Draggable regions (`reactive_box`, `reactive_point`)
1906
+
1907
+ Move and resize a box, or move a point, over an image, a chart or a diagram —
1908
+ with a mouse, a pen, a finger or the keyboard — and its fields always hold
1909
+ where it is, as fractions of the frame (0..1 on each axis). A crop box, a
1910
+ focal point, a hotspot, a selection: no Stimulus controller of your own.
1911
+
1912
+ ```ruby
1913
+ class CropPicker < Phlex::HTML
1914
+ include Phlex::Reactive::Component
1915
+
1916
+ reactive_record :photo
1917
+ action :crop, params: { x: :float, y: :float, width: :float, height: :float }
1918
+
1919
+ def crop(x:, y:, width:, height:)
1920
+ authorize! @photo, :update?
1921
+ # The schema casts; it does not range-check. A forged box outside the frame changes nothing.
1922
+ return reply.morph unless inside?(x, width) && inside?(y, height)
1923
+
1924
+ @photo.update!(crop: { x:, y:, width:, height: })
1925
+ reply.morph
1926
+ end
1927
+
1928
+ def view_template
1929
+ div(**reactive_root) do
1930
+ div(**mix(reactive_frame, class: "relative")) do
1931
+ img(src: @photo.url, alt: "", class: "block w-full")
1932
+ div(**reactive_box(fields: { x: "crop[x]", y: "crop[y]", width: "crop[width]", height: "crop[height]" },
1933
+ value: @photo.crop, aspect: "aspect", min: 0.05, action: :crop)) do
1934
+ %i[nw n ne e se s sw w].each { span(**reactive_box_handle(it)) }
1935
+ span(**reactive_point(fields: { x: "focus[x]", y: "focus[y]" }, value: @photo.focus,
1936
+ within: "[data-reactive-box]"))
1937
+ end
1938
+ end
1939
+ # the fields: hidden inputs named crop[x] … focus[y], and the aspect radios
1940
+ end
1941
+ end
1942
+
1943
+ private
1944
+
1945
+ # The fields round to 4 decimals, so x + width may reach 1.0001.
1946
+ def inside?(at, size) = at.between?(0, 1) && size.positive? && at + size <= 1.0001
1947
+ end
1948
+ ```
1949
+
1950
+ The helpers render the box's `left`/`top`/`width`/`height` (and the point's
1951
+ `left`/`top`) as percentages from `value:`, so the first paint is right with
1952
+ no JavaScript and a re-render with new values moves them. The frame must be
1953
+ positioned (`relative`, or `absolute` for an overlay); its padding box is the
1954
+ coordinate space.
1955
+
1956
+ - **Pointer**: a press on the box moves it, a press on a handle resizes from
1957
+ that edge or corner; the box stays inside the frame and never goes below
1958
+ `min:`. A drag starts after a 4 px move; `touch-action: none` keeps a finger
1959
+ drag from scrolling the page.
1960
+ - **Fields**: written as `set_value` writes them (the live `.value`, rounded to
1961
+ 4 decimals, a bubbling `input` at most once per field per frame), so
1962
+ `reactive_show`, compute reducers and other bindings follow the box. One
1963
+ `change` fires on release.
1964
+ - **`aspect:`**: a Numeric (`16/9r`) or the name of a field holding `"16:9"`, a
1965
+ decimal or `""` (free), read at drag start. When the field changes (a preset
1966
+ radio), the box re-fits to the new ratio around its centre.
1967
+ - **`action:`** (optional): sent once on release through the root's pipeline,
1968
+ with `x`, `y`, `width`, `height` (a point: `x`, `y`). Without it the binding
1969
+ is client-only and works on a `ClientBindings` root. The `:float` schema casts
1970
+ but does not range-check: refuse values outside 0..1, a `width` or `height`
1971
+ of 0, and `x + width` or `y + height` past 1.0001 (the fields' rounding)
1972
+ before you store them, since `reactive_box` refuses to render such a box.
1973
+ - **Points**: `reactive_point` moves within the frame, or — placed inside a box
1974
+ with `within:` — within that box, its fractions of the box.
1975
+ - **Keyboard**: the box and the point are focusable; arrows move 1% (Shift:
1976
+ 10%), Alt+arrows resize from the bottom-right corner or the focused handle,
1977
+ Escape puts it back. Each step is announced through a live region; translate
1978
+ the words with `messages:` (`{x}`, `{y}`, `{width}`, `{height}` as whole
1979
+ percents). Letting go of the key is the release.
1980
+ - **Styling**: `data-reactive-frame-active` (the frame) and
1981
+ `data-reactive-dragging` (the pressed box, handle or point). A morph that
1982
+ lands mid-drag does not interrupt it.
1983
+
1984
+ Full guide: [Draggable regions](https://phlex-reactive.zoolutions.llc/docs/draggable-regions).
1985
+
1903
1986
  ### Presence (`reactive_presence`)
1904
1987
 
1905
1988
  Who is viewing a stream, one entry per browser tab, each tab with a small
@@ -2857,6 +2940,7 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
2857
2940
  | `.also(target => content, …)` | **UPDATE** (inner HTML) companion elements by DOM id; `content` is a plain string (escaped) or a Phlex component. The argument *type* picks the action — pairs mean `update` |
2858
2941
  | `.also(component, morph: false)` | **REPLACE** another Streamable component at its own `#id` (`morph: true` morphs in place). A component argument means `replace` |
2859
2942
  | `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped, wrapped in a level-carrying `<div>` — see [Flash levels](#flash-levels)) or a Phlex component (rendered verbatim; off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
2943
+ | `.refused(*names)` | the reply **answers the posted values without storing them**: the client keeps the fields a morph held (`data-reactive-held`, #370), so the root's next action names them in `reactive_held_fields` again instead of saving over the other change. No names keeps every mark; names (the fields' `name` attributes) keep only those. Chain it on a morph (`reply.morph.refused`): a plain `replace` swaps the marked field out. Still a 200: no error marker, no `reactive:error` (#413) |
2860
2944
  | `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |
2861
2945
  | `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
2862
2946
  | `reply.streams(*streams)` | **partial update** — emit exactly these streams (no full-self replace) + a tiny token-only refresh, so live inputs survive; for per-field grid editing (issue #30) |