phlex-reactive 0.15.5 → 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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +344 -142
  3. data/README.md +100 -3
  4. data/app/controllers/phlex/reactive/actions_controller.rb +10 -0
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +4 -3
  6. data/app/javascript/phlex/reactive/core.js +1 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -4
  8. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  9. data/app/javascript/phlex/reactive/features/bindings.js +81 -4
  10. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  11. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  12. data/app/javascript/phlex/reactive/features/drag.js +522 -0
  13. data/app/javascript/phlex/reactive/features/drag.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/drag.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  16. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  17. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +7 -6
  18. data/app/javascript/phlex/reactive/runtime.js +57 -23
  19. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +2 -1
  20. data/lib/phlex/reactive/apm/subscriber.rb +5 -0
  21. data/lib/phlex/reactive/apm.rb +5 -6
  22. data/lib/phlex/reactive/authorization.rb +31 -5
  23. data/lib/phlex/reactive/client_bindings.rb +19 -0
  24. data/lib/phlex/reactive/component/css_vars_binding.rb +118 -0
  25. data/lib/phlex/reactive/component/drag.rb +270 -0
  26. data/lib/phlex/reactive/component/dsl.rb +15 -1
  27. data/lib/phlex/reactive/component/helpers.rb +9 -1
  28. data/lib/phlex/reactive/component/identity.rb +17 -6
  29. data/lib/phlex/reactive/component/lazy.rb +4 -0
  30. data/lib/phlex/reactive/component/presence_declaration.rb +4 -0
  31. data/lib/phlex/reactive/component/registry.rb +76 -6
  32. data/lib/phlex/reactive/component/sortable.rb +3 -0
  33. data/lib/phlex/reactive/component.rb +4 -0
  34. data/lib/phlex/reactive/defer.rb +4 -4
  35. data/lib/phlex/reactive/doctor/ractor_readiness.rb +65 -0
  36. data/lib/phlex/reactive/doctor.rb +2 -0
  37. data/lib/phlex/reactive/engine.rb +31 -4
  38. data/lib/phlex/reactive/fragment.rb +5 -4
  39. data/lib/phlex/reactive/held_fields.rb +21 -1
  40. data/lib/phlex/reactive/param_schema.rb +3 -2
  41. data/lib/phlex/reactive/pending/markup.rb +27 -9
  42. data/lib/phlex/reactive/pending.rb +4 -4
  43. data/lib/phlex/reactive/response.rb +55 -4
  44. data/lib/phlex/reactive/shareable.rb +87 -0
  45. data/lib/phlex/reactive/store.rb +73 -0
  46. data/lib/phlex/reactive/streamable.rb +49 -19
  47. data/lib/phlex/reactive/test_helpers.rb +6 -0
  48. data/lib/phlex/reactive/version.rb +1 -1
  49. data/lib/phlex/reactive.rb +293 -122
  50. data/sig/phlex/reactive/component.rbs +108 -0
  51. data/sig/phlex/reactive/param_schema.rbs +23 -0
  52. data/sig/phlex/reactive/response.rbs +73 -0
  53. data/sig/phlex/reactive/streamable.rbs +62 -0
  54. data/sig/phlex/reactive.rbs +231 -0
  55. metadata +14 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb2e7841a38e3400b1777833cde2f16a73794b54c29f9e0ceb1a2b02c358263d
4
- data.tar.gz: 8023c8ab7f9bb88833282c33c99a7797f565fb491795cf9b0c7566d0040653ef
3
+ metadata.gz: 860b397593c4d0a2d05d2485345bfc78564e784942c8413db1b67fbbacea6b41
4
+ data.tar.gz: '059b1148284859d6a69afdd4f62693f4dbedb0eab41370af843e30a4f3d27e4f'
5
5
  SHA512:
6
- metadata.gz: 5257d059749e070f6b7e369e89c06fbca8b5c6b0cb9d6663b7983081a9af5a7831ba7f4d46b01da97d04a49f5fe727b6d760351f449d140e4ccffb2881cfd7c3
7
- data.tar.gz: 0e29f1ca596e632855b89c9dacf2b1e0e4b6fc0db3f836f662d61c8349c0fbda0f11ebb706fe165acfcfb4ed9fbcd4d630154db8f25c023b8d7ce547dbaf9cab
6
+ metadata.gz: 0500ccbf1d82ea1039cf078dbe62e67efd6da9c9a2ecbeaace46d7d316f4a6c62ef479ea8b1a81e263ad1f738c25d9aa64f0dc53fc7af7a1bbadd6dab49e5021
7
+ data.tar.gz: eae4009ae39fba74bc6219c4b90df31589bed31ed70a457c7f240075ffd50b84662a39af475d878bc339d2e538611e248a287e11a5bbdacd52cb47d1bf73d45e
data/CHANGELOG.md CHANGED
@@ -8,6 +8,183 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
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
70
+
71
+ ### Upgrading from 0.15.x
72
+
73
+ - **The configuration is finalized automatically at boot (#364).** Nothing to
74
+ call: the engine runs `Phlex::Reactive.finalize!` once the app has booted,
75
+ after every initializer and every `after_initialize` block the app
76
+ registered. From then on every setting reads back as a frozen copy, and the
77
+ param-type and named-schema registries are closed (registering into either
78
+ after boot already raised). Booting the gem without the engine, call
79
+ `Phlex::Reactive.finalize!` at the end of your own boot.
80
+
81
+ - **The configuration's lists are frozen once the app has booted (#364).**
82
+ Append to `authorization_errors` in an initializer or an `after_initialize`
83
+ block, as before; after boot, assign a new list (`<<` raises `FrozenError`).
84
+ `around_action`, `on_action_error` and `register_token_upgrader` now replace
85
+ a frozen list instead of appending to it; `around_actions`,
86
+ `on_action_error_hooks` and `token_upgraders` are frozen, and
87
+ `authorization_methods`' default is a frozen list. A setting reads back
88
+ frozen too: change it by assigning, never in place.
89
+
90
+ - **Nothing an app configures makes boot raise (#364).** A value no Ractor can
91
+ share is kept as it is and works on the main Ractor only: a block written in
92
+ an initializer (`around_action`, `on_action_error`, `param_type`), a lambda
93
+ that closes over app state, a logger, an APM object, the verifier. An app
94
+ booting without a `secret_key_base` (a production rake task or build step)
95
+ keeps the verifier lazy, as before: the first token raises, not boot.
96
+
97
+ ### Added
98
+
99
+ - **`Phlex::Reactive.finalize!`, `finalized?` and a test-only
100
+ `reset_finalization!` (#364).** `finalize!` freezes the param-type and
101
+ named-schema registries and stores every setting as a deep-frozen,
102
+ Ractor-shareable copy (the objects an app handed in are never frozen), so a
103
+ non-main Ractor can read the configuration. The engine calls it at boot (see
104
+ Upgrading). Writers keep working afterwards, and what they write is shared
105
+ too.
106
+
107
+ - **`Phlex::Reactive.renderer` takes a name (#364).** `renderer =
108
+ "ApplicationController"`, like `base_controller_name`, resolves on every read,
109
+ so a class Zeitwerk reloaded in development is picked up (and the per-thread
110
+ view contexts rebuild for it). A class keeps working, and keeps rendering the
111
+ class object it was given. The README and the install template show the name.
112
+
113
+ - **`phlex_reactive:doctor` reports Ractor readiness (#364).** An advisory line
114
+ (never a failure): eager loading on, the configuration finalized, and each
115
+ setting `finalize!` shares that still holds a value a non-main Ractor cannot
116
+ read, with its class (the verifier, never shareable, is left out).
117
+
118
+ - **RBS signatures (#364).** `sig/` covers the configuration and token surface
119
+ of `Phlex::Reactive`, a component's class-level DSL, Streamable (its instance
120
+ methods and its class methods), `Response`, `ParamSchema`, `Store` and
121
+ `Shareable`, and ships with the gem. Lint runs `rbs -I sig validate`; a spec
122
+ fails when a public method has no signature.
123
+
124
+ - **Concurrency conformance (#364).** conformance-kit 0.4's gates run in CI:
125
+ every `rubocop-thread_safety` cop, with no directory excludes (`NewThread`
126
+ is excluded only from the five spec files that start threads on purpose),
127
+ thread and Async contention examples over rendering, tokens, params and
128
+ broadcasts, a
129
+ Ruby 4.0 Ractor canary (param coercion, Stream wrapping, `Streamable#dom_id`,
130
+ configuration reads and token upgrades run in a non-main Ractor; rendering and
131
+ signing are pending on Phlex and ActiveSupport), and `conformance-audit
132
+ --strict`. A report-only kino smoke serves the dummy app in Ractor mode and
133
+ records the first blocker. Docs: a new "Concurrency & Ractors" guide page.
134
+
135
+ ### Changed
136
+
137
+ - **No reader writes on first use (#364).** Every setting starts at its default
138
+ when the gem loads (the param-type registry is seeded then too; the verifier,
139
+ which needs `Rails.application`, is built by `finalize!` at boot when it can
140
+ be). With eager loading the engine also prepends `verify_authorized`'s
141
+ interceptor to every component and resolves every component's registries and
142
+ render memos at boot (a `ClientBindings`-only view's too), so a request
143
+ writes no class.
144
+
145
+ - **Per-Ractor state instead of `Concurrent::Map` and lock constants (#364).**
146
+ The fragment viewer warnings, the CSRF-name patterns, the once-per-process
147
+ config warnings, the APM adapter memo, the Streamable class registry, the
148
+ pending-markup variants and the registry's write lock live in
149
+ `Phlex::Reactive::Store` (`Ractor.store_if_absent`, with a Mutex-guarded
150
+ table on an engine without Ractors). `lib/` and `app/` no longer reference
151
+ `concurrent-ruby`, which was never a declared dependency.
152
+
153
+ ### Fixed
154
+
155
+ - **Eager loading the gem raised `NoMethodError: undefined method 'to_json'`
156
+ in a process that had not loaded json yet (#364).** `Component::Sortable`
157
+ calls `Hash#to_json` while it loads; it now requires json itself.
158
+
159
+ - **`verify_authorized` could prepend two interceptors to one class (#364).**
160
+ The lazy, request-path `Authorization.instrument!` checked and prepended
161
+ without a lock; two threads first-calling it for one class both prepended.
162
+ It now holds a lock across the check and the prepend.
163
+
164
+ ## [0.15.5] - 2026-10-15
165
+
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
185
+
186
+ ### Added
187
+
11
188
  - **A server deadline counted down on the client — `reactive_countdown(until:, format: :ms, waiting:, done:)` (#400).**
12
189
  `span(**reactive_countdown(until: deadline.iso8601, waiting: "#wait", done: "#done")) { "2:00" }`
13
190
  puts the deadline on the element; the client paints the time left from
@@ -176,6 +353,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
176
353
  anything else raises at render. Client: +27 B gzipped in the default
177
354
  bundle, +20 B in the persist module; the split core unchanged.
178
355
 
356
+ ## [0.15.3] - 2026-10-13
357
+
358
+ ### Added
359
+
179
360
  - **An image fallback that recovers — `reactive_image_fallback(hide: true, show_alt: nil)` (#383).**
180
361
  `img(**reactive_image_fallback)` marks an `<img>` whose `src` fails with
181
362
  `data-reactive-image-state="error"` and hides it; `show_alt: "#bank-name"`
@@ -196,6 +377,91 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
196
377
  +550 B in the split core. Docs: "An image that falls back, and recovers" on
197
378
  the client-ops example.
198
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
+
199
465
  - **Media previews — `js.copy_media` and `reactive_media` (#373).**
200
466
  `js.copy_media("#logo", from: "#logo-007")` copies `src`, `srcset` and `alt`
201
467
  from an `<img>` the server rendered onto a preview `<img>`; the URL is read
@@ -285,6 +551,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
285
551
  Client: +251 B gzipped in the default bundle, +247 B in the split core.
286
552
  Docs: "Keeping client state through a morph" on the client-ops example.
287
553
 
554
+ ## [0.15.1] - 2026-10-12
555
+
556
+ ### Added
557
+
288
558
  - **Modal dialogs — `js.show_modal` / `js.close_dialog` and
289
559
  `data-reactive-modal` (#365).** `js.show_modal("#picker")` calls
290
560
  `HTMLDialogElement#showModal()`, so an `on_client` dialog gets the browser's
@@ -310,6 +580,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
310
580
  every other key alone (+134 B gzipped). `once:` raises on these keys. Existing
311
581
  chords render unchanged.
312
582
 
583
+ ## [0.15.0] - 2026-10-12
584
+
585
+ ### Added
586
+
313
587
  - **Presence — `reactive_presence`, `presence`, `js.presence` (#358).** A
314
588
  roster component declares the presence stream it shows
315
589
  (`reactive_presence :viewers, state: { selected: :integer }, ttl: 45`) and
@@ -353,6 +627,40 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
353
627
  true` without `shortcut:` behaves exactly as before (never prevented, no
354
628
  editable guard).
355
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
+
356
664
  - **Sortable lists — `reactive_sortable`, `reactive_sortable_source`,
357
665
  `reactive_sortable_item` (#350).** Rows drag to reorder a list with a mouse,
358
666
  a finger (a 200 ms press) or the keyboard (Space picks up, arrows move,
@@ -390,104 +698,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
390
698
  client the stream that brings it starts the import). Bundle: default
391
699
  +977 B gzip, split core +412 B, `features/bindings` +524 B.
392
700
 
393
- - **`on_client(…, detail: { success: true })` — run ops only when the event's
394
- payload qualifies (#346).** A binding can require top-level `event.detail`
395
- keys to strictly equal JSON scalars, so `on_client("turbo:submit-end",
396
- js.check_group("ids[]", false), detail: { success: true })` clears a bulk
397
- selection after a successful submit and keeps it after a failed one. A
398
- missing detail or key never matches; a miss runs no confirm and no
399
- `preventDefault`. Each binding record carries its own filter, so two bindings
400
- on one element can split success and failure. A non-scalar value, an empty
401
- Hash, or `detail:` with `once:` raises at render. Records without `detail`
402
- are byte-identical.
701
+ ## [0.14.3] - 2026-10-07
403
702
 
404
703
  ### Fixed
405
704
 
406
- - **A server trigger inside a client-only root reaches the component around it
407
- (#410).** A `ClientBindings` component (a `reactive_filter` list, a
408
- `reactive_show` wrapper, an `on_client` menu) renders a root with no token.
409
- Rendered inside a server component, it swallowed that component's `on(...)`
410
- triggers: Stimulus sends the action to the nearest root, which had no token,
411
- and the outer root's field collection skipped every control under it (the
412
- #15 rule). A tokenless client-only root is now transparent to server round
413
- trips: `dispatch` forwards to the nearest token-bearing root above (skipping
414
- further client-only roots, waking a dormant one), and that root's action
415
- collects the controls under nested client-only roots. A nested server
416
- component keeps its own fields and triggers, and client bindings stay on
417
- their own root. With no token-bearing root above, nothing is sent, and
418
- `verbose_errors`/debug logs one warning. Lazy and deferred shells are not
419
- client-only. Client: +414 B gzipped in the default bundle, +415 B in the
420
- split core.
421
-
422
- - **`reactive_media` waits for `take` characters before calling a key a miss
423
- (#380).** A value shorter than `take:` sliced to a short key, missed the
424
- pool and wrote `blank_label` while the user was still typing. It is now
425
- *pending*: `blank_label` only names a full-length key that misses. New
426
- options: `match:` (a Regexp, anchored to the whole key; a key it rejects is
427
- pending too) and `pending: :clear` (default: clear the preview, empty the
428
- label) or `:keep` (leave both alone). The preview carries
429
- `data-reactive-media-state="pending|hit|miss"`, so a row can hide itself
430
- with CSS. `match:` reaches the browser as a JS pattern: its `\A`/`\z`
431
- anchors are dropped (a trailing `\Z` becomes `\n?`), and what JS reads
432
- differently raises at render (options, escapes such as `\h`/`\p{…}`/`\s`/
433
- `\u{…}`, POSIX and nested classes, `&&`, `(?i)`/`(?>…)`, characters above
434
- U+FFFF). **Behaviour change:** with `take:`, a short value now leaves the
435
- label empty instead of writing `blank_label`. Without `take:` or `match:`
436
- nothing changes. Client: +160 B gzipped in the default bundle (+152 B on
437
- top of #381/#382), +152 B in the bindings module, the split core unchanged. Docs: "Media
438
- previews" on the client-ops example.
439
-
440
- - **A pool `<img>` with an `alt` and no `src` keeps its name (#381).**
441
- `js.copy_media` and `reactive_media` treated a source `<img>` without `src`
442
- as a clear: the preview's `alt` was emptied and `label:` got `blank_label`,
443
- so a known entry with no logo file lost its name. Such a source is now a hit
444
- without an image: the target's `src` and `srcset` are removed, its `alt` is
445
- set, and `label:` gets that `alt`. Only a pool miss, a selector that matches
446
- nothing, or a source that is not an `<img>` clears. Client: +1 B gzipped in
447
- the default bundle, −2 B in the split core; the bindings module is
448
- unchanged.
449
-
450
- - **`js.preview_file` follows an app's upload validation (#382).** A preview
451
- ran once, on `change`, so a file the app then rejected (cleared with
452
- `input.value = ""`, no new `change`) stayed on screen. Two pieces fix it.
453
- `js.preview_file(…, valid: true)` treats an input whose `validity.valid` is
454
- false (`setCustomValidity`, `required`) as empty: it never gets a preview,
455
- and one it had is cleared and its URL revoked. And `reactive:file-changed`
456
- is the documented event for "the app changed this input's files without a
457
- `change` event": bind the op to it as well,
458
- `mix(on_client(:change, op), on_client("reactive:file-changed", op))`, and
459
- the app dispatches it after it clears or invalidates the field. That works
460
- whichever `change` listener runs first. Without `valid:` the op behaves as
461
- before. Client: +22 B gzipped in the default bundle, +34 B in the split
462
- core (on top of #381). Docs: "Preview a chosen image" on the client-ops example.
463
-
464
- - **A stream that renders into a connected root wires what its content needs
465
- (#356).** A root read its feature markers only at connect and after a morph
466
- of itself, so a `reactive_show` target, select-all/count group, filter,
467
- nested list, dirty field or paste trigger that an `append`, `prepend`,
468
- `update`, `before`, `after` or child `replace` brought into it stayed dead
469
- until the root reconnected. Now, once such a stream has rendered, the root
470
- re-runs the seeds of the features it has (a new show target takes the
471
- field's current value) and connects any feature its new content needs, in
472
- both clients; bindings and form also wire a binding whose gate reads true
473
- only then. As after a morph, this covers a root with an action token or a
474
- lazy shell; a tokenless, client-only root does not re-read. On the split
475
- client the stream's render waits for the module the content needs, as it
476
- already did for an incoming root, so the content lands wired; the wait is
477
- capped at 1 s, after which Turbo renders and the root connects the module
478
- when it arrives. A draft
479
- restore no longer marks fields `data-reactive-dirty` on a root that never
480
- opted into dirty tracking (one that only gates a paste trigger).
481
- - **An editable is editable in an engine without `contenteditable="plaintext-only"`,
482
- however it reaches the page (#355).** Such an engine reads the server's
483
- `plaintext-only` as invalid, so the element cannot even be focused. The
484
- bindings feature switched it to `"true"` only when it connected, or after a
485
- morph of the root, which left an editable that a stream appended, a frame
486
- loaded or an unfocused morph rewrote uneditable. The runtime now does the
487
- switch with one observer, installed only in such an engine (elsewhere the
488
- probe is all that runs). Bundle: default +157 B gzip, split core +241 B,
489
- `features/bindings` -138 B.
490
-
491
705
  - **A `checked:`-only `reactive_show_targets` entry hides at zero boxes
492
706
  (#348).** `"#bulk-bar" => { if: { "ids[]" => { checked: 1.. } } }` was left
493
707
  visible once the owning root held no box of the group (the last ticked row
@@ -497,42 +711,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
497
711
  first paint already were. A target with any value term keeps the skip when
498
712
  none of its fields is owned.
499
713
 
500
- ### Performance
714
+ ## [0.14.2] - 2026-10-07
501
715
 
502
- - **Guarded and persisted forms connect in linear time.** Each `warn_unsaved`
503
- root added its own `beforeunload` / `turbo:before-visit` pair on `window`,
504
- and each persisted root its own `turbo:submit-end` listener on `document`.
505
- A browser scans a target's listeners on every add and remove, so connecting
506
- and disconnecting N of them was quadratic. One shared listener per window or
507
- document now serves them all, added with the first root and removed with
508
- the last; behaviour is unchanged (live dirty count, one prompt per visit,
509
- #298). Headless Chromium, insert + connect: 4,000 guarded roots ~306 ->
510
- ~227 ms, 4,000 persisted roots ~280 -> ~230 ms. +194 B gzipped in the
511
- default bundle (110 B for the guard, 84 B for persist).
512
- - **A record-backed token no longer builds a GlobalID URI.** For a record
513
- whose `to_gid` is GlobalID's own and whose id is an Integer, the gid is
514
- built directly as `gid://<app>/<Class>/<id>` (byte-identical, pinned by
515
- `identity_gid_spec.rb`); anything else still calls `to_gid`. The token goes
516
- from ~11.4 µs and 54 objects to ~5.2 µs and 20 (in-place A/B).
517
- - **`rake bench:browser`.** Connect, disconnect and a heap soak for the
518
- shipped client in headless Chromium. The happy-dom connect bench is
519
- engine-relative and missed the quadratic above; this is the one to trust.
520
- - **The doctor's authorization check is linear, not quadratic.** It rebuilt
521
- the Inspector's whole component list (a Prism scan per action) once for
522
- every component. It now reads it once: `doctor.checks` over the dummy app's
523
- 90 components drops from 1,337 ms to 55 ms, and an app with a few hundred
524
- components no longer waits tens of seconds on `rake phlex_reactive:doctor`.
525
- - **`on()` checks for removed keywords without building arrays.** Every
526
- trigger ran `params.keys & REMOVED_ON_KWARGS.keys`. It now returns at once
527
- when there are no extra keywords. `on(:increment)` allocates 5 objects
528
- instead of 8; a 7-trigger component's `render_component` 118 instead of 136
529
- (~5% faster in an in-place A/B, a method-level win that shows on broadcast
530
- fan-out, not request latency).
531
- - **The request bench measures production, not a file watcher.** The dummy
532
- app's test env left code reloading on, so every request globbed `app/`:
533
- `rake bench:request` read ~1.7-2.0 ms and 1,272 objects per state-backed
534
- action, against ~0.30-0.35 ms and 824 objects with it off (same machine,
535
- 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.
536
728
 
537
729
  ## [0.14.1] - 2026-10-07
538
730
 
@@ -564,7 +756,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
564
756
 
565
757
  ## [0.14.0] - 2026-10-07
566
758
 
567
- 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
568
760
  changelog sections of their own.
569
761
 
570
762
  ### Added
@@ -2324,7 +2516,7 @@ changelog sections of their own.
2324
2516
  (`spec/requests/deferred_latency_spec.rb`) pins that a 120 ms segment cost
2325
2517
  moves OFF the actor's reply and onto the deferred leg; it never disappears.
2326
2518
 
2327
- ## [0.9.0] - 2026-07-03
2519
+ ## [0.9.0] - 2026-07-04
2328
2520
 
2329
2521
  Consolidates everything since 0.2.6; tags 0.2.7–0.4.8 shipped without changelog
2330
2522
  sections, so this section spans the whole run to 0.9.0. The jump from 0.4.8 to
@@ -3760,7 +3952,7 @@ room for dogfood fixes.
3760
3952
  Outer flat fields and per-row reactive editing compose cleanly, with no
3761
3953
  name-disjointness workarounds.
3762
3954
 
3763
- ## [0.2.6]
3955
+ ## [0.2.6] - 2026-06-25
3764
3956
 
3765
3957
  ### Added
3766
3958
 
@@ -3787,7 +3979,7 @@ room for dogfood fixes.
3787
3979
  for non-remove/redirect responses, and never double-prepends when the action
3788
3980
  already included a self-targeted stream.
3789
3981
 
3790
- ## [0.2.5]
3982
+ ## [0.2.5] - 2026-06-25
3791
3983
 
3792
3984
  ### Fixed
3793
3985
 
@@ -3803,7 +3995,7 @@ room for dogfood fixes.
3803
3995
  bun unit test (`spec/javascript/reactive_controller.test.js`) that asserts the
3804
3996
  synchronous call and fails on the pre-fix deferred code. Closes #11.
3805
3997
 
3806
- ## [0.2.4] - 2026-06-24
3998
+ ## [0.2.4] - 2026-06-25
3807
3999
 
3808
4000
  ### Fixed
3809
4001
 
@@ -3821,6 +4013,12 @@ room for dogfood fixes.
3821
4013
  byte-identical to source by a guard spec, so the browser tests never validate
3822
4014
  stale client code again. Closes #8.
3823
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
+
3824
4022
  ## [0.2.3] - 2026-06-24
3825
4023
 
3826
4024
  ### Fixed
@@ -3840,12 +4038,6 @@ room for dogfood fixes.
3840
4038
  round trip (only a genuinely absent value falls back to the `initialize`
3841
4039
  default). No API changes. Closes #6.
3842
4040
 
3843
- ### Documentation
3844
-
3845
- - Documented the combined record + state identity (the `{c, gid, s}` token
3846
- shape): the README, `docs/architecture.md`, and `docs/security.md` no longer
3847
- frame `reactive_record` and `reactive_state` as mutually exclusive. (#9)
3848
-
3849
4041
  ## [0.2.2] - 2026-06-24
3850
4042
 
3851
4043
  ### Fixed
@@ -3922,7 +4114,17 @@ room for dogfood fixes.
3922
4114
  scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
3923
4115
  state-backed by default or record-backed with `--record`.
3924
4116
 
3925
- [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
3926
4128
  [0.14.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.0...v0.14.1
3927
4129
  [0.14.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.9.0...v0.14.0
3928
4130
  [0.9.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.6...v0.9.0