phlex-reactive 0.13.4 → 0.14.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1079 -630
  3. data/README.md +612 -17
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1582 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/reactive_controller.js +86 -5821
  37. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  38. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  39. data/app/javascript/phlex/reactive/runtime.js +3706 -0
  40. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  41. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  42. data/lib/phlex/reactive/component/dsl.rb +74 -2
  43. data/lib/phlex/reactive/component/helpers.rb +39 -9
  44. data/lib/phlex/reactive/component/lazy.rb +138 -2
  45. data/lib/phlex/reactive/component/registry.rb +2 -0
  46. data/lib/phlex/reactive/component/selection.rb +188 -0
  47. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  48. data/lib/phlex/reactive/doctor.rb +166 -8
  49. data/lib/phlex/reactive/dormant.rb +66 -0
  50. data/lib/phlex/reactive/engine.rb +63 -3
  51. data/lib/phlex/reactive/fragment.rb +233 -0
  52. data/lib/phlex/reactive/inspector/report.rb +24 -3
  53. data/lib/phlex/reactive/inspector.rb +23 -2
  54. data/lib/phlex/reactive/js.rb +47 -2
  55. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  56. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  57. data/lib/phlex/reactive/param_schema.rb +68 -54
  58. data/lib/phlex/reactive/show_conditions.rb +84 -56
  59. data/lib/phlex/reactive/streamable.rb +27 -7
  60. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  61. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  62. data/lib/phlex/reactive/version.rb +1 -1
  63. data/lib/phlex/reactive.rb +84 -0
  64. data/lib/tasks/phlex_reactive.rake +6 -2
  65. metadata +38 -1
data/CHANGELOG.md CHANGED
@@ -6,9 +6,183 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.14.1] - 2026-10-07
9
10
 
10
11
  ### Added
11
12
 
13
+ - **`js.check_group(group, checked = true, global: false)` — "clear selection"
14
+ without a controller (#342).** A client op that ticks or unticks every owned
15
+ box of a checkbox group from any control (a bulk-action bar's ✕), dispatching
16
+ `input` + `change` on each box it flips and re-syncing the header,
17
+ `reactive_count`, `reactive_show` and `reactive_enable` once — the select-all
18
+ header's own path. A nested root's boxes are left alone; `global: true` (a
19
+ trigger outside the owning root) flips the group in every connected reactive
20
+ root that owns boxes of it. Actor-only: `broadcast_to(js:)` refuses it. A form
21
+ `reset` inside the root now re-syncs the group bindings and re-arms
22
+ checked-count `reactive_on_complete` latches.
23
+
24
+ - **A count and an enable outside the root that owns the checkboxes (#343).**
25
+ `reactive_group_targets("ids[]", count: "#bulk-count", enable: { "#bulk-archive" => 1.. })`,
26
+ spread on the root that owns a checkbox group, drives outside, id-allowlisted
27
+ elements: the ticked count as text, and `disabled` from an Integer (exact
28
+ count), a Range (threshold) or a full conditions Hash. It is the count and
29
+ enable sibling of `reactive_show_targets`, so a list that is its own reactive
30
+ root can drive a bulk bar the page renders. Targets re-sync on every change,
31
+ the header's flip, `js.check_group`, a morph of the owning root and rows
32
+ added or removed; a non-id selector raises at render and is warn-skipped on
33
+ the client. `reactive_group_target_attrs(group, :count)` /
34
+ `(group, :enable, 1..)` give the outside markup its first paint from
35
+ `reactive_values`.
36
+
37
+ ## [0.14.0] - 2026-10-07
38
+
39
+ This section also covers 0.10.0 through 0.13.4, which were released without
40
+ changelog sections of their own.
41
+
42
+ ### Added
43
+
44
+ - **Bulk-selection lists without a per-list controller (#319).**
45
+ `reactive_select_all("ids[]")` makes a header box that ticks or unticks every
46
+ owned box of the group (dispatching `input` and `change` on each one it flips) and shows
47
+ checked / indeterminate / unchecked from the group. `reactive_count("ids[]")`
48
+ writes the ticked count. `reactive_enable(if:/if_any:/unless:)` is
49
+ `reactive_show`'s sibling that flips the element's own `disabled` (a button,
50
+ a fieldset), with the same server first paint. The conditions language gains
51
+ `{ checked: n }` / `{ checked: 1.. }` — the ticked count of a checkbox group,
52
+ in every binding that takes conditions, evaluated for first paint from
53
+ `reactive_values` (an Array of checked values, or a count). Rows appended or
54
+ removed by a stream re-sync the header, the count, the show/enable bindings
55
+ and show targets, and re-arm `reactive_on_complete` without firing it; a
56
+ header sets every box before dispatching, so a count condition sees the
57
+ final count; a nested root's boxes are never counted or flipped. `js.submit(to,
58
+ submitter: "#delete-submit")` submits through that submit control, so its
59
+ `name=value` posts; anything that is not a submit control of the form warns
60
+ and falls back to a plain submit. The client grows by about 0.8 KB gzip
61
+ (the default bundle 24,976 → 25,786 B).
62
+
63
+ - **`phlex_reactive:doctor` flags Falcon under `:thread` isolation (#321).**
64
+ Falcon serves each request as a fiber on one thread, so under Rails' default
65
+ `config.active_support.isolation_level = :thread` every piece of thread-keyed
66
+ state (`CurrentAttributes`, `IsolatedExecutionState`, thread variables, the
67
+ transactional-test connection lock) is shared between requests. A new
68
+ `fiber_isolation` check fails when Falcon serves the app and the level is
69
+ `:thread`, with the fix (`config.active_support.isolation_level = :fiber` in
70
+ `config/application.rb`); it passes under `:fiber` and for Puma or any other
71
+ server. The server is read from the bundle and the server constants already
72
+ loaded, never by requiring one; when Falcon is bundled beside another server
73
+ and is not the only server loaded, the line is advisory. The installation docs gain a
74
+ "Running under Falcon" section.
75
+
76
+ - **A hotkey pressed before the controller connects is no longer lost (#303).**
77
+ `phlex/reactive/early` now records `window:` triggers — how a hotkey is
78
+ usually bound (`on(:toggle, event: "keydown.k", window: true)`,
79
+ `on_client("keydown.k", …, window: true)`) — by a capture listener on
80
+ `window`, and the controller replays them on connect. Such a keypress also
81
+ wakes a dormant root. It is never prevented (a window binding is not prevented
82
+ once connected either), and it is replayed only if the controller connects
83
+ within 1.5 s of the keypress, or the early-event TTL if that is shorter.
84
+ `outside:` triggers and non-bubbling events from inside the page are not
85
+ recorded (Stimulus's window listener never hears the latter). Two roots
86
+ sharing a hotkey both hear it, as they do once connected. `early.min.js`
87
+ grows from 1,060 B to 1,296 B gzipped; its test budget moves from 1,100 to
88
+ 1,300 B. The replay side adds about 210 B gzipped to both the default bundle
89
+ and the opt-in core.
90
+ - **`phlex_reactive:doctor` finds the early import anywhere and checks every import map (#307).** The dormant-roots check now scans every app JS entry and inline module scripts in `app/views` and `app/components` (naming the file), and a new advisory line per import map lacking the `phlex/reactive/early` pin; set `Phlex::Reactive.importmaps = -> { { "landing" => map } }` for maps outside `Rails.application.importmap`.
91
+ - **`reactive_lazy(cache:)` — privately cacheable lazy renders (#277).**
92
+ `reactive_lazy cache: { max_age: 10.minutes }` (combinable with `on:` and
93
+ `tag:`) renders a stable signed URL into the shell and the client GETs it; the
94
+ new `GET /reactive/fragment/:id` answers `Cache-Control: max-age=…, private`
95
+ with an ETag, so the browser reuses the fragment instead of asking again. With
96
+ `on:` that is "load on first open, reuse across page views", and a refresh
97
+ morph re-materializes from the browser cache. By default the reply says
98
+ `Vary: Cookie`; define `reactive_cache_viewer` to key the URL on the viewer
99
+ instead (needed for reuse across page views under Rails' cookie session store,
100
+ whose cookie changes on every response) and `reactive_cache_version` to bust
101
+ it. Every non-200 reply is `no-store`, a component without `cache:` is 404, the
102
+ id is purpose-scoped (not an identity or defer token), a blank
103
+ `reactive_cache_viewer` falls back to `Vary: Cookie` (never a shared anonymous
104
+ key) and `u` is a keyed digest, a cacheable reply carries no `Set-Cookie` (a
105
+ request during which any callback changed the session, set or consumed a
106
+ flash, minted a CSRF token, or wrote a cookie is `no-store` instead; a filter
107
+ can tighten the policy — `no_store`, `expires_now`, a shorter `max-age`,
108
+ `must-revalidate`, and `max-age=0` means `no-store` — never loosen it; the
109
+ ETag is the body only, so `etag { }` blocks do not apply to fragments), a redirected or non-turbo-stream reply is a failed load,
110
+ and
111
+ a render that embeds a form authenticity token is served `no-store`. New
112
+ settings: `Phlex::Reactive.fragment_path` (if you change it, also render
113
+ `<meta name="phlex-reactive-fragment-path">`), and
114
+ `Phlex::Reactive.fragment_cache_max_age_limit` (1 hour). `phlex_reactive:doctor`
115
+ checks the GET route. Not breaking: opt-in.
116
+
117
+ - **`reactive_lazy(on:)` — load on an event or on visibility (#276).**
118
+ `reactive_lazy on: "panel:opened"` keeps the placeholder shell until that
119
+ event reaches it (bubbling from inside counts), then fetches the real render
120
+ once; `on: :visible` (or `on: { visible: "200px" }`) does the same when the
121
+ shell first enters the observer region (the viewport, grown by any
122
+ rootMargin). No request on page load, no re-arm after the
123
+ replace, and no TTL: the shell carries the identity token and a
124
+ framework-owned `__materialize` trigger, which the action endpoint answers
125
+ with the real render (403 for components that aren't `reactive_lazy(on:)`).
126
+ `action :__materialize` is now refused at declaration. A Turbo morph that
127
+ turns the loaded root back into the shell re-materializes it at once, and a
128
+ shell that is still unloaded after a morph is re-armed (a failed load's retry
129
+ path). Plain `reactive_lazy` is unchanged.
130
+
131
+ - **`phlex/reactive/early`: triggers that fire before the controller connects
132
+ are replayed, not lost (#273).** Import it eagerly
133
+ (`import "phlex/reactive/early"`; the engine pins it with `preload: true`) and
134
+ the `reactive` controller may load lazily: a click, submit, key or custom
135
+ event that reaches an element-bound `on`/`on_client` trigger (not a `window:`
136
+ or `outside:` one) before its root connects is
137
+ queued — its native default stopped where the controller would stop it — and
138
+ replayed once on connect. `:once` triggers replay once. Entries older than
139
+ `Phlex::Reactive.early_event_ttl_ms` (10 s, read from
140
+ `<meta name="phlex-reactive-early-ttl">`) or whose element left the page are
141
+ dropped, with a warning under `data-reactive-verbose`. The module is under
142
+ 1.1 KB gzipped, asserted by a test. Every root now also sets
143
+ `data-reactive-connected` and emits a bubbling `reactive:connect` event when
144
+ its controller connects. Apps that never import `early` see no change beyond
145
+ that event and attribute. If you add the import, remove any hand-written
146
+ "wait for the controller, then dispatch" loop, or the trigger fires twice.
147
+
148
+ - **Dormant roots: mount the controller on first use (#274).**
149
+ `reactive_dormant` (class-level, inherited) or `reactive_root(dormant: true)`
150
+ / `reactive_attrs(dormant: true)` renders `data-reactive-dormant="reactive"`
151
+ in place of `data-controller="reactive"`, so a root that only matters after a
152
+ gesture neither mounts the controller nor, when it loads lazily, fetches it
153
+ (68 KB minified, 20 KB gzipped). The first element-bound `on`/`on_client`
154
+ trigger (not a `window:` or `outside:` one) wakes the
155
+ root: `phlex/reactive/early` moves the identifier into `data-controller`
156
+ (other controllers on the root stay connected) and the trigger is replayed
157
+ once on connect. Requires `import "phlex/reactive/early"`;
158
+ `phlex_reactive:doctor` lists the dormant components and whether it found
159
+ the import. The actor's reply, a `reactive_lazy(on:)` materialize and the
160
+ defer endpoint render the root awake; the page and broadcasts render it
161
+ dormant, and a dormant render landing on an awake root is re-woken by the
162
+ next trigger. A `reactive_lazy(on: "event")` shell can be dormant (the event
163
+ wakes and loads it in one request); `on: :visible` cannot, and declaring both
164
+ raises. Non-dormant roots render the same
165
+ bytes as before. `early.min.js` grows 53 B (1,007 → 1,060 B gzipped; the test
166
+ budget moves from 1,024 to 1,100 B).
167
+
168
+ - **`phlex_reactive:actions` tells a deliberate skip from a forgotten check
169
+ (#278).** The `auth` column now has three states: `authorized*` (a call was
170
+ detected), `skipped` / `skipped (class)` (declared `skip_verify_authorized`,
171
+ per action or component-wide, inherited too) and `unverified` (neither).
172
+ `UNVERIFIED=1` lists only the unverified rows. The JSON and the MCP
173
+ `phlex_reactive_actions` / `phlex_reactive_find` tools add `authorization`
174
+ (`detected`/`skipped`/`none`) and `authorization_skip` (`class`/`action`/`null`);
175
+ `authorization_call_detected` stays for compatibility.
176
+
177
+ - **System-test helpers that count reactive requests (#279).**
178
+ `reset_reactive_requests!`, `have_reactive_requests(n, kind: nil)` and
179
+ `reactive_request_count` in `TestHelpers::System` answer "one request on
180
+ first open, none after" without a fetch spy. The matcher waits until the
181
+ layer is idle, then asserts the exact count (`kind: :action` or `:defer`),
182
+ failing at once if the count overshoots. The client keeps per-kind totals on
183
+ `<html data-reactive-requests>` only under the verbose gate (dev/test by
184
+ default); production writes nothing.
185
+
12
186
  - **Several `on_client` bindings on one element (#271).** Two `on_client`
13
187
  calls merged with `mix` now both work — e.g. a root that closes a menu on an
14
188
  outside click AND on Escape. Each call carries its own descriptor, flags and
@@ -474,324 +648,352 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
474
648
  `StaleReferenceError` or read a transient blank — the matcher waits for the value
475
649
  to settle.
476
650
 
477
- ### Fixed
478
-
479
- - **`reactive_compute` never saw a checkbox's checked state (#262).**
480
- `#recompute` resolved each declared name first-wins and read `.value`, which
481
- for a checkbox is a constant. Measured: a Rails `check_box` pair read `0`
482
- ticked or not (the hidden companion comes first), a lone `value="1"` box read
483
- `1` either way, and a box with no value attribute read `0` (`Number("on")` is
484
- NaN). A radio group read its FIRST radio's value, whichever was checked. The
485
- reducer ran on the toggle, from values that could not have changed.
486
-
487
- A checkbox now contributes its checked state, coerced by the declared type:
488
- `1`/`0` as a `:number` (and in the array form), `"true"`/`"false"` as a
489
- `:string`, and `true`/`false` under the new **`:boolean`** type. It wins over
490
- its same-named hidden companion, as it already did for `reactive_show`. A radio
491
- group contributes its checked radio's value. The identity mirror and the
492
- `mirror:` fallback paint the same reading.
651
+ - **Conditional confirm — warn only when the values look suspect (#179).**
652
+ `confirm:` now takes a Hash for soft-validation-before-submit, so the dialog fires
653
+ ONLY when the field values are wrong — instead of a hand-written submit handler that
654
+ inspects fields and calls `confirm()` itself. Two forms, both evaluated client-side
655
+ over the same collected fields `reactive_compute` reads:
656
+ - **Declarative** — `confirm: { when: { total: 0 }, message: "Total is 0 — continue?" }`.
657
+ `when:` reuses `reactive_show`'s conditions language verbatim: a scalar is equals,
658
+ a `Range` is a threshold (`qty: 100..`), an `Array` is membership. Zero JS. The
659
+ dialog fires when the condition MATCHES; a clean value submits silently.
660
+ - **Named predicate** — `confirm: { predicate: "end_before_start", message: "…" }` for
661
+ multi-field logic the single-field form can't express. Register a pure function at
662
+ boot (`setConfirmPredicate("end_before_start", ({ starts_at, ends_at }) => ends_at < starts_at)`),
663
+ the twin of `setComputeReducer`. An unregistered name warns and proceeds without a
664
+ dialog. Works on `on(...)` AND `on_client(...)`. The predicate is soft-validation UX,
665
+ **not authorization** — a user can bypass it and the action still hits the endpoint's
666
+ real authorize/default-deny; never let it stand in for a server-side check.
493
667
 
494
- Outputs were wrong the same way: a result whose name resolved to a checkbox
495
- pair wrote `.value` on the hidden companion, changing what the UNCHECKED state
496
- submits and leaving the box alone. An output now sets a checkbox's `checked`
497
- from the result's truthiness (`""`, `"0"`, `"false"`, `0` and `false` untick)
498
- and checks the radio of a group that carries the result.
668
+ - **`confirm:` on `on_client(...)` — themed confirmation for zero-round-trip client ops (#178).**
669
+ The client-op path gains the SAME overridable `confirmResolver` gate `on(:action, confirm:)`
670
+ has (#52/#55). A destructive-feeling client op (clear a draft, reset a form) gets the
671
+ app's themed dialog with one line and no round trip:
672
+ `button(**on_client(:click, js.text("#draft", ""), confirm: "Discard this draft?"))`.
673
+ The gate lives in the user-gesture path (`runOps`), never in the shared op applier — a
674
+ server-pushed `reactive:js` op stream must not prompt. `setConfirmResolver` now themes
675
+ both paths at once.
499
676
 
500
- **If a reducer relied on the old reading**, it changes: a lone checkbox with a
501
- numeric `value` used to read that number regardless of state and now reads
502
- `1`/`0` — write the amount in the reducer (`gift ? 25 : 0`). Apps that worked
503
- around the defect by reading the box from `document` inside the reducer keep
504
- working and can drop the workaround.
677
+ - **CI transport verification matrix — the browser suite runs on Action Cable AND pgbus (#187).**
678
+ The system job is now `server × transport` (Puma/Falcon × cable/pgbus). The pgbus
679
+ cells add a plain `postgres:18` (pgbus vendors the PGMQ schema via its own
680
+ migrations — no extension image) and set `TRANSPORT=pgbus`, so the existing browser
681
+ suite proves the reactive round trip is transport-agnostic, and new `:pgbus`-tagged
682
+ specs prove real cross-tab broadcast delivery + actor-echo exclusion over live
683
+ Postgres SSE. `rake spec:system_matrix` runs the 2×2 locally (pgbus cells skip with
684
+ a note when Postgres isn't reachable); `rake pgbus:prepare_test_db` sets up the DB.
685
+ pgbus remains an optional, non-gemspec dependency — it is now a first-class *tested*
686
+ transport, not a required one.
505
687
 
506
- Cost, same machine, happy-dom (engine-relative): the 30-input calculator
507
- bench goes from 16.3 to 18.4 µs/iter, allocations flat. That is one `el.type`
508
- read per declared name, which is what telling a checkbox from a text field
509
- takes.
688
+ - **Compound & numeric `reactive_show` predicates — `all:`/`any:` and
689
+ `gte:`/`gt:`/`lte:`/`lt:` (#176).** Value-conditional visibility now spans
690
+ **more than one field** and **numeric thresholds**, staying inside the
691
+ eval-free "declared literal predicate" contract. `all:` / `any:` fold a list of
692
+ per-field terms (`{ field:, equals:/not:/in:/gte:/… }`) with one fixed
693
+ connective — AND vs OR over the same literal vocabulary, no expression surface;
694
+ one flat binding replaces wrapper-div nesting and is the only way to express OR.
695
+ `gte:`/`gt:`/`lte:`/`lt:` compare `Number(value)` against a literal number baked
696
+ into the binding (the RHS must be a real `Numeric` — a typo fails at render); a
697
+ non-numeric field value is `NaN` → hidden, the safe reveal-on-threshold default.
698
+ Numeric predicates work standalone, as a compound term, and inside a
699
+ `reactive_show_targets` map. Malformed terms fold **false** (fail-closed:
700
+ default-deny). The single-field `reactive_show(:field, equals:)` form is
701
+ unchanged; the additions are backwards compatible.
510
702
 
511
- - **The dropped-param hints said nothing for a schema with string keys.**
512
- `ParamSchema.compile` keeps keys as the author wrote them, so
513
- `params: { "date" => :string }` is valid, but the #16/#21 hints only looked
514
- up symbol keys. The param was still dropped and logged, just without the hint
515
- that says where the schema declares it.
703
+ - **Installable Claude debugging skill + `rails g phlex:reactive:claude` (#168).**
704
+ The gem ships a `phlex-reactive-debugging` skill (the doctor → inventory → find
705
+ → browser `report()` → MCP workflow + a failure table) under `lib/`, and the
706
+ new generator copies it into a host app's `.claude/skills/` and writes the MCP
707
+ server entry to `.mcp.json` — only when absent (it never rewrites an existing
708
+ `.mcp.json`; it prints the snippet instead). A new **Debugging & tooling** docs
709
+ page ties the four surfaces together; the security page documents
710
+ `verify_authorized`; the instrumentation table gains the `:unverified` outcome;
711
+ the README gains a Debugging & tooling section + the config rows.
516
712
 
517
- - **A checkbox group collapsed to one boolean, and the chosen values never left
518
- the browser (#258).** `#collectFields` wrote `fields[name] = field.checked` for
519
- every checkbox, so several boxes sharing a `features[]` name overwrote each
520
- other and the action received the LAST box's checked state — `{}` under a
521
- `[:string]` schema, `"false"` under a flat `:string` one, silent either way,
522
- while a native submission of the same boxes sends
523
- `features[]=news&features[]=events`. A name ending in `[]` is now collected as
524
- an array of the chosen values: a ticked box contributes its `value`, an
525
- unticked one nothing, a `<select multiple>` its selected options. The suffix is
526
- the only trigger — a group says so rather than being inferred from two controls
527
- sharing a name.
713
+ - **On-demand client inspector — `phlex/reactive/inspect` (#168).** A standalone
714
+ JS module (the `confirm.js`/`compute.js` precedent — **zero hot-path cost**, no
715
+ edit to `reactive_controller.js`, loaded only when imported) that scans the live
716
+ DOM and maps every reactive root + bound trigger back to the server
717
+ `Component#action` names. From the browser console:
718
+ `(await import("phlex/reactive/inspect")).report()` prints a `console.table` of
719
+ every reactive root — its `id`, decoded token payload (component class, gid,
720
+ state keys, token version — try/catch base64+JSON decode, degrading to
721
+ `{ opaque: true }` on a Marshal-serialized payload), status attrs, triggers
722
+ (action + event + params + debounce/throttle/confirm), client-only ops,
723
+ computes, the `name`d fields the dispatch would collect, and the
724
+ show/filter/text binding families. Triggers are scoped to the **nearest** root,
725
+ so nested roots aren't double-attributed. The server↔client mapping is
726
+ by-name: `scan()`'s `component` + trigger `action` strings are exactly the
727
+ identifiers `phlex_reactive:actions` and the MCP tools list. Pinned by the
728
+ engine (not preloaded); pure read, never mutates the page.
528
729
 
529
- **That makes the suffix a migration point.** ANY `[]`-named control now
530
- contributes to an array, including a single one and including a named rich
531
- editor or bare contenteditable, which the collector reads in a second pass: a
532
- lone `<input type="text" name="tags[]">` used to post `"abc"` and now posts
533
- `["abc"]`. An editor sharing its `[]` name with another control adds its
534
- value to the group instead of standing down behind it, so the entry count
535
- changes there too. Beside a radio it still stands down, because a radio keeps
536
- its single value with or without the suffix. A ready but empty editor
537
- contributes an empty string, the way an empty text field in the same group
538
- does. An unticked box still contributes nothing. A hidden input under the
539
- same name keeps contributing: nothing in the DOM tells a hidden that mirrors
540
- an editor from one that is a list JS maintains, and a value posted twice is
541
- visible where a swallowed one is not. Against a flat `params: { tags: :string
542
- }` the array coerces to the literal `"[\"abc\"]"` — silently, with a 200. A
543
- control whose name ends in `[]` has to be declared as an array type (`tags:
544
- [:string]`), or renamed without the suffix if it was never meant as a list.
730
+ - **Read-only diagnostic MCP server (#168).** `bin/rails phlex_reactive:mcp`
731
+ starts a stdio [MCP](https://modelcontextprotocol.io) server exposing five
732
+ read-only tools — `phlex_reactive_doctor`, `phlex_reactive_components`,
733
+ `phlex_reactive_actions` (optional `component:` filter), `phlex_reactive_find`
734
+ (fuzzy search + Prism method source), `phlex_reactive_config` (redacted) — so
735
+ Claude Code inside a host app can introspect the live reactive registry when
736
+ debugging. The `mcp` gem is **optional and lazy**: it is NOT a gemspec runtime
737
+ dependency; `Phlex::Reactive::MCP.load!` requires it on demand with a helpful
738
+ message when missing (the pgbus pattern), and the gem-dependent tool tree stays
739
+ out of the Zeitwerk autoloader — a host app without `mcp` boots and eager-loads
740
+ unaffected. Every tool is read-only and non-destructive (no arbitrary-query or
741
+ mutation tool) and reports names/paths/schemas only — never a token, secret, or
742
+ runtime state; `phlex_reactive_config` never emits the verifier or
743
+ `secret_key_base`. Consumer `.mcp.json`:
744
+ `{ "mcpServers": { "phlex-reactive": { "command": "bin/rails", "args": ["phlex_reactive:mcp"] } } }`.
745
+ Known constraint: stdio MCP needs a clean stdout — an initializer that `puts`
746
+ breaks the transport (same caveat as pgbus).
545
747
 
546
- Three shapes keep their meaning on purpose: a lone checkbox without `[]` stays
547
- the documented yes/no boolean, a radio group keeps its single checked value
548
- with or without the suffix, and a hidden input sharing a name with a checkbox
549
- is that box's companion. The companion is identified by the shared name, not
550
- by its value, because Rails renders three of them: `check_box` emits
551
- `value="0"`, `check_box(..., multiple: true)` the same under a `[]` name, and
552
- `collection_check_boxes` a blank one — or none at all with
553
- `unchecked_value: nil`. Reading the second shape by value collected
554
- `["0","0","0","3"]` for three boxes with the third ticked.
748
+ - **verify_authorized runtime guard (#168).** New
749
+ `Phlex::Reactive::Authorization` (fiber-local tracking window, method
750
+ interception, the enforcement decision), `mark_authorized!` instance helper,
751
+ the `skip_verify_authorized` DSL (registry #6, inherits like the other five),
752
+ and `Phlex::Reactive.verify_authorized` / `authorization_methods` config
753
+ (`defined?`-guarded so an explicit override sticks). See the breaking note
754
+ above for the behavior and remedies.
555
755
 
556
- A form body can't carry an empty array, so a cleared group used to go missing
557
- whenever the client sent a form body (as soon as a file input holds a file),
558
- and the action got its keyword default instead of `[]`. Now the client names
559
- the cleared group in `empty_groups[]`, a field next to `token`, `act` and
560
- `params`, and the endpoint sets it to `[]`. A request without that field
561
- behaves as before, and a group that carries values keeps them.
756
+ - **Action inventory — `Phlex::Reactive::Inspector` + rake tasks (#168).** A new
757
+ read-only introspection layer that answers "what reactive actions exist in this
758
+ app, where are they defined, and is each authorized?" without grepping.
759
+ `Phlex::Reactive::Inspector.components` discovers every constant-backed reactive
760
+ component from the loaded `Streamable` registry; `.find(query)` fuzzy-matches
761
+ one (exact > prefix > substring > subsequence, on both the demodulized and the
762
+ full name). Each action reports its declared param schema, `file:line`, the full
763
+ `def … end` source (extracted with **Prism**, degrading to `nil` on an
764
+ unreadable/unparseable file — never raising), and a **heuristic** authorization
765
+ status (a Prism scan for a configured authorization method or
766
+ `mark_authorized!` in the body — advisory only, since a helper may authorize
767
+ indirectly). Two shipped rake tasks surface it: `bin/rails
768
+ phlex_reactive:actions` (plain-text table, `FORMAT=json` for tooling) and
769
+ `bin/rails "phlex_reactive:find[query]"` (ranked matches; top match in detail
770
+ with each action's method source). Output is names/paths/schemas only — never
771
+ tokens, secrets, or runtime state (the instrumentation privacy contract
772
+ extended to tooling). `Doctor` now delegates its `constant_backed_component?`
773
+ filter to the Inspector so the endpoint-rebuild predicate lives in one place.
562
774
 
563
- It applies to params the action declares as an array, by plain name or with
564
- the component's `reactive_scope` in front, and only at the top level. A group
565
- declared one level down or inside nested attributes is ignored and keeps its
566
- keyword default; `verbose_errors` logs it. The endpoint never takes the key it
567
- writes from the request. It looks the name up in the declaration and writes
568
- the declared key, so a request can't create a param the action didn't ask for.
775
+ - **Deferred reply segments — `reply.defer` (#165).** An expensive part of a
776
+ reply (a cross-aggregate rollup, a report) no longer stalls the actor's
777
+ interaction: `reply.streams(cheap).defer(SessionTotals.new(workout:))`
778
+ returns the cheap streams immediately and streams the real render to the
779
+ SAME actor when it finishes. Keep-content default (the stale value stays
780
+ visible, marked `data-reactive-defer-pending` + `aria-busy` for CSS
781
+ shimmer); `placeholder: true` / a component swaps a skeleton in;
782
+ `morph: true` morphs the arrival (the mode rides INSIDE the signed token).
783
+ Transactional (the directive rides the post-commit reply — a rollback or a
784
+ denied action leaks nothing), actor-scoped (peers keep `broadcast_*_to`),
785
+ superseding (a newer action for the same target aborts the in-flight
786
+ deferred render — no stale paint), and interactive on arrival (fresh action
787
+ token). Delivery is transport-adaptive (`Phlex::Reactive.defer_transport`,
788
+ default `:auto`): a parallel fetch to the new `POST /reactive/defer`
789
+ endpoint everywhere (purpose-scoped, short-TTL defer token —
790
+ `defer_token_ttl`, default 120s; `reply.defer` tokens are **actor-bound**
791
+ to the requesting session so a leaked one can't be redeemed elsewhere,
792
+ `reactive_lazy` shell tokens are unbound by necessity — they render
793
+ before a session exists — with the TTL + `authorize!` as their bound;
794
+ never interchangeable with action tokens),
795
+ or a **durable pgbus one-shot stream + `DeferredRenderJob`** when pgbus's
796
+ reactive Streams and ActiveJob are present (`defer_job_queue` config; the
797
+ durable since-id replay closes the broadcast-before-subscribe race, and the
798
+ broadcast tears down its own subscription). The push lane's one-shot queue is
799
+ reclaimed by pgbus's age-based orphan-stream sweep (**pgbus ≥ 0.9.10**; run
800
+ the Dispatcher with `streams_orphan_threshold` set); we never eager-drop it
801
+ (that would reopen the delivery race). The one-shot key is sized to the live
802
+ pgbus `queue_prefix` budget, so a non-default prefix can't overflow it; the
803
+ render job broadcasts a cleanup on ANY failure so the actor's pending state
804
+ always resolves. Every capability gap degrades to the fetch lane — the
805
+ Action-Cable-or-pgbus invariant holds. **Profile first:** an app-side N+1
806
+ looks exactly like framework lag; defer is for segments that are genuinely
807
+ expensive after the synchronous path is cheap.
569
808
 
570
- A blank entry (`params[name][]=""`) would have been the other way, but the
571
- schema reads `[""]` per element type, and for a `[:file]` param behind
572
- `has_many_attached` that is the difference between "not sent" and "remove the
573
- attachments".
809
+ - **Lazy initial mount — `reactive_lazy` (#165).** The same machinery for the
810
+ FIRST render (Livewire `#[Lazy]`): the page ships the component's
811
+ placeholder shell (`deferred_placeholder`, or a built-in pending shell) with
812
+ the defer token on the root; the client fetches the real content on connect
813
+ AND after a Turbo page-refresh morph (so a lazy component survives a
814
+ `turbo:reload`). `reactive_lazy tag: :tr` (etc.) ships a shell element that
815
+ matches a `<tr>`/`<li>` root instead of an invalid `<div>`.
816
+ Reactive-machinery renders (an action's self-replace, broadcasts, the defer
817
+ endpoint/job) stay REAL, so actions never pay two round trips.
574
818
 
575
- `post_reactive_multipart` takes `empty_groups:` so a request spec can send a
576
- cleared group the way the client does. `ParamSchema#array_param(name)`
577
- returns the declared key for an array param, or `nil`.
819
+ - **pgbus capability gates.** `Phlex::Reactive.pgbus?` and `.pgbus_streams?`
820
+ (the documented broadcast-accepts-`:exclude` probe, now actually
821
+ implemented) plus `.defer_push_capable?` for the defer push lane.
578
822
 
579
- `reactive_persist` drafts such a group as the list of ticked values and
580
- restores exactly those boxes; before, the draft held one boolean and the
581
- restore ticked every box of the group. Whether the server already rendered a
582
- box ticked — in which case it keeps its say and the draft yields — is decided
583
- once before the restore walks the controls, because the walk writes `checked`
584
- as it goes and asking from inside it would read the restore's own work: the
585
- first box it ticks would make every later box of the group look
586
- server-rendered, and a draft of two values would come back as one. A
587
- non-checkbox control sharing the group's name additionally threw inside the
588
- draft write, which is swallowed — the root then persisted nothing at all,
589
- silently. On restore, such a control keeps what the server rendered whenever
590
- the group has two or more contributors: the list records the values, not
591
- which control each one came from, so replaying it would paste `freeform,news`
592
- into a text field, an editor, or a contenteditable. A group of ONE
593
- contributor has no such ambiguity — its single entry can only have come from
594
- that control — so a plain field whose name merely ends in `[]`, the usual
595
- shape for a list JS maintains, keeps its draft exactly as it did before
596
- groups existed. A `<select multiple>` reads a list by matching option values,
597
- which is only sound when the list is its own: sharing a group with another
598
- contributor, it too keeps what the server rendered, since a text value that
599
- happens to equal an option would otherwise select it.
600
-
601
- Drafts written before this release are not discarded, but their group key is
602
- no longer applied to the controls that read a list: it holds one boolean (or,
603
- in a mixed group, whichever control wrote last), and applying that kept
604
- causing damage for as long as the draft lived — by default seven days after
605
- the upgrade. The damage differed by control. A checkbox group came back fully
606
- ticked. A `<select multiple>` sharing the group's name lost its rendered
607
- selection instead, because under `restore: "always"` the select branch skips
608
- the "the server had a say" check and matches `Set{"true"}` against its
609
- options, where nothing matches. The next snapshot replaces the key with the
610
- list. Only that one key changed meaning, which is why `PERSIST_VERSION` stays
611
- where it is: bumping it would also throw away the drafted prose of every form
612
- that has no checkbox group at all.
613
-
614
- - **`reply.pending` kept its settle handle under
615
- `enqueue_after_transaction_commit = true` (#254).** The handle was captured in
616
- `Phlex::Reactive::Settles#serialize`, which reads a thread-local that lives
617
- only for the duration of `reply.pending`'s enqueue block. Under Rails'
618
- `ActiveJob::Base.enqueue_after_transaction_commit = true` (the 7.2+
619
- recommended setting) `job.enqueue` is deferred to
620
- `ActiveRecord.after_all_transactions_commit`, and the endpoint runs every
621
- action inside a transaction — so `serialize` always ran AFTER the block had
622
- exited. The job serialized with no `phlex_reactive_settle` key,
623
- `reactive_settle` was a no-op, and the row sat shimmering until someone
624
- reloaded. The handle is now captured when the job INSTANCE is created
625
- (`perform_later` → `job_or_instantiate` → `new`), which is synchronous inside
626
- the block whether or not the enqueue itself is deferred; `serialize` prefers
627
- the captured handle and still falls back to the thread-local. Both the
628
- `job:`/`args:` sugar (narrowing per record, so failures stay attributable) and
629
- the block form are covered, and a retry re-enqueue keeps its handle. Jobs
630
- enqueued outside any `reply.pending` still carry nothing.
631
-
632
- - **`rake release` bumps the pin in every tracked lockfile (#247, #253).** Since
633
- #246 the gem root's `Gemfile.lock` is committed alongside `docs/Gemfile.lock`,
634
- and both pin `phlex-reactive` by local path — so a version bump that left them
635
- alone shipped a stale lockfile, and the Release workflow's frozen
636
- `bundle install` refused it (v0.13.0's first attempt).
637
-
638
- The task now bumps that pin **with a text edit**, not `bundle lock`. #247's
639
- first cut used `bundle lock --local`, which is a full re-resolve — and a
640
- re-resolve trips over constraints that have nothing to do with this gem:
641
- `docs/Gemfile.lock` declares Linux platforms for the Kamal deploy, and
642
- resolving `thruster` for those against a Mac's installed gems fails ("Could
643
- not find gems matching 'thruster' valid for all resolution platforms"), which
644
- aborted v0.13.1's first attempt mid-release with `version.rb` already bumped.
645
- A re-resolve also silently folds unrelated dependency drift into the release
646
- commit the moment a Gemfile is out of sync with its lock. The only line a
647
- version bump changes is the path-gem pin, so the task edits exactly that (the
648
- `PATH` spec + the `CHECKSUMS` entry) in place — deterministic on any machine,
649
- no network, no installed gems, the same 2-line diff bundler produced. A
650
- lockfile that names no pin at all now **aborts** the release rather than
651
- reporting itself already current — and that check runs in a PREFLIGHT, before
652
- the task does anything destructive or irreversible: before the `force`
653
- cleanup that deletes the GitHub release and its tag, and before `version.rb`
654
- or any lockfile is written. So an abort leaves the release intact and the tree
655
- clean, rather than a deleted release or a half-bumped tree the clean-tree
656
- guard would then block on retry.
657
- pgbus's release task made the same call after the same failure.
658
-
659
- - **`Component::Action` renamed `ActionDefinition` — it shadowed a host kit
660
- component named `Action` under dev autoloading (#233).** The mixin sits in
661
- every reactive component's ancestry, so its bare `Action` constant satisfied
662
- Phlex::Kit's LazyLoader `const_get` check with the wrong class: the kit's
663
- real `Action` component never autoloaded and the call fell through to
664
- `NoMethodError` — intermittently (any unrelated load of the kit class masks
665
- it until the next reload) and only under lazy autoloading, so an
666
- eager-loaded test suite stays green. The rename matches its siblings
667
- (`ComputeDefinition`, `OnCompleteDefinition`, `CollectionDefinition`) and
668
- removes the constant entirely — **deliberately no alias**, since any
669
- `Action` constant in the ancestry re-creates the shadowing. Breaking only
670
- for code referencing the internal
671
- `Phlex::Reactive::Component::Action` Data class directly; the `action :name`
672
- DSL and `reactive_actions` registry are unchanged. A regression spec guards
673
- the whole component ancestry against `:Action` coming back.
674
-
675
- - **Multipart path corrupted/dropped fields with Rails-bracketed names (#231).**
676
- `#buildFormData` wrapped every collected field/file name verbatim as
677
- `params[<name>]`, so `blog_post[summary]` went out as the Rack-unparseable
678
- `params[blog_post[summary]]` — the scalar arrived as `{"]" => value}` (and a
679
- writer stringified that hash into the record, with a 200), and a `:file` under
680
- a bracketed name (`blog_post[image]`) never reached its schema key (silent
681
- drop). It only fired when a file input was populated — the JSON path expands
682
- the same names server-side — which is why flat-named forms never caught it.
683
- The client now bracket-expands the field **name** into wire segments
684
- (`params[blog_post][summary]`, `params[blog_post][image]`, and
685
- `params[photos][]` for a `multiple` picker named `photos[]`), mirroring the
686
- server's `bracket_path` exactly, so a multipart body and a JSON body coerce
687
- identically for the same fields. Flat names are byte-identical to before.
688
-
689
- - **Reply-rendered components now generate absolute URLs with the REQUESTING
690
- host, not the process default (#232).** Actor replies (`reply.replace`,
691
- `reply.streams(...)`, the implicit replace) render through the memoized
692
- off-request view context, whose `url_options` were process defaults — so any
693
- absolute URL helper (`image_tag` on an Active Storage attachment being the
694
- everyday case) rendered the wrong host on a multi-host app: a broken image
695
- after every save, healed by the next full page load. The action endpoint now
696
- threads the request's protocol/host/port into the reply render (the
697
- `ActiveStorage::SetCurrent` move) via a thread-local the memoized context
698
- merges per call — the context is never rebuilt, so the render memoization
699
- win is untouched (allocations byte-identical, throughput flat within noise).
700
- The defer **pull** endpoint gets the same treatment (it is an actor request).
701
- **Broadcasts are unchanged by design** — there is no request and subscribers
702
- can be on different hosts, so a broadcast fired *inside* an action explicitly
703
- clears the actor's url_options around its render; "URLs in broadcast-rendered
704
- components must be host-relative" is now documented in the Broadcasting guide.
705
-
706
- - **`rake bench` ran the removed `broadcast_replace_to` API and exited 1.**
707
- `benchmark/micro/broadcast.rb` migrated to the #185 spellings
708
- (`broadcast_to(*key, replace:)` / `broadcast_to(each:, replace:)`); the
709
- transport double is unchanged.
710
-
711
- - **`reactive_nested_remove(confirm:)` now interpolates `%{field}` on client-added
712
- rows (#222).** A row added in the browser via `reactive_nested_add` is a
713
- `cloneNode` of the `<template>`, and the clone path (`#renumberNestedRow` /
714
- `#seedNestedRow`) never rewrote the confirm attribute — so a per-row confirm
715
- froze to the template's value-less string on every added row (the exact rows
716
- the primitive exists for). The client now resolves `%{field}` placeholders in
717
- the confirm message from **that row's live field values** at click time (keyed
718
- by the same trailing-bracket inference `as: :json` uses), so
719
- `confirm: "Delete '%{name}'?"` on the template shows `Delete 'Widget'?` on the
720
- added row — reflecting a later edit too (resolved on remove, not on clone). An
721
- unresolved `%{key}` is left as its literal text (debuggable, never a silent
722
- blank); the placeholder works in the conditional Hash's `message:` as well.
723
- Server-rendered rows already interpolate server-side, so their finished strings
724
- are unaffected. **Also (superset of the issue's proposal 3):** `confirmResolver`
725
- now receives an optional second argument — a context object, always `{ el }`
726
- (the trigger), plus `{ row, fields }` on a `reactive_nested_remove` — so a
727
- themed-dialog override can build row-specific messages programmatically. The
728
- arg is additive: a one-parameter resolver (and `window.confirm`) is unchanged.
729
-
730
- - **A draft (unsaved-parent) token can now round-trip real server actions (#208).**
731
- `Component::Identity` already signed a gid-less `{c, state}` token for an
732
- unpersisted (or nil) record, but `from_identity` still `fetch`ed the absent
733
- `gid` — the FIRST action against a draft crashed with an uncaught `KeyError`
734
- (500). It now rebuilds through the record kwarg's **initialize default**
735
- (e.g. `def initialize(order: Order.new, …)`), with the declared
736
- `reactive_state` riding the token as before; a component whose initialize
737
- REQUIRES the record kwarg raises a guided `Phlex::Reactive::Error` naming the
738
- fix instead of a bare missing-keyword `ArgumentError`.
739
-
740
- - **`have_reactive_value` now reads the field's `.value` property, so it can verify a
741
- reducer-set disabled/computed field (#204).** The matcher (added in #201) delegated to
742
- Capybara's `have_field(with:)`, which matches the value **attribute** — but a
743
- `reactive_compute` reducer paints a computed output by setting its JS `.value`
744
- **property** (`el.value = …`), and for a **disabled / read-only** output the attribute
745
- never reflects that. So the matcher read `""` and failed on exactly the fields it was
746
- built for. It now re-resolves the field by id **or name** each poll and asserts the live
747
- `.value` property (via `evaluate_script`), covering enabled AND disabled/computed fields
748
- while keeping the morph-immunity. `have_reactive_text` (textContent) was unaffected.
749
-
750
- - **A freshly-rendered `reactive_compute` root now self-seeds its derived fields on connect (#199).**
751
- Before, a compute root computed nothing until the first user `input` — a fresh render
752
- (or a server validation-error re-render that replaced the body) left every derived
753
- output and cross-root `mirror:` node blank until you typed. Apps worked around it by
754
- dispatching a synthetic seed `input` on connect, but the compute root is a distinct
755
- Stimulus controller that may connect a frame later, so the seed raced its own wiring —
756
- the reported symptom being a PARTIAL apply (an early output painted; a later output
757
- and the mirror stayed blank). Now `connect()` runs ONE full `recompute()` after the
758
- controller is fully connected, so the whole single-pass write set (batch outputs →
759
- paint text sinks + cross-root mirrors → dispatch) runs synchronously and every
760
- declared output, text sink, and mirror paints from a single reducer result — no user
761
- interaction, no round trip. It re-seeds on `turbo:morph-element` (the show/filter/dirty
762
- precedent) and is idempotent (change-guarded writes make a re-seed a no-op, so an app
763
- still dispatching a synthetic input is harmless). The seed is gated on a new
764
- `data-reactive-compute-seed` marker emitted by `reactive_root(compute:)`; a non-compute
765
- root pays one attribute read and never seeds. The seed passes no event, so
766
- `meta.changed` is `null` — the correct "no field edited yet" semantics, so a convergent
767
- reducer's default branch computes the full settled set.
768
-
769
- - **Scoped form fields no longer silently drop their params — the #67 footgun is fixed (#184).**
770
- Under `reactive_scope`, `reactive_field(:date)` now emits the scoped wire name
771
- (`name="invoice[date]"`), so the POST arrives bracketed — and the endpoint unwraps
772
- exactly ONE scope level before schema matching, so a FLAT schema
773
- (`params: { date: :string }`) matches. Previously a scoped field either POSTed a bare
774
- name (misaligned with the `reactive_show`/`reactive_compute` resolvers, which already
775
- query `[name="scope[x]"]`) or forced a hand-written bracketed name + a hand-nested
776
- schema that Rails' bracket-expansion silently dropped. The client's `meta.changed`
777
- resolver (`#changedComputeField`) is now scope-aware too, so a scoped compute
778
- component's reducer still sees the bare declared name.
823
+ - **Client-side option filtering — `reactive_filter` (#163).** The other half
824
+ of #72's combobox: **preload the options, type to narrow — zero round
825
+ trips.** Spread `reactive_filter(input:, option:, group:, empty:)` onto the
826
+ root and the generic controller shows/hides each option on every keystroke by
827
+ a case-folded substring match against its `data-reactive-filter-text`
828
+ haystack (falling back to the option's own text) — no POST, no token, no
829
+ bespoke per-feature Stimulus controller. Optional `group:` collapses a header
830
+ whose every contained option is hidden; optional `empty:` reveals a
831
+ no-matches node at 0 visible. Selectors resolve within the root only (nested
832
+ reactive roots untouched), state seeds at connect and re-applies after a
833
+ morph, and blank selectors raise at render (a dead binding must fail loudly).
834
+ Composes with keyboard nav and per-row selection: a filtered-out option also
835
+ drops out of the Arrow-key path and loses its highlight, so Enter can never
836
+ pick an invisible row — selection itself stays a signed `on(:select)` action.
837
+ - **Standalone combobox keyboard nav — `reactive_listnav` (#163).** The same
838
+ Arrow/Enter/Escape wiring `on(…, listnav:)` appends, without the dispatch
839
+ descriptor — for the preload-and-filter input that fires **no** action (an
840
+ `on()` trigger would POST per keystroke). Spread
841
+ `reactive_listnav("[role=option]")` onto the input; Enter still picks by
842
+ clicking the highlighted option's own signed trigger.
843
+ - **Cross-root `reactive_show` targets — `reactive_show_targets` (#164).** A
844
+ field can now drive the visibility of declared elements **outside** its
845
+ reactive root — the nav tab, the panel in another tab pane, the sidebar note
846
+ a mode selector governs — the visibility parallel to the #159 cross-root
847
+ text mirror. The component that **owns** the field declares which outside
848
+ ids it governs, spread on the root:
849
+ `mix(reactive_root, reactive_show_targets(:mode, "#advanced-tab" =>
850
+ { equals: "advanced" }, "#basic-note" => { not: "advanced" }))`. Same
851
+ posture as `mirror:`: opt-in and declared, never implicit (a plain
852
+ `reactive_show` stays root-isolated, #15 untouched); targets are **single id
853
+ selectors only** — a class/compound selector raises at declare time AND is
854
+ warn-and-skipped by the client (two-sided default-deny); the predicate is
855
+ the same literal-only `reactive_show` vocabulary; the toggle is `hidden`
856
+ only. The field read stays **owned** — you can only drive outside visibility
857
+ from a field the declaring root owns. A target id not on the page is
858
+ silently skipped (an unrendered tab pane is normal); the cross-root pass
859
+ shares the owned-binding pass's field-read memo (one read per field per
860
+ sync). No map declared → one `getAttribute` and out. **One call per root**:
861
+ Phlex `mix` space-joins duplicate string data values, so a second call's
862
+ JSON would corrupt the attr (the client warns and ignores it) — several
863
+ fields go in one call via the hash form
864
+ (`reactive_show_targets(mode: { … }, kind: { … })`).
865
+ - **Value-conditional visibility — `reactive_show` (#161).** The `x-show` /
866
+ `data-show` / `wire:show` case — show/hide an element from a form field's
867
+ **current value** — no longer needs a hand-written `change`-listener Stimulus
868
+ controller. Spread `reactive_show(:mode, not: "off")` onto the element to
869
+ show/hide (also `equals:` and `in: [...]`; `equals: true` reads a checkbox's
870
+ checked state, a radio group reads the checked radio's value) and the generic
871
+ controller toggles the `hidden` attribute on every `input`/`change` —
872
+ client-only, zero round trip, no token. The predicate is a **declared literal
873
+ match**, never an expression (no eval surface); exactly one predicate is
874
+ enforced loudly at render; a missing field or malformed wire attr is
875
+ warn-and-skipped (client-side default-deny). Visibility seeds at connect and
876
+ re-syncs after a `turbo:morph-element`; a `reactive_compute` output write
877
+ dispatches a real `input` event, so derived values drive visibility too.
878
+ Ownership follows the nested-root rules (#15). Roots without a binding pay
879
+ one connect-time probe — no new listeners, byte-identical wire.
779
880
 
780
- - **`reactive_compute` output order no longer silently corrupts values (#183).**
781
- Each output write used to dispatch an `input` event **mid-loop**, re-entering the
782
- reducer synchronously from a **half-written DOM** — so a wrong `outputs:` order
783
- could commit wrong values (a real money-corruption class), settling only if the
784
- reducer was convergent in exactly the declared order. The client now runs the
785
- reducer **once** from one pre-write input snapshot, writes all changed outputs as
786
- a **batch**, paints text/mirror sinks from the settled values, then dispatches the
787
- `input` events — each self-marked so the root skips re-running its own reducer
788
- while other listeners (chained repaint, dirty tracking, `reactive_show`, sibling
789
- roots) still fire. Declared output order is no longer semantics. Covered by a JS
790
- order-independence test (declare outputs in the historically-wrong order — values
791
- stay correct) and a self-re-entry-suppression test.
881
+ - **Cross-root text mirrors + the `text` client op (#159).** A derived value can
882
+ now be painted into a text node **outside** the computing component's reactive
883
+ root — the read-only recap in another tab pane that previously forced a
884
+ bespoke JS listener — with the library's default-deny posture intact:
885
+ - `reactive_compute ..., mirror: { sum_total: "#sum_total" }` declares
886
+ allowlisted cross-root text mirrors. Each compute pass paints every declared
887
+ name into its document-wide **id** target(s) via `textContent`
888
+ (change-guarded, never `innerHTML`, never blanks a name the pass produced no
889
+ value for). The value comes from the reducer result, a just-written output's
890
+ field, or a declared input's identity value — so it works with no reducer at
891
+ all. Non-id selectors raise at declare time AND are warn-and-skipped by the
892
+ client interpreter (two-sided default-deny). No `mirror:` → the wire is
893
+ byte-identical to before.
894
+ - `js.text(to, value, global: false)` — a new op that sets `textContent`
895
+ (stringified; `nil` clears), available to `on_client`, `reply.js`, and
896
+ `broadcast_js_to`. Strictly less powerful than `set_attr`; pair with
897
+ `global: true` for the cross-root paint.
898
+ - `global: true` is now honored on the `reactive:js` stream path: a single op
899
+ can opt out of the reply's target-root scope to document-wide resolution
900
+ (previously it was silently ignored when a `target` was set).
792
901
 
793
902
  ### Changed
794
903
 
904
+ - **The client ships as two entries: the default, one file with everything,
905
+ and an opt-in core that imports feature modules on demand (#275). The
906
+ default is unchanged: nothing to do when upgrading.**
907
+ - **`phlex/reactive/reactive_controller` — the default — is still one file
908
+ with the whole client in it.** Same specifier, same imports (Stimulus and
909
+ the three override seams), nothing fetched on demand, every feature
910
+ connected inside `connect()` as before. Existing importmap pins, bundler
911
+ aliases and vendored copies keep working as they are. It contains no
912
+ `import()` at all. It is 24,675 B gzipped, 2,403 B more than the 22,272 B
913
+ it was before the split. The split first left it at 26,132 B; #305 builds
914
+ it with `__SPLIT__` false, so the runtime imports every feature statically
915
+ and calls it directly — no feature table, no loader, no `withFeature` —
916
+ and recovers 1,457 B of that. What remains is how the features are written
917
+ as modules (per-root state records where the controller had private
918
+ fields, exported wrappers, the `core` handle), not the boundary between
919
+ them. The budget test holds a 24,900 B ratchet; getting back to the
920
+ 22,700 B target (the pre-split size plus a loader's worth) is #310.
921
+ - **`phlex/reactive/core` — new, opt-in — is the controller without its
922
+ feature modules** (12,467 B gzipped). It imports a feature the first time
923
+ something on the page needs it, by a literal bare-specifier `import()`:
924
+ `phlex/reactive/features/persist` (3,210 B: `reactive_persist` drafts and
925
+ rich-text editors), `…/defer` (2,846 B: `reply.defer`, `reactive_lazy`,
926
+ `reactive_lazy(on:/cache:)`), `…/form` (1,015 B: dirty tracking,
927
+ `warn_unsaved`, the paste-trigger gate), `…/bindings` (5,360 B:
928
+ `reactive_show` and show targets, `reactive_on_complete`,
929
+ `reactive_filter`, `reactive_tags`, nested rows and their JSON mode, the
930
+ conditional `confirm:`), `…/compute` (2,084 B: `reactive_compute`,
931
+ `reactive_text`, the connect-time seed), `…/effects` (1,693 B:
932
+ `reactive_effects` and `dismiss_after`), `…/hints` (1,024 B: `optimistic:`
933
+ and `busy:`) and `…/devtools` (1,589 B: the latency simulator, the
934
+ zero-target warnings, the debug trace). The engine pins the core and every
935
+ feature (`preload: false`) and precompiles them. An app imports it INSTEAD
936
+ of `reactive_controller`, never both; loading both logs `the client was
937
+ loaded twice`. The README's "The split client" section is the opt-in
938
+ guide: what loads when, the trade-off, how to preload a feature, and the
939
+ one prefix alias a bundler needs.
940
+ - **The budget.** The epic's "core under 8 KB" became 10,240 B during the
941
+ work. The honest moves — every feature out, the test seams out of the
942
+ shipped build, the diagnostics into devtools, the conditional confirm and
943
+ its DNF fold into bindings, the compute seam import into compute — leave
944
+ the core at 12,383 B, 2,143 B over. The budget test holds the real size
945
+ (rounded up to 12,500 B) as the ratchet and prints the target; the PR for
946
+ the last phase lists what else could move and what each is worth. The
947
+ epic's criterion is amended to the shipped number.
948
+ - **Only with the split client:** the first root of a page load that needs
949
+ a feature waits for its import (later roots and Turbo visits do not).
950
+ While a draft-keeping root waits, nothing is drafted from the server's
951
+ blanks, what the user changes is drafted when the module arrives (also if
952
+ they leave the page first), a successful submit still forgets the draft,
953
+ `persist_state` / `persist_clear` wait for the restore, and an action
954
+ waits for the restore and posts the restored values — except when the
955
+ module is slower than the feature timeout, fails to load, or the root
956
+ leaves the page first. An `on:` event during a lazy shell's wait loads it
957
+ exactly once afterwards; a `reactive:defer` stream is kept and applied. A
958
+ stream that brings a page's first effect or dismissing flash — or swaps
959
+ in a root that needs a module — waits up to a second for the imports, and
960
+ the streams behind it keep their order; after that they render as they
961
+ are, and the effects module sweeps up the flashes it missed when it
962
+ arrives. A dirty-tracked form counts an edit made before its module
963
+ arrived, but `warn_unsaved` does not prompt for a navigation in that
964
+ window (preload `phlex/reactive/features/form` where that matters). A
965
+ form's bindings and a compute root show the server's rendering until
966
+ their module arrives, then seed from the fields as the user left them;
967
+ both hold the root's requests until then. A trigger with a hint has its
968
+ request wait for the hints module, then the hint applies and the request
969
+ goes out once; a failure after a late apply still reverts it. A
970
+ conditional `confirm:` asks once the bindings module can evaluate it.
971
+ `enableLatencySim` / `disableLatencySim` are exported by
972
+ `phlex/reactive/features/devtools` (the default client still exports them
973
+ itself). `reactive:error` gains `kind: "feature"`, whose detail carries
974
+ `feature`, `error` and a `phase` (one of `"load"`, `"timeout"`,
975
+ `"connect"`, `"detect"`); a feature that failed to load stays failed until
976
+ the page is reloaded.
977
+ - **`controller.featuresReady`** is a promise that resolves once the
978
+ features a root needs have connected. With the default client it is
979
+ always already resolved. It never rejects.
980
+ - A morph that adds a feature's marker to a connected, token-bearing root
981
+ connects the feature (and, on the split client, loads it first).
982
+ - The shipped builds no longer export the controller's `__…ForTest` seams
983
+ (they remain in the source the JS suite imports).
984
+ - `spec/javascript/bundle_budget.test.js` holds a ratchet on the default
985
+ file, the core, each feature and the split total (31,204 B).
986
+ - `rake bench:client` gains a `connect()` bench (2,000 roots), the one
987
+ place a feature module adds work to every root.
988
+ - Verified on Chromium (the browser suite, Puma and Falcon). Not run:
989
+ Firefox or WebKit; a real importmap-rails app with Propshaft digests on
990
+ the default entry (the docs site is one and its suite passes, but its
991
+ network requests were not inspected); the per-root `connect()` bench on
992
+ a quiet machine. A preloaded feature module (`preload: true`,
993
+ `modulepreload`) removes the network wait, not the window: it connects
994
+ in the task after `connect()`, and the first root's request gate applies
995
+ for that moment.
996
+
795
997
  - **`on_client` emits a binding record (#271).** `data-reactive-ops-param` now
796
998
  holds `{"on":…,"ops":[…], "window"?, "outside"?, "confirm"?, "confirmWhen"?}`
797
999
  instead of the bare `[[op, args]]` list, with every space written as
@@ -973,68 +1175,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
973
1175
  contradicted the clean-break rule. Each old name now raises a guided `NoMethodError`
974
1176
  naming the real method.
975
1177
 
976
- ### Fixed
977
-
978
- - **A pending-state text swap no longer destroys a composite trigger's icon (#181).**
979
- The former `disable_with:`/`loading:` text swap wrote `trigger.textContent`, which
980
- **flattens every child node** — a `<button><svg/> Save</button>` lost its icon the
981
- instant the request enqueued, and it never came back on restore. `busy:`'s swap now
982
- reads and restores `innerHTML`, so an icon-plus-label trigger round-trips intact.
983
- Covered by a new composite-trigger browser assertion and a JS child-preservation
984
- unit test.
985
-
986
- - **`exclude:`/`visible_to:` now actually reach pgbus — actor-echo suppression works over SSE (#187).**
987
- `broadcast_*_to`/`broadcast_*_to_each` passed `exclude:`/`visible_to:` as keyword
988
- arguments to `Turbo::StreamsChannel.broadcast_*_to`, but turbo-rails swallows
989
- unknown kwargs into its render locals — so on the pgbus transport the options were
990
- **silently dropped** and `exclude: reactive_connection_id` never suppressed the
991
- actor's own broadcast echo. They are now threaded through the thread-locals pgbus
992
- reads (`Thread.current[:pgbus_broadcast_exclude]`/`_visible_to`), capability-gated
993
- on `pgbus_streams?` so the Action Cable path is unchanged. Found by the new
994
- end-to-end pgbus transport suite (below) — the prior unit doubles only proved the
995
- option was *forwarded to the method*, never that it *reached pgbus*.
996
-
997
- ### Added
998
-
999
- - **Conditional confirm — warn only when the values look suspect (#179).**
1000
- `confirm:` now takes a Hash for soft-validation-before-submit, so the dialog fires
1001
- ONLY when the field values are wrong — instead of a hand-written submit handler that
1002
- inspects fields and calls `confirm()` itself. Two forms, both evaluated client-side
1003
- over the same collected fields `reactive_compute` reads:
1004
- - **Declarative** — `confirm: { when: { total: 0 }, message: "Total is 0 — continue?" }`.
1005
- `when:` reuses `reactive_show`'s conditions language verbatim: a scalar is equals,
1006
- a `Range` is a threshold (`qty: 100..`), an `Array` is membership. Zero JS. The
1007
- dialog fires when the condition MATCHES; a clean value submits silently.
1008
- - **Named predicate** — `confirm: { predicate: "end_before_start", message: "…" }` for
1009
- multi-field logic the single-field form can't express. Register a pure function at
1010
- boot (`setConfirmPredicate("end_before_start", ({ starts_at, ends_at }) => ends_at < starts_at)`),
1011
- the twin of `setComputeReducer`. An unregistered name warns and proceeds without a
1012
- dialog. Works on `on(...)` AND `on_client(...)`. The predicate is soft-validation UX,
1013
- **not authorization** — a user can bypass it and the action still hits the endpoint's
1014
- real authorize/default-deny; never let it stand in for a server-side check.
1015
-
1016
- - **`confirm:` on `on_client(...)` — themed confirmation for zero-round-trip client ops (#178).**
1017
- The client-op path gains the SAME overridable `confirmResolver` gate `on(:action, confirm:)`
1018
- has (#52/#55). A destructive-feeling client op (clear a draft, reset a form) gets the
1019
- app's themed dialog with one line and no round trip:
1020
- `button(**on_client(:click, js.text("#draft", ""), confirm: "Discard this draft?"))`.
1021
- The gate lives in the user-gesture path (`runOps`), never in the shared op applier — a
1022
- server-pushed `reactive:js` op stream must not prompt. `setConfirmResolver` now themes
1023
- both paths at once.
1024
-
1025
- - **CI transport verification matrix — the browser suite runs on Action Cable AND pgbus (#187).**
1026
- The system job is now `server × transport` (Puma/Falcon × cable/pgbus). The pgbus
1027
- cells add a plain `postgres:18` (pgbus vendors the PGMQ schema via its own
1028
- migrations — no extension image) and set `TRANSPORT=pgbus`, so the existing browser
1029
- suite proves the reactive round trip is transport-agnostic, and new `:pgbus`-tagged
1030
- specs prove real cross-tab broadcast delivery + actor-echo exclusion over live
1031
- Postgres SSE. `rake spec:system_matrix` runs the 2×2 locally (pgbus cells skip with
1032
- a note when Postgres isn't reachable); `rake pgbus:prepare_test_db` sets up the DB.
1033
- pgbus remains an optional, non-gemspec dependency — it is now a first-class *tested*
1034
- transport, not a required one.
1035
-
1036
- ### Changed
1037
-
1038
1178
  - **BREAKING: ONE pending-state vocabulary — `busy:` replaces `loading:` / `disable_with:` (#181).**
1039
1179
  A trigger's declarative pending affordance is now a single `on(…, busy:)` kwarg
1040
1180
  that shares `optimistic:`'s key vocabulary and normalizer — the only difference
@@ -1053,292 +1193,599 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1053
1193
  | `on(:save, loading: { disable: true, class: "opacity-50", text: "Saving…" })` | `on(:save, busy: { disable: true, add_class: "opacity-50", text: "Saving…" })` |
1054
1194
  | `on(:search, event: "input", listnav: "[role=option]")` | `mix(on(:search, event: "input"), reactive_listnav)` |
1055
1195
 
1056
- The wire attribute is renamed `data-reactive-loading-param` → `data-reactive-busy-param`;
1057
- the client keeps a **read shim** for the old attribute for one minor so an
1058
- in-flight page rendered by the previous gem across a deploy keeps its affordance.
1196
+ The wire attribute is renamed `data-reactive-loading-param` → `data-reactive-busy-param`;
1197
+ the client keeps a **read shim** for the old attribute for one minor so an
1198
+ in-flight page rendered by the previous gem across a deploy keeps its affordance.
1199
+
1200
+ - **BREAKING: `reactive_show` speaks ONE conditions language — `if:`/`if_any:`/`unless:` (#180).**
1201
+ The four accreted dialects (the positional-field predicate kwargs
1202
+ `equals:`/`not:`/`in:`/`gte:`/`gt:`/`lte:`/`lt:`, the `all:`/`any:` term
1203
+ arrays, and the `{ equals: … }` target predicate hashes) are **removed** and
1204
+ replaced by a single Ruby-native language: a **Hash is an AND**, an **Array is
1205
+ membership**, a **Range is a threshold**, `if_any:` is OR-of-AND (one level of
1206
+ disjunction — the distributive-law killer), and `unless:` negates. Each removed
1207
+ form raises a **guided `ArgumentError`** printing the exact rewrite. Migration:
1208
+
1209
+ | Before (removed) | After |
1210
+ |---|---|
1211
+ | `reactive_show(:mode, not: "off")` | `reactive_show(unless: { mode: "off" })` |
1212
+ | `reactive_show(:gift, equals: true)` | `reactive_show(if: { gift: true })` |
1213
+ | `reactive_show(:size, in: %w[l xl])` | `reactive_show(if: { size: %w[l xl] })` |
1214
+ | `reactive_show(:qty, gte: 10)` | `reactive_show(if: { qty: 10.. })` |
1215
+ | `reactive_show(:amt, gt: 5000)` | `reactive_show(unless: { amt: ..5000 })` |
1216
+ | `reactive_show(all: [{ field: :a, equals: "x" }, …])` | `reactive_show(if: { a: "x", … })` |
1217
+ | `reactive_show(any: [{ field: :a, equals: "x" }, …])` | `reactive_show(if_any: [{ a: "x" }, …])` |
1218
+ | `reactive_show_targets(:m, "#id" => { equals: "x" })` | `reactive_show_targets(:m, "#id" => "x")` |
1219
+
1220
+ New alongside it: **`reactive_values`** computes each binding's first-paint
1221
+ `hidden:` server-side (no per-section mirror method, no flash);
1222
+ **`reactive_scope :form`** lets bindings use bare field symbols
1223
+ (`[name="form[field]"]` on the client); **`disable: true`** disables a hidden
1224
+ section's own controls so a switched-away value never submits. The wire is now
1225
+ ONE DNF shape (`data-reactive-show='{"any":[[term,…],…]}'`); the client keeps
1226
+ legacy read arms for one minor (deploy overlap), removed in 0.11.
1227
+
1228
+ - **`Phlex::Reactive::ClientBindings` — a blessed client-only include (#180).** A
1229
+ view that only shows/hides, filters, or computes client-side (no server
1230
+ actions, no token) includes this instead of the full `Component`. It does NOT
1231
+ pull in `Streamable` (so it never clobbers an app's own `replace`/`to_stream_*`
1232
+ concern) or the token machinery — a token-less `reactive_root` with no `#id`
1233
+ requirement, invisible to `phlex_reactive:doctor`. The full `Component`
1234
+ includes it (one implementation), then layers Streamable + Identity on top.
1235
+
1236
+ - **Automatic token refresh — `reply.with` and `reply.streams` converge (#180).**
1237
+ The action endpoint now appends a `reactive:token` refresh stream automatically
1238
+ whenever a reply does not re-render the component's root, instead of prepending
1239
+ a full self-replace that could clobber a live input. Picking `.streams` vs
1240
+ `.with` to keep the signed token fresh is no longer a correctness decision — a
1241
+ companion-only reply refreshes the token either way.
1242
+
1243
+ - **BREAKING: `verify_authorized` is ON by default (#168).** A reactive action
1244
+ that completes **without any authorization call now raises**
1245
+ `Phlex::Reactive::AuthorizationNotVerified` — **rolling back the transaction**
1246
+ (fail-closed, stronger than Pundit's after-the-fact check). This is the
1247
+ presence-side complement to `authorization_errors`: a forgotten `authorize!`
1248
+ becomes a loud 500 your error tracker sees, not a silent hole. The guard
1249
+ detects a call to any `Phlex::Reactive.authorization_methods` name
1250
+ (default `%i[authorize! authorize allowed_to?]` — Pundit/CanCanCan/ActionPolicy)
1251
+ **or** `mark_authorized!`, made anywhere during the action (a helper the action
1252
+ calls counts too). **Three remedies** for each action:
1253
+ 1. call your authorization method (`authorize! @record, :update?`);
1254
+ 2. call `mark_authorized!` after a bespoke check the interceptor can't see;
1255
+ 3. declare `skip_verify_authorized` (whole component) or
1256
+ `skip_verify_authorized :action_name` (specific actions) for an
1257
+ intentionally public action.
1258
+ Turn it off globally with `Phlex::Reactive.verify_authorized = false` (the
1259
+ install-generator initializer documents the knob). The `action.phlex_reactive`
1260
+ instrumentation event gains a new `:unverified` outcome. A new **advisory**
1261
+ doctor check flags mutating actions with no detected authorization call
1262
+ (heuristic — a helper may authorize indirectly; never a hard fail).
1263
+
1264
+ ### Fixed
1265
+
1266
+ - **`reply.morph` no longer overwrites the field being typed in** (#338).
1267
+ Turbo's stream morph runs Idiomorph without `ignoreActiveValue`, so when a
1268
+ debounced save's render carried a different value — normalised by the
1269
+ server (`"Hello "` saved as `"Hello"`) or stale because the user kept typing
1270
+ during the round trip — the focused field took it, and the next keystrokes
1271
+ landed after it (`"Helloworld"`). Present since `reply.morph` (#28), so in
1272
+ 0.13.4. The client now holds the value of a focused `<input>`/`<textarea>`
1273
+ inside a reactive root through any morph (it cancels
1274
+ `turbo:before-morph-attribute` for `value` on that field, which Idiomorph
1275
+ honours for the attribute and the property) and writes the render's value as
1276
+ the field's new default itself, so dirty tracking still re-scans against the
1277
+ saved value. Unfocused fields, every other attribute (a root's token
1278
+ included), and morphs outside a reactive root are untouched. A field that
1279
+ must be rewritten while focused (a formatter) opts out with
1280
+ `data-reactive-morph-value`.
1281
+
1282
+ - **`reactive_scope` with a flat schema receives its fields from a real browser
1283
+ (#337).** The client's default JSON body keeps each field's bracketed name as
1284
+ one key (`"todo[title]"`), and the endpoint looked for the scope key (`todo`)
1285
+ before expanding those names, so it peeled nothing and the flat schema
1286
+ (`params: { title: :string }`) dropped every field. Only the multipart body,
1287
+ which Rails expands itself, worked; a request spec posting a nested hash
1288
+ passed. The endpoint now expands first and then peels, so both encodings
1289
+ give the action the same params, and `verbose_errors` no longer reports
1290
+ `todo[title]` as dropped. Bare params posted beside the scoped fields (a
1291
+ trigger's `on(:save, note: "x")`, an unscoped file input) now stay next to
1292
+ them on both encodings, where the multipart path used to drop them; an
1293
+ explicit `on(...)` param wins over a scoped field of the same name, as it
1294
+ does on the client. Only declared params still reach the
1295
+ action. Present since `reactive_scope` landed in 0.11.0 (#184), through
1296
+ 0.13.4.
1297
+
1298
+ - **A `transition:` on show/hide/toggle no longer ends when a child's
1299
+ animation or transition does.** `runTransition` had the settle bugs #296
1300
+ fixed in effects: a descendant's bubbling `animationend` consumed its
1301
+ `{ once: true }` listener and stripped the `during`/`to` classes early, and
1302
+ a CSS transition's own `transitionend` was never listened for. Only the
1303
+ element's own end event (or the fallback) settles it now, and settling
1304
+ removes both listeners. In a hidden tab the fallback already cleaned up (it
1305
+ never waited on a frame), but the late frame then re-added `to` for good;
1306
+ cleanup now cancels that frame and clears `from` too. Rapid show/hide on one
1307
+ element no longer lets a superseded run's late cleanup strip the new run's
1308
+ classes: a new run settles the live one first (its classes, timer,
1309
+ listeners and frame). And the fallback is no longer a fixed 350 ms that cut
1310
+ longer transitions short: it follows the element's computed
1311
+ transition/animation durations (times an animation's iteration count) plus
1312
+ delays (+50 ms), with 350 ms as the floor and a 5 s cap.
1313
+
1314
+ - **A custom-legs exit effect no longer waits for a background tab to become
1315
+ visible (#295).** The legs choreography awaited an animation frame before
1316
+ any timeout was armed, so in a hidden tab (no `requestAnimationFrame`) the
1317
+ removal, and every stream behind it, stalled. The frame wait now races the
1318
+ 1 s settle fallback.
1319
+
1320
+ - **An effect no longer ends when a child's animation or transition does
1321
+ (#296).** `animationend`/`transitionend` bubble, so a descendant finishing
1322
+ first cleared the container's effect class (and released an exit's removal)
1323
+ early; settling also left the other end listener attached. Only the
1324
+ element's own end event settles it now, and settling removes both listeners.
1325
+
1326
+ - **A JSON-mode nested row serializes what a real submit would carry
1327
+ (#299).** The row object behind `reactive_nested_list(as: :json)` and the
1328
+ per-row confirm `%{field}` interpolation now takes only the checked radio
1329
+ of a group (it took the last in DOM order), skips a nested reactive root's
1330
+ controls, and skips disabled controls (so `reactive_show(disable: true)`
1331
+ keeps a hidden field out of the JSON). A radio group with nothing checked,
1332
+ like a disabled control, now leaves its key out; a confirm `%{field}` for
1333
+ such a key stays literal.
1334
+
1335
+ - **A single `<select>` with no `selected` option no longer reads as dirty
1336
+ (#297).** The browser selects its first enabled option while that option's
1337
+ `defaultSelected` stays false; dirty tracking now compares a one-row single
1338
+ select against its reset state (the last `selected` option, else the first
1339
+ enabled one — an option inside a disabled `<optgroup>` is not enabled).
1340
+ - **Two dirty `warn_unsaved` roots prompt once per Turbo visit (#298).** The
1341
+ first dirty root to see a `turbo:before-visit` asks; the others skip that
1342
+ event, and any dirty root's decline still vetoes the visit.
1343
+
1344
+ - **A token an outside morph brings wins over the cached one (#301).** After a
1345
+ morph or update reply the controller kept the token it cached from that
1346
+ reply, so a later broadcast, page refresh or dormant morph-back that morphed
1347
+ the root in place with markup signed for another state was ignored, and the
1348
+ next action ran against the old state. The controller now drops its cached
1349
+ token whenever `data-reactive-token-value` changes (Stimulus'
1350
+ `tokenValueChanged`, which also fires on a reconnect after such a change).
1351
+
1352
+ - **A pushed deferred render stays pending until it arrives (#292).** The
1353
+ client settled a `via="stream"` defer on any turbo-stream to its target, so
1354
+ an unrelated update of that element before the job's broadcast released the
1355
+ guard (and the activity count) early. Only the job's removal of
1356
+ `reactive-defer-src-<target>` settles it now.
1357
+
1358
+ - **A morph inside a loading lazy shell no longer restarts its load (#294).**
1359
+ `turbo:morph-element` bubbles, so a morph of any descendant re-probed a
1360
+ `reactive_lazy` shell and aborted its in-flight fetch for a new one. Only a
1361
+ morph of the shell's root re-probes now.
1362
+
1363
+ - **A `hide:`/`show:` hint no longer flips a target that was already in that
1364
+ state (#300).** A `busy:` or `optimistic:` `hide:` aimed at an
1365
+ already-hidden element revealed it when the request settled (or failed), and
1366
+ a `show:` on a visible one hid it. hide/show now undo only what the hint
1367
+ changed, like the class ops.
1368
+
1369
+ - **Subclasses of a reactive component appear in the actions inventory
1370
+ (#302).** A subclass inherits `Phlex::Reactive::Component` instead of
1371
+ including it, so Streamable's `included` hook never registered it, and
1372
+ `phlex_reactive:actions`, `phlex_reactive:find`, the MCP tools and the doctor
1373
+ skipped it. Streamable now registers subclasses from an `inherited` hook. A
1374
+ subclass with a constant name is listed with its inherited and own actions
1375
+ and any inherited `skip_verify_authorized`; an anonymous subclass is still
1376
+ left out. Subclasses also have their memoized view context reset on a Rails
1377
+ code reload now.
1378
+
1379
+ - **The browser suite no longer wedges under Falcon when two requests
1380
+ overlap (#303).** Transactional system tests pin one connection that the
1381
+ test and the server share, guarded by a lock keyed on
1382
+ `ActiveSupport::IsolatedExecutionState` — a thread-keyed `ThreadMonitor`
1383
+ under the default `:thread` isolation. Falcon serves each request as a fiber
1384
+ on one thread, so the first page that made two action requests overlap (the
1385
+ two-roots hotkey fixture) let both fibers into the pinned connection's
1386
+ critical section: a `ThreadError` in `ConnectionPool#checkout`, after which
1387
+ every later request blocked and CI sat on the step until the 6-hour kill.
1388
+ The dummy app now sets `config.active_support.isolation_level = :fiber` when
1389
+ it serves under Falcon, as Rails documents for fiber-per-request servers; a
1390
+ spec reproduces the overlap without a browser. Apps that run Falcon need the
1391
+ same setting (the testing page says so).
1392
+
1393
+ - **A lazy shell connects the feature a morph asks for (#312).** A Turbo morph
1394
+ that kept a `reactive_lazy` shell connected (a `cache:` one, or a plain one)
1395
+ but turned it into a root needing persist, form, bindings or compute never
1396
+ connected that feature, or imported it on the opt-in core: the root's own
1397
+ morph listener was installed only on a root with an identity token, which a
1398
+ shell does not carry.
1399
+
1400
+ - **A refused fragment URL supersedes the in-flight lazy load (#293).** A morph
1401
+ that re-showed a lazy shell with a URL the client refuses left the earlier
1402
+ load running, and its late arrival painted over the refused shell.
1403
+
1404
+ - **A loaded `reactive_lazy` root morphed back into its shell loads again.**
1405
+ A plain `reactive_lazy` component only re-probed after a Turbo morph when its
1406
+ controller had connected on the shell. Once the real content had replaced it
1407
+ (a new element, a new controller), a page-refresh morph that re-showed the
1408
+ shell left it pending forever. The morph handler every token-bearing root
1409
+ already has now probes a fetch-on-connect shell too (found while wiring
1410
+ `cache:` into the same path, #277).
1411
+
1412
+ - **`reactive_compute` never saw a checkbox's checked state (#262).**
1413
+ `#recompute` resolved each declared name first-wins and read `.value`, which
1414
+ for a checkbox is a constant. Measured: a Rails `check_box` pair read `0`
1415
+ ticked or not (the hidden companion comes first), a lone `value="1"` box read
1416
+ `1` either way, and a box with no value attribute read `0` (`Number("on")` is
1417
+ NaN). A radio group read its FIRST radio's value, whichever was checked. The
1418
+ reducer ran on the toggle, from values that could not have changed.
1419
+
1420
+ A checkbox now contributes its checked state, coerced by the declared type:
1421
+ `1`/`0` as a `:number` (and in the array form), `"true"`/`"false"` as a
1422
+ `:string`, and `true`/`false` under the new **`:boolean`** type. It wins over
1423
+ its same-named hidden companion, as it already did for `reactive_show`. A radio
1424
+ group contributes its checked radio's value. The identity mirror and the
1425
+ `mirror:` fallback paint the same reading.
1426
+
1427
+ Outputs were wrong the same way: a result whose name resolved to a checkbox
1428
+ pair wrote `.value` on the hidden companion, changing what the UNCHECKED state
1429
+ submits and leaving the box alone. An output now sets a checkbox's `checked`
1430
+ from the result's truthiness (`""`, `"0"`, `"false"`, `0` and `false` untick)
1431
+ and checks the radio of a group that carries the result.
1432
+
1433
+ **If a reducer relied on the old reading**, it changes: a lone checkbox with a
1434
+ numeric `value` used to read that number regardless of state and now reads
1435
+ `1`/`0` — write the amount in the reducer (`gift ? 25 : 0`). Apps that worked
1436
+ around the defect by reading the box from `document` inside the reducer keep
1437
+ working and can drop the workaround.
1438
+
1439
+ Cost, same machine, happy-dom (engine-relative): the 30-input calculator
1440
+ bench goes from 16.3 to 18.4 µs/iter, allocations flat. That is one `el.type`
1441
+ read per declared name, which is what telling a checkbox from a text field
1442
+ takes.
1443
+
1444
+ - **The dropped-param hints said nothing for a schema with string keys.**
1445
+ `ParamSchema.compile` keeps keys as the author wrote them, so
1446
+ `params: { "date" => :string }` is valid, but the #16/#21 hints only looked
1447
+ up symbol keys. The param was still dropped and logged, just without the hint
1448
+ that says where the schema declares it.
1449
+
1450
+ - **A checkbox group collapsed to one boolean, and the chosen values never left
1451
+ the browser (#258).** `#collectFields` wrote `fields[name] = field.checked` for
1452
+ every checkbox, so several boxes sharing a `features[]` name overwrote each
1453
+ other and the action received the LAST box's checked state — `{}` under a
1454
+ `[:string]` schema, `"false"` under a flat `:string` one, silent either way,
1455
+ while a native submission of the same boxes sends
1456
+ `features[]=news&features[]=events`. A name ending in `[]` is now collected as
1457
+ an array of the chosen values: a ticked box contributes its `value`, an
1458
+ unticked one nothing, a `<select multiple>` its selected options. The suffix is
1459
+ the only trigger — a group says so rather than being inferred from two controls
1460
+ sharing a name.
1461
+
1462
+ **That makes the suffix a migration point.** ANY `[]`-named control now
1463
+ contributes to an array, including a single one and including a named rich
1464
+ editor or bare contenteditable, which the collector reads in a second pass: a
1465
+ lone `<input type="text" name="tags[]">` used to post `"abc"` and now posts
1466
+ `["abc"]`. An editor sharing its `[]` name with another control adds its
1467
+ value to the group instead of standing down behind it, so the entry count
1468
+ changes there too. Beside a radio it still stands down, because a radio keeps
1469
+ its single value with or without the suffix. A ready but empty editor
1470
+ contributes an empty string, the way an empty text field in the same group
1471
+ does. An unticked box still contributes nothing. A hidden input under the
1472
+ same name keeps contributing: nothing in the DOM tells a hidden that mirrors
1473
+ an editor from one that is a list JS maintains, and a value posted twice is
1474
+ visible where a swallowed one is not. Against a flat `params: { tags: :string
1475
+ }` the array coerces to the literal `"[\"abc\"]"` — silently, with a 200. A
1476
+ control whose name ends in `[]` has to be declared as an array type (`tags:
1477
+ [:string]`), or renamed without the suffix if it was never meant as a list.
1478
+
1479
+ Three shapes keep their meaning on purpose: a lone checkbox without `[]` stays
1480
+ the documented yes/no boolean, a radio group keeps its single checked value
1481
+ with or without the suffix, and a hidden input sharing a name with a checkbox
1482
+ is that box's companion. The companion is identified by the shared name, not
1483
+ by its value, because Rails renders three of them: `check_box` emits
1484
+ `value="0"`, `check_box(..., multiple: true)` the same under a `[]` name, and
1485
+ `collection_check_boxes` a blank one — or none at all with
1486
+ `unchecked_value: nil`. Reading the second shape by value collected
1487
+ `["0","0","0","3"]` for three boxes with the third ticked.
1488
+
1489
+ A form body can't carry an empty array, so a cleared group used to go missing
1490
+ whenever the client sent a form body (as soon as a file input holds a file),
1491
+ and the action got its keyword default instead of `[]`. Now the client names
1492
+ the cleared group in `empty_groups[]`, a field next to `token`, `act` and
1493
+ `params`, and the endpoint sets it to `[]`. A request without that field
1494
+ behaves as before, and a group that carries values keeps them.
1059
1495
 
1060
- - **BREAKING: `reactive_show` speaks ONE conditions language — `if:`/`if_any:`/`unless:` (#180).**
1061
- The four accreted dialects (the positional-field predicate kwargs
1062
- `equals:`/`not:`/`in:`/`gte:`/`gt:`/`lte:`/`lt:`, the `all:`/`any:` term
1063
- arrays, and the `{ equals: … }` target predicate hashes) are **removed** and
1064
- replaced by a single Ruby-native language: a **Hash is an AND**, an **Array is
1065
- membership**, a **Range is a threshold**, `if_any:` is OR-of-AND (one level of
1066
- disjunction — the distributive-law killer), and `unless:` negates. Each removed
1067
- form raises a **guided `ArgumentError`** printing the exact rewrite. Migration:
1496
+ It applies to params the action declares as an array, by plain name or with
1497
+ the component's `reactive_scope` in front, and only at the top level. A group
1498
+ declared one level down or inside nested attributes is ignored and keeps its
1499
+ keyword default; `verbose_errors` logs it. The endpoint never takes the key it
1500
+ writes from the request. It looks the name up in the declaration and writes
1501
+ the declared key, so a request can't create a param the action didn't ask for.
1068
1502
 
1069
- | Before (removed) | After |
1070
- |---|---|
1071
- | `reactive_show(:mode, not: "off")` | `reactive_show(unless: { mode: "off" })` |
1072
- | `reactive_show(:gift, equals: true)` | `reactive_show(if: { gift: true })` |
1073
- | `reactive_show(:size, in: %w[l xl])` | `reactive_show(if: { size: %w[l xl] })` |
1074
- | `reactive_show(:qty, gte: 10)` | `reactive_show(if: { qty: 10.. })` |
1075
- | `reactive_show(:amt, gt: 5000)` | `reactive_show(unless: { amt: ..5000 })` |
1076
- | `reactive_show(all: [{ field: :a, equals: "x" }, …])` | `reactive_show(if: { a: "x", … })` |
1077
- | `reactive_show(any: [{ field: :a, equals: "x" }, …])` | `reactive_show(if_any: [{ a: "x" }, …])` |
1078
- | `reactive_show_targets(:m, "#id" => { equals: "x" })` | `reactive_show_targets(:m, "#id" => "x")` |
1503
+ A blank entry (`params[name][]=""`) would have been the other way, but the
1504
+ schema reads `[""]` per element type, and for a `[:file]` param behind
1505
+ `has_many_attached` that is the difference between "not sent" and "remove the
1506
+ attachments".
1079
1507
 
1080
- New alongside it: **`reactive_values`** computes each binding's first-paint
1081
- `hidden:` server-side (no per-section mirror method, no flash);
1082
- **`reactive_scope :form`** lets bindings use bare field symbols
1083
- (`[name="form[field]"]` on the client); **`disable: true`** disables a hidden
1084
- section's own controls so a switched-away value never submits. The wire is now
1085
- ONE DNF shape (`data-reactive-show='{"any":[[term,…],…]}'`); the client keeps
1086
- legacy read arms for one minor (deploy overlap), removed in 0.11.
1508
+ `post_reactive_multipart` takes `empty_groups:` so a request spec can send a
1509
+ cleared group the way the client does. `ParamSchema#array_param(name)`
1510
+ returns the declared key for an array param, or `nil`.
1087
1511
 
1088
- - **`Phlex::Reactive::ClientBindings` — a blessed client-only include (#180).** A
1089
- view that only shows/hides, filters, or computes client-side (no server
1090
- actions, no token) includes this instead of the full `Component`. It does NOT
1091
- pull in `Streamable` (so it never clobbers an app's own `replace`/`to_stream_*`
1092
- concern) or the token machinery — a token-less `reactive_root` with no `#id`
1093
- requirement, invisible to `phlex_reactive:doctor`. The full `Component`
1094
- includes it (one implementation), then layers Streamable + Identity on top.
1512
+ `reactive_persist` drafts such a group as the list of ticked values and
1513
+ restores exactly those boxes; before, the draft held one boolean and the
1514
+ restore ticked every box of the group. Whether the server already rendered a
1515
+ box ticked — in which case it keeps its say and the draft yields — is decided
1516
+ once before the restore walks the controls, because the walk writes `checked`
1517
+ as it goes and asking from inside it would read the restore's own work: the
1518
+ first box it ticks would make every later box of the group look
1519
+ server-rendered, and a draft of two values would come back as one. A
1520
+ non-checkbox control sharing the group's name additionally threw inside the
1521
+ draft write, which is swallowed — the root then persisted nothing at all,
1522
+ silently. On restore, such a control keeps what the server rendered whenever
1523
+ the group has two or more contributors: the list records the values, not
1524
+ which control each one came from, so replaying it would paste `freeform,news`
1525
+ into a text field, an editor, or a contenteditable. A group of ONE
1526
+ contributor has no such ambiguity — its single entry can only have come from
1527
+ that control — so a plain field whose name merely ends in `[]`, the usual
1528
+ shape for a list JS maintains, keeps its draft exactly as it did before
1529
+ groups existed. A `<select multiple>` reads a list by matching option values,
1530
+ which is only sound when the list is its own: sharing a group with another
1531
+ contributor, it too keeps what the server rendered, since a text value that
1532
+ happens to equal an option would otherwise select it.
1095
1533
 
1096
- - **Automatic token refresh — `reply.with` and `reply.streams` converge (#180).**
1097
- The action endpoint now appends a `reactive:token` refresh stream automatically
1098
- whenever a reply does not re-render the component's root, instead of prepending
1099
- a full self-replace that could clobber a live input. Picking `.streams` vs
1100
- `.with` to keep the signed token fresh is no longer a correctness decision — a
1101
- companion-only reply refreshes the token either way.
1534
+ Drafts written before this release are not discarded, but their group key is
1535
+ no longer applied to the controls that read a list: it holds one boolean (or,
1536
+ in a mixed group, whichever control wrote last), and applying that kept
1537
+ causing damage for as long as the draft lived — by default seven days after
1538
+ the upgrade. The damage differed by control. A checkbox group came back fully
1539
+ ticked. A `<select multiple>` sharing the group's name lost its rendered
1540
+ selection instead, because under `restore: "always"` the select branch skips
1541
+ the "the server had a say" check and matches `Set{"true"}` against its
1542
+ options, where nothing matches. The next snapshot replaces the key with the
1543
+ list. Only that one key changed meaning, which is why `PERSIST_VERSION` stays
1544
+ where it is: bumping it would also throw away the drafted prose of every form
1545
+ that has no checkbox group at all.
1102
1546
 
1103
- - **BREAKING: `verify_authorized` is ON by default (#168).** A reactive action
1104
- that completes **without any authorization call now raises**
1105
- `Phlex::Reactive::AuthorizationNotVerified` — **rolling back the transaction**
1106
- (fail-closed, stronger than Pundit's after-the-fact check). This is the
1107
- presence-side complement to `authorization_errors`: a forgotten `authorize!`
1108
- becomes a loud 500 your error tracker sees, not a silent hole. The guard
1109
- detects a call to any `Phlex::Reactive.authorization_methods` name
1110
- (default `%i[authorize! authorize allowed_to?]` — Pundit/CanCanCan/ActionPolicy)
1111
- **or** `mark_authorized!`, made anywhere during the action (a helper the action
1112
- calls counts too). **Three remedies** for each action:
1113
- 1. call your authorization method (`authorize! @record, :update?`);
1114
- 2. call `mark_authorized!` after a bespoke check the interceptor can't see;
1115
- 3. declare `skip_verify_authorized` (whole component) or
1116
- `skip_verify_authorized :action_name` (specific actions) for an
1117
- intentionally public action.
1118
- Turn it off globally with `Phlex::Reactive.verify_authorized = false` (the
1119
- install-generator initializer documents the knob). The `action.phlex_reactive`
1120
- instrumentation event gains a new `:unverified` outcome. A new **advisory**
1121
- doctor check flags mutating actions with no detected authorization call
1122
- (heuristic — a helper may authorize indirectly; never a hard fail).
1547
+ - **`reply.pending` kept its settle handle under
1548
+ `enqueue_after_transaction_commit = true` (#254).** The handle was captured in
1549
+ `Phlex::Reactive::Settles#serialize`, which reads a thread-local that lives
1550
+ only for the duration of `reply.pending`'s enqueue block. Under Rails'
1551
+ `ActiveJob::Base.enqueue_after_transaction_commit = true` (the 7.2+
1552
+ recommended setting) `job.enqueue` is deferred to
1553
+ `ActiveRecord.after_all_transactions_commit`, and the endpoint runs every
1554
+ action inside a transaction — so `serialize` always ran AFTER the block had
1555
+ exited. The job serialized with no `phlex_reactive_settle` key,
1556
+ `reactive_settle` was a no-op, and the row sat shimmering until someone
1557
+ reloaded. The handle is now captured when the job INSTANCE is created
1558
+ (`perform_later` → `job_or_instantiate` → `new`), which is synchronous inside
1559
+ the block whether or not the enqueue itself is deferred; `serialize` prefers
1560
+ the captured handle and still falls back to the thread-local. Both the
1561
+ `job:`/`args:` sugar (narrowing per record, so failures stay attributable) and
1562
+ the block form are covered, and a retry re-enqueue keeps its handle. Jobs
1563
+ enqueued outside any `reply.pending` still carry nothing.
1123
1564
 
1124
- ### Added
1565
+ - **`rake release` bumps the pin in every tracked lockfile (#247, #253).** Since
1566
+ #246 the gem root's `Gemfile.lock` is committed alongside `docs/Gemfile.lock`,
1567
+ and both pin `phlex-reactive` by local path — so a version bump that left them
1568
+ alone shipped a stale lockfile, and the Release workflow's frozen
1569
+ `bundle install` refused it (v0.13.0's first attempt).
1125
1570
 
1126
- - **Compound & numeric `reactive_show` predicates — `all:`/`any:` and
1127
- `gte:`/`gt:`/`lte:`/`lt:` (#176).** Value-conditional visibility now spans
1128
- **more than one field** and **numeric thresholds**, staying inside the
1129
- eval-free "declared literal predicate" contract. `all:` / `any:` fold a list of
1130
- per-field terms (`{ field:, equals:/not:/in:/gte:/… }`) with one fixed
1131
- connective — AND vs OR over the same literal vocabulary, no expression surface;
1132
- one flat binding replaces wrapper-div nesting and is the only way to express OR.
1133
- `gte:`/`gt:`/`lte:`/`lt:` compare `Number(value)` against a literal number baked
1134
- into the binding (the RHS must be a real `Numeric` — a typo fails at render); a
1135
- non-numeric field value is `NaN` → hidden, the safe reveal-on-threshold default.
1136
- Numeric predicates work standalone, as a compound term, and inside a
1137
- `reactive_show_targets` map. Malformed terms fold **false** (fail-closed:
1138
- default-deny). The single-field `reactive_show(:field, equals:)` form is
1139
- unchanged; the additions are backwards compatible.
1571
+ The task now bumps that pin **with a text edit**, not `bundle lock`. #247's
1572
+ first cut used `bundle lock --local`, which is a full re-resolve — and a
1573
+ re-resolve trips over constraints that have nothing to do with this gem:
1574
+ `docs/Gemfile.lock` declares Linux platforms for the Kamal deploy, and
1575
+ resolving `thruster` for those against a Mac's installed gems fails ("Could
1576
+ not find gems matching 'thruster' valid for all resolution platforms"), which
1577
+ aborted v0.13.1's first attempt mid-release with `version.rb` already bumped.
1578
+ A re-resolve also silently folds unrelated dependency drift into the release
1579
+ commit the moment a Gemfile is out of sync with its lock. The only line a
1580
+ version bump changes is the path-gem pin, so the task edits exactly that (the
1581
+ `PATH` spec + the `CHECKSUMS` entry) in place — deterministic on any machine,
1582
+ no network, no installed gems, the same 2-line diff bundler produced. A
1583
+ lockfile that names no pin at all now **aborts** the release rather than
1584
+ reporting itself already current — and that check runs in a PREFLIGHT, before
1585
+ the task does anything destructive or irreversible: before the `force`
1586
+ cleanup that deletes the GitHub release and its tag, and before `version.rb`
1587
+ or any lockfile is written. So an abort leaves the release intact and the tree
1588
+ clean, rather than a deleted release or a half-bumped tree the clean-tree
1589
+ guard would then block on retry.
1590
+ pgbus's release task made the same call after the same failure.
1140
1591
 
1141
- - **Installable Claude debugging skill + `rails g phlex:reactive:claude` (#168).**
1142
- The gem ships a `phlex-reactive-debugging` skill (the doctor → inventory → find
1143
- → browser `report()` → MCP workflow + a failure table) under `lib/`, and the
1144
- new generator copies it into a host app's `.claude/skills/` and writes the MCP
1145
- server entry to `.mcp.json` — only when absent (it never rewrites an existing
1146
- `.mcp.json`; it prints the snippet instead). A new **Debugging & tooling** docs
1147
- page ties the four surfaces together; the security page documents
1148
- `verify_authorized`; the instrumentation table gains the `:unverified` outcome;
1149
- the README gains a Debugging & tooling section + the config rows.
1592
+ - **`Component::Action` renamed `ActionDefinition` — it shadowed a host kit
1593
+ component named `Action` under dev autoloading (#233).** The mixin sits in
1594
+ every reactive component's ancestry, so its bare `Action` constant satisfied
1595
+ Phlex::Kit's LazyLoader `const_get` check with the wrong class: the kit's
1596
+ real `Action` component never autoloaded and the call fell through to
1597
+ `NoMethodError` — intermittently (any unrelated load of the kit class masks
1598
+ it until the next reload) and only under lazy autoloading, so an
1599
+ eager-loaded test suite stays green. The rename matches its siblings
1600
+ (`ComputeDefinition`, `OnCompleteDefinition`, `CollectionDefinition`) and
1601
+ removes the constant entirely — **deliberately no alias**, since any
1602
+ `Action` constant in the ancestry re-creates the shadowing. Breaking only
1603
+ for code referencing the internal
1604
+ `Phlex::Reactive::Component::Action` Data class directly; the `action :name`
1605
+ DSL and `reactive_actions` registry are unchanged. A regression spec guards
1606
+ the whole component ancestry against `:Action` coming back.
1150
1607
 
1151
- - **On-demand client inspector — `phlex/reactive/inspect` (#168).** A standalone
1152
- JS module (the `confirm.js`/`compute.js` precedent — **zero hot-path cost**, no
1153
- edit to `reactive_controller.js`, loaded only when imported) that scans the live
1154
- DOM and maps every reactive root + bound trigger back to the server
1155
- `Component#action` names. From the browser console:
1156
- `(await import("phlex/reactive/inspect")).report()` prints a `console.table` of
1157
- every reactive root — its `id`, decoded token payload (component class, gid,
1158
- state keys, token version — try/catch base64+JSON decode, degrading to
1159
- `{ opaque: true }` on a Marshal-serialized payload), status attrs, triggers
1160
- (action + event + params + debounce/throttle/confirm), client-only ops,
1161
- computes, the `name`d fields the dispatch would collect, and the
1162
- show/filter/text binding families. Triggers are scoped to the **nearest** root,
1163
- so nested roots aren't double-attributed. The server↔client mapping is
1164
- by-name: `scan()`'s `component` + trigger `action` strings are exactly the
1165
- identifiers `phlex_reactive:actions` and the MCP tools list. Pinned by the
1166
- engine (not preloaded); pure read, never mutates the page.
1608
+ - **Multipart path corrupted/dropped fields with Rails-bracketed names (#231).**
1609
+ `#buildFormData` wrapped every collected field/file name verbatim as
1610
+ `params[<name>]`, so `blog_post[summary]` went out as the Rack-unparseable
1611
+ `params[blog_post[summary]]` — the scalar arrived as `{"]" => value}` (and a
1612
+ writer stringified that hash into the record, with a 200), and a `:file` under
1613
+ a bracketed name (`blog_post[image]`) never reached its schema key (silent
1614
+ drop). It only fired when a file input was populated — the JSON path expands
1615
+ the same names server-side — which is why flat-named forms never caught it.
1616
+ The client now bracket-expands the field **name** into wire segments
1617
+ (`params[blog_post][summary]`, `params[blog_post][image]`, and
1618
+ `params[photos][]` for a `multiple` picker named `photos[]`), mirroring the
1619
+ server's `bracket_path` exactly, so a multipart body and a JSON body coerce
1620
+ identically for the same fields. Flat names are byte-identical to before.
1167
1621
 
1168
- - **Read-only diagnostic MCP server (#168).** `bin/rails phlex_reactive:mcp`
1169
- starts a stdio [MCP](https://modelcontextprotocol.io) server exposing five
1170
- read-only tools — `phlex_reactive_doctor`, `phlex_reactive_components`,
1171
- `phlex_reactive_actions` (optional `component:` filter), `phlex_reactive_find`
1172
- (fuzzy search + Prism method source), `phlex_reactive_config` (redacted) — so
1173
- Claude Code inside a host app can introspect the live reactive registry when
1174
- debugging. The `mcp` gem is **optional and lazy**: it is NOT a gemspec runtime
1175
- dependency; `Phlex::Reactive::MCP.load!` requires it on demand with a helpful
1176
- message when missing (the pgbus pattern), and the gem-dependent tool tree stays
1177
- out of the Zeitwerk autoloader — a host app without `mcp` boots and eager-loads
1178
- unaffected. Every tool is read-only and non-destructive (no arbitrary-query or
1179
- mutation tool) and reports names/paths/schemas only — never a token, secret, or
1180
- runtime state; `phlex_reactive_config` never emits the verifier or
1181
- `secret_key_base`. Consumer `.mcp.json`:
1182
- `{ "mcpServers": { "phlex-reactive": { "command": "bin/rails", "args": ["phlex_reactive:mcp"] } } }`.
1183
- Known constraint: stdio MCP needs a clean stdout — an initializer that `puts`
1184
- breaks the transport (same caveat as pgbus).
1622
+ - **Reply-rendered components now generate absolute URLs with the REQUESTING
1623
+ host, not the process default (#232).** Actor replies (`reply.replace`,
1624
+ `reply.streams(...)`, the implicit replace) render through the memoized
1625
+ off-request view context, whose `url_options` were process defaults — so any
1626
+ absolute URL helper (`image_tag` on an Active Storage attachment being the
1627
+ everyday case) rendered the wrong host on a multi-host app: a broken image
1628
+ after every save, healed by the next full page load. The action endpoint now
1629
+ threads the request's protocol/host/port into the reply render (the
1630
+ `ActiveStorage::SetCurrent` move) via a thread-local the memoized context
1631
+ merges per call — the context is never rebuilt, so the render memoization
1632
+ win is untouched (allocations byte-identical, throughput flat within noise).
1633
+ The defer **pull** endpoint gets the same treatment (it is an actor request).
1634
+ **Broadcasts are unchanged by design** — there is no request and subscribers
1635
+ can be on different hosts, so a broadcast fired *inside* an action explicitly
1636
+ clears the actor's url_options around its render; "URLs in broadcast-rendered
1637
+ components must be host-relative" is now documented in the Broadcasting guide.
1185
1638
 
1186
- - **verify_authorized runtime guard (#168).** New
1187
- `Phlex::Reactive::Authorization` (fiber-local tracking window, method
1188
- interception, the enforcement decision), `mark_authorized!` instance helper,
1189
- the `skip_verify_authorized` DSL (registry #6, inherits like the other five),
1190
- and `Phlex::Reactive.verify_authorized` / `authorization_methods` config
1191
- (`defined?`-guarded so an explicit override sticks). See the breaking note
1192
- above for the behavior and remedies.
1639
+ - **`rake bench` ran the removed `broadcast_replace_to` API and exited 1.**
1640
+ `benchmark/micro/broadcast.rb` migrated to the #185 spellings
1641
+ (`broadcast_to(*key, replace:)` / `broadcast_to(each:, replace:)`); the
1642
+ transport double is unchanged.
1193
1643
 
1194
- - **Action inventory — `Phlex::Reactive::Inspector` + rake tasks (#168).** A new
1195
- read-only introspection layer that answers "what reactive actions exist in this
1196
- app, where are they defined, and is each authorized?" without grepping.
1197
- `Phlex::Reactive::Inspector.components` discovers every constant-backed reactive
1198
- component from the loaded `Streamable` registry; `.find(query)` fuzzy-matches
1199
- one (exact > prefix > substring > subsequence, on both the demodulized and the
1200
- full name). Each action reports its declared param schema, `file:line`, the full
1201
- `def … end` source (extracted with **Prism**, degrading to `nil` on an
1202
- unreadable/unparseable file — never raising), and a **heuristic** authorization
1203
- status (a Prism scan for a configured authorization method or
1204
- `mark_authorized!` in the body — advisory only, since a helper may authorize
1205
- indirectly). Two shipped rake tasks surface it: `bin/rails
1206
- phlex_reactive:actions` (plain-text table, `FORMAT=json` for tooling) and
1207
- `bin/rails "phlex_reactive:find[query]"` (ranked matches; top match in detail
1208
- with each action's method source). Output is names/paths/schemas only — never
1209
- tokens, secrets, or runtime state (the instrumentation privacy contract
1210
- extended to tooling). `Doctor` now delegates its `constant_backed_component?`
1211
- filter to the Inspector so the endpoint-rebuild predicate lives in one place.
1644
+ - **`reactive_nested_remove(confirm:)` now interpolates `%{field}` on client-added
1645
+ rows (#222).** A row added in the browser via `reactive_nested_add` is a
1646
+ `cloneNode` of the `<template>`, and the clone path (`#renumberNestedRow` /
1647
+ `#seedNestedRow`) never rewrote the confirm attribute — so a per-row confirm
1648
+ froze to the template's value-less string on every added row (the exact rows
1649
+ the primitive exists for). The client now resolves `%{field}` placeholders in
1650
+ the confirm message from **that row's live field values** at click time (keyed
1651
+ by the same trailing-bracket inference `as: :json` uses), so
1652
+ `confirm: "Delete '%{name}'?"` on the template shows `Delete 'Widget'?` on the
1653
+ added row — reflecting a later edit too (resolved on remove, not on clone). An
1654
+ unresolved `%{key}` is left as its literal text (debuggable, never a silent
1655
+ blank); the placeholder works in the conditional Hash's `message:` as well.
1656
+ Server-rendered rows already interpolate server-side, so their finished strings
1657
+ are unaffected. **Also (superset of the issue's proposal 3):** `confirmResolver`
1658
+ now receives an optional second argument — a context object, always `{ el }`
1659
+ (the trigger), plus `{ row, fields }` on a `reactive_nested_remove` — so a
1660
+ themed-dialog override can build row-specific messages programmatically. The
1661
+ arg is additive: a one-parameter resolver (and `window.confirm`) is unchanged.
1212
1662
 
1213
- - **Deferred reply segments — `reply.defer` (#165).** An expensive part of a
1214
- reply (a cross-aggregate rollup, a report) no longer stalls the actor's
1215
- interaction: `reply.streams(cheap).defer(SessionTotals.new(workout:))`
1216
- returns the cheap streams immediately and streams the real render to the
1217
- SAME actor when it finishes. Keep-content default (the stale value stays
1218
- visible, marked `data-reactive-defer-pending` + `aria-busy` for CSS
1219
- shimmer); `placeholder: true` / a component swaps a skeleton in;
1220
- `morph: true` morphs the arrival (the mode rides INSIDE the signed token).
1221
- Transactional (the directive rides the post-commit reply — a rollback or a
1222
- denied action leaks nothing), actor-scoped (peers keep `broadcast_*_to`),
1223
- superseding (a newer action for the same target aborts the in-flight
1224
- deferred render — no stale paint), and interactive on arrival (fresh action
1225
- token). Delivery is transport-adaptive (`Phlex::Reactive.defer_transport`,
1226
- default `:auto`): a parallel fetch to the new `POST /reactive/defer`
1227
- endpoint everywhere (purpose-scoped, short-TTL defer token —
1228
- `defer_token_ttl`, default 120s; `reply.defer` tokens are **actor-bound**
1229
- to the requesting session so a leaked one can't be redeemed elsewhere,
1230
- `reactive_lazy` shell tokens are unbound by necessity — they render
1231
- before a session exists — with the TTL + `authorize!` as their bound;
1232
- never interchangeable with action tokens),
1233
- or a **durable pgbus one-shot stream + `DeferredRenderJob`** when pgbus's
1234
- reactive Streams and ActiveJob are present (`defer_job_queue` config; the
1235
- durable since-id replay closes the broadcast-before-subscribe race, and the
1236
- broadcast tears down its own subscription). The push lane's one-shot queue is
1237
- reclaimed by pgbus's age-based orphan-stream sweep (**pgbus ≥ 0.9.10**; run
1238
- the Dispatcher with `streams_orphan_threshold` set); we never eager-drop it
1239
- (that would reopen the delivery race). The one-shot key is sized to the live
1240
- pgbus `queue_prefix` budget, so a non-default prefix can't overflow it; the
1241
- render job broadcasts a cleanup on ANY failure so the actor's pending state
1242
- always resolves. Every capability gap degrades to the fetch lane — the
1243
- Action-Cable-or-pgbus invariant holds. **Profile first:** an app-side N+1
1244
- looks exactly like framework lag; defer is for segments that are genuinely
1245
- expensive after the synchronous path is cheap.
1663
+ - **A draft (unsaved-parent) token can now round-trip real server actions (#208).**
1664
+ `Component::Identity` already signed a gid-less `{c, state}` token for an
1665
+ unpersisted (or nil) record, but `from_identity` still `fetch`ed the absent
1666
+ `gid` — the FIRST action against a draft crashed with an uncaught `KeyError`
1667
+ (500). It now rebuilds through the record kwarg's **initialize default**
1668
+ (e.g. `def initialize(order: Order.new, …)`), with the declared
1669
+ `reactive_state` riding the token as before; a component whose initialize
1670
+ REQUIRES the record kwarg raises a guided `Phlex::Reactive::Error` naming the
1671
+ fix instead of a bare missing-keyword `ArgumentError`.
1246
1672
 
1247
- - **Lazy initial mount — `reactive_lazy` (#165).** The same machinery for the
1248
- FIRST render (Livewire `#[Lazy]`): the page ships the component's
1249
- placeholder shell (`deferred_placeholder`, or a built-in pending shell) with
1250
- the defer token on the root; the client fetches the real content on connect
1251
- AND after a Turbo page-refresh morph (so a lazy component survives a
1252
- `turbo:reload`). `reactive_lazy tag: :tr` (etc.) ships a shell element that
1253
- matches a `<tr>`/`<li>` root instead of an invalid `<div>`.
1254
- Reactive-machinery renders (an action's self-replace, broadcasts, the defer
1255
- endpoint/job) stay REAL, so actions never pay two round trips.
1673
+ - **`have_reactive_value` now reads the field's `.value` property, so it can verify a
1674
+ reducer-set disabled/computed field (#204).** The matcher (added in #201) delegated to
1675
+ Capybara's `have_field(with:)`, which matches the value **attribute** — but a
1676
+ `reactive_compute` reducer paints a computed output by setting its JS `.value`
1677
+ **property** (`el.value = …`), and for a **disabled / read-only** output the attribute
1678
+ never reflects that. So the matcher read `""` and failed on exactly the fields it was
1679
+ built for. It now re-resolves the field by id **or name** each poll and asserts the live
1680
+ `.value` property (via `evaluate_script`), covering enabled AND disabled/computed fields
1681
+ while keeping the morph-immunity. `have_reactive_text` (textContent) was unaffected.
1256
1682
 
1257
- - **pgbus capability gates.** `Phlex::Reactive.pgbus?` and `.pgbus_streams?`
1258
- (the documented broadcast-accepts-`:exclude` probe, now actually
1259
- implemented) plus `.defer_push_capable?` for the defer push lane.
1683
+ - **A freshly-rendered `reactive_compute` root now self-seeds its derived fields on connect (#199).**
1684
+ Before, a compute root computed nothing until the first user `input` — a fresh render
1685
+ (or a server validation-error re-render that replaced the body) left every derived
1686
+ output and cross-root `mirror:` node blank until you typed. Apps worked around it by
1687
+ dispatching a synthetic seed `input` on connect, but the compute root is a distinct
1688
+ Stimulus controller that may connect a frame later, so the seed raced its own wiring —
1689
+ the reported symptom being a PARTIAL apply (an early output painted; a later output
1690
+ and the mirror stayed blank). Now `connect()` runs ONE full `recompute()` after the
1691
+ controller is fully connected, so the whole single-pass write set (batch outputs →
1692
+ paint text sinks + cross-root mirrors → dispatch) runs synchronously and every
1693
+ declared output, text sink, and mirror paints from a single reducer result — no user
1694
+ interaction, no round trip. It re-seeds on `turbo:morph-element` (the show/filter/dirty
1695
+ precedent) and is idempotent (change-guarded writes make a re-seed a no-op, so an app
1696
+ still dispatching a synthetic input is harmless). The seed is gated on a new
1697
+ `data-reactive-compute-seed` marker emitted by `reactive_root(compute:)`; a non-compute
1698
+ root pays one attribute read and never seeds. The seed passes no event, so
1699
+ `meta.changed` is `null` — the correct "no field edited yet" semantics, so a convergent
1700
+ reducer's default branch computes the full settled set.
1260
1701
 
1261
- - **Client-side option filtering — `reactive_filter` (#163).** The other half
1262
- of #72's combobox: **preload the options, type to narrow — zero round
1263
- trips.** Spread `reactive_filter(input:, option:, group:, empty:)` onto the
1264
- root and the generic controller shows/hides each option on every keystroke by
1265
- a case-folded substring match against its `data-reactive-filter-text`
1266
- haystack (falling back to the option's own text) — no POST, no token, no
1267
- bespoke per-feature Stimulus controller. Optional `group:` collapses a header
1268
- whose every contained option is hidden; optional `empty:` reveals a
1269
- no-matches node at 0 visible. Selectors resolve within the root only (nested
1270
- reactive roots untouched), state seeds at connect and re-applies after a
1271
- morph, and blank selectors raise at render (a dead binding must fail loudly).
1272
- Composes with keyboard nav and per-row selection: a filtered-out option also
1273
- drops out of the Arrow-key path and loses its highlight, so Enter can never
1274
- pick an invisible row — selection itself stays a signed `on(:select)` action.
1275
- - **Standalone combobox keyboard nav — `reactive_listnav` (#163).** The same
1276
- Arrow/Enter/Escape wiring `on(…, listnav:)` appends, without the dispatch
1277
- descriptor — for the preload-and-filter input that fires **no** action (an
1278
- `on()` trigger would POST per keystroke). Spread
1279
- `reactive_listnav("[role=option]")` onto the input; Enter still picks by
1280
- clicking the highlighted option's own signed trigger.
1281
- - **Cross-root `reactive_show` targets — `reactive_show_targets` (#164).** A
1282
- field can now drive the visibility of declared elements **outside** its
1283
- reactive root — the nav tab, the panel in another tab pane, the sidebar note
1284
- a mode selector governs — the visibility parallel to the #159 cross-root
1285
- text mirror. The component that **owns** the field declares which outside
1286
- ids it governs, spread on the root:
1287
- `mix(reactive_root, reactive_show_targets(:mode, "#advanced-tab" =>
1288
- { equals: "advanced" }, "#basic-note" => { not: "advanced" }))`. Same
1289
- posture as `mirror:`: opt-in and declared, never implicit (a plain
1290
- `reactive_show` stays root-isolated, #15 untouched); targets are **single id
1291
- selectors only** — a class/compound selector raises at declare time AND is
1292
- warn-and-skipped by the client (two-sided default-deny); the predicate is
1293
- the same literal-only `reactive_show` vocabulary; the toggle is `hidden`
1294
- only. The field read stays **owned** — you can only drive outside visibility
1295
- from a field the declaring root owns. A target id not on the page is
1296
- silently skipped (an unrendered tab pane is normal); the cross-root pass
1297
- shares the owned-binding pass's field-read memo (one read per field per
1298
- sync). No map declared → one `getAttribute` and out. **One call per root**:
1299
- Phlex `mix` space-joins duplicate string data values, so a second call's
1300
- JSON would corrupt the attr (the client warns and ignores it) — several
1301
- fields go in one call via the hash form
1302
- (`reactive_show_targets(mode: { … }, kind: { … })`).
1303
- - **Value-conditional visibility — `reactive_show` (#161).** The `x-show` /
1304
- `data-show` / `wire:show` case — show/hide an element from a form field's
1305
- **current value** — no longer needs a hand-written `change`-listener Stimulus
1306
- controller. Spread `reactive_show(:mode, not: "off")` onto the element to
1307
- show/hide (also `equals:` and `in: [...]`; `equals: true` reads a checkbox's
1308
- checked state, a radio group reads the checked radio's value) and the generic
1309
- controller toggles the `hidden` attribute on every `input`/`change` —
1310
- client-only, zero round trip, no token. The predicate is a **declared literal
1311
- match**, never an expression (no eval surface); exactly one predicate is
1312
- enforced loudly at render; a missing field or malformed wire attr is
1313
- warn-and-skipped (client-side default-deny). Visibility seeds at connect and
1314
- re-syncs after a `turbo:morph-element`; a `reactive_compute` output write
1315
- dispatches a real `input` event, so derived values drive visibility too.
1316
- Ownership follows the nested-root rules (#15). Roots without a binding pay
1317
- one connect-time probe — no new listeners, byte-identical wire.
1702
+ - **Scoped form fields no longer silently drop their params — the #67 footgun is fixed (#184).**
1703
+ Under `reactive_scope`, `reactive_field(:date)` now emits the scoped wire name
1704
+ (`name="invoice[date]"`), so the POST arrives bracketed — and the endpoint unwraps
1705
+ exactly ONE scope level before schema matching, so a FLAT schema
1706
+ (`params: { date: :string }`) matches. Previously a scoped field either POSTed a bare
1707
+ name (misaligned with the `reactive_show`/`reactive_compute` resolvers, which already
1708
+ query `[name="scope[x]"]`) or forced a hand-written bracketed name + a hand-nested
1709
+ schema that Rails' bracket-expansion silently dropped. The client's `meta.changed`
1710
+ resolver (`#changedComputeField`) is now scope-aware too, so a scoped compute
1711
+ component's reducer still sees the bare declared name.
1318
1712
 
1319
- - **Cross-root text mirrors + the `text` client op (#159).** A derived value can
1320
- now be painted into a text node **outside** the computing component's reactive
1321
- root — the read-only recap in another tab pane that previously forced a
1322
- bespoke JS listener — with the library's default-deny posture intact:
1323
- - `reactive_compute ..., mirror: { sum_total: "#sum_total" }` declares
1324
- allowlisted cross-root text mirrors. Each compute pass paints every declared
1325
- name into its document-wide **id** target(s) via `textContent`
1326
- (change-guarded, never `innerHTML`, never blanks a name the pass produced no
1327
- value for). The value comes from the reducer result, a just-written output's
1328
- field, or a declared input's identity value — so it works with no reducer at
1329
- all. Non-id selectors raise at declare time AND are warn-and-skipped by the
1330
- client interpreter (two-sided default-deny). No `mirror:` → the wire is
1331
- byte-identical to before.
1332
- - `js.text(to, value, global: false)` — a new op that sets `textContent`
1333
- (stringified; `nil` clears), available to `on_client`, `reply.js`, and
1334
- `broadcast_js_to`. Strictly less powerful than `set_attr`; pair with
1335
- `global: true` for the cross-root paint.
1336
- - `global: true` is now honored on the `reactive:js` stream path: a single op
1337
- can opt out of the reply's target-root scope to document-wide resolution
1338
- (previously it was silently ignored when a `target` was set).
1713
+ - **`reactive_compute` output order no longer silently corrupts values (#183).**
1714
+ Each output write used to dispatch an `input` event **mid-loop**, re-entering the
1715
+ reducer synchronously from a **half-written DOM** — so a wrong `outputs:` order
1716
+ could commit wrong values (a real money-corruption class), settling only if the
1717
+ reducer was convergent in exactly the declared order. The client now runs the
1718
+ reducer **once** from one pre-write input snapshot, writes all changed outputs as
1719
+ a **batch**, paints text/mirror sinks from the settled values, then dispatches the
1720
+ `input` events — each self-marked so the root skips re-running its own reducer
1721
+ while other listeners (chained repaint, dirty tracking, `reactive_show`, sibling
1722
+ roots) still fire. Declared output order is no longer semantics. Covered by a JS
1723
+ order-independence test (declare outputs in the historically-wrong order — values
1724
+ stay correct) and a self-re-entry-suppression test.
1725
+
1726
+ - **A pending-state text swap no longer destroys a composite trigger's icon (#181).**
1727
+ The former `disable_with:`/`loading:` text swap wrote `trigger.textContent`, which
1728
+ **flattens every child node** — a `<button><svg/> Save</button>` lost its icon the
1729
+ instant the request enqueued, and it never came back on restore. `busy:`'s swap now
1730
+ reads and restores `innerHTML`, so an icon-plus-label trigger round-trips intact.
1731
+ Covered by a new composite-trigger browser assertion and a JS child-preservation
1732
+ unit test.
1733
+
1734
+ - **`exclude:`/`visible_to:` now actually reach pgbus — actor-echo suppression works over SSE (#187).**
1735
+ `broadcast_*_to`/`broadcast_*_to_each` passed `exclude:`/`visible_to:` as keyword
1736
+ arguments to `Turbo::StreamsChannel.broadcast_*_to`, but turbo-rails swallows
1737
+ unknown kwargs into its render locals — so on the pgbus transport the options were
1738
+ **silently dropped** and `exclude: reactive_connection_id` never suppressed the
1739
+ actor's own broadcast echo. They are now threaded through the thread-locals pgbus
1740
+ reads (`Thread.current[:pgbus_broadcast_exclude]`/`_visible_to`), capability-gated
1741
+ on `pgbus_streams?` so the Action Cable path is unchanged. Found by the new
1742
+ end-to-end pgbus transport suite (below) — the prior unit doubles only proved the
1743
+ option was *forwarded to the method*, never that it *reached pgbus*.
1339
1744
 
1340
1745
  ### Performance
1341
1746
 
1747
+ - **A deferred fragment renders as soon as it is read (#336).** The defer lane
1748
+ (a `reply.defer` fetch, a `reactive_lazy` load, a `cache:` fragment) handed
1749
+ its reply to `Turbo.renderStreamMessage`, and Turbo applies each
1750
+ `<turbo-stream>` one animation frame after its `turbo:before-stream-render`.
1751
+ The lane now catches those events and, once every listener has run, calls
1752
+ the final `event.detail.render` itself, in order, in the same task; Turbo's
1753
+ frame-later call gets a no-op. The event is still Turbo's own, so
1754
+ `preventDefault()` cancels a stream and a wrapped `detail.render` (effects,
1755
+ dismissing flashes) still runs. A Turbo that dispatches later renders on its
1756
+ own, as before. A warm `reactive_lazy(on:, cache:)` open in headless
1757
+ Chromium: median time from the trigger to the rows in the DOM 5.9 → 0.8 ms
1758
+ (stream event to rows 5.2 → 0.2 ms). Action replies and broadcasts keep
1759
+ Turbo's timing. On the opt-in client, a stream held for a module on its way
1760
+ wraps a copy of the event's render instead of writing it back, so Turbo's
1761
+ later call stays the no-op. The default bundle grows 128 B gzip
1762
+ (25,429 → 25,557 B), the defer module 129 B (2,845 → 2,974 B), the split
1763
+ core 21 B (13,323 → 13,344 B).
1764
+
1765
+ - **The default client is 1,008 B smaller gzipped (#310).**
1766
+ `reactive_controller.min.js` goes from 25,933 B to 24,925 B. The feature
1767
+ modules keep their per-root state in closures instead of records whose
1768
+ property names the minifier cannot rename (form, bindings, persist, defer;
1769
+ compute's record gets short names), and hints, devtools and bindings drop
1770
+ their per-call context records and wrapper exports. The features are also
1771
+ laid out in the bundle so gzip's 32 KB window reaches the runtime code they
1772
+ repeat (about 450 B of the total). The split client total goes from
1773
+ 32,568 B to 32,027 B; the core is unchanged. Behaviour is unchanged. The
1774
+ 22,700 B target is not met: 2,225 B remain, about 800 B of them from bulk
1775
+ selection (#319), which landed after the target was set.
1776
+ - **A cacheable lazy shell is less than half the size (#306).** A
1777
+ `reactive_lazy(on:, cache:)` shell with `reactive_cache_viewer` and
1778
+ `reactive_cache_version` gzips to about 290 B (was about 580–670 B). The
1779
+ fragment id is the identity encoded once plus a 128-bit MAC (it was the
1780
+ verifier's token wrapped in a second Base64); `u` is 22 base64url characters
1781
+ and `v` 11. A `cache:` shell no longer carries the identity token: a fragment
1782
+ URL the client refuses falls back to a GET of the same id under the fragment
1783
+ path it knows (`cache: "no-store"`), not to the `__materialize` POST, and a
1784
+ failed defer module is reported by its `reactive:error` (`kind: "feature"`)
1785
+ without a POST. Ids minted before this change still verify; their old `u`
1786
+ no longer matches, so such a URL renders but is `no-store`. The
1787
+ deferred-rendering page now says when deferring pays off.
1788
+
1342
1789
  - The defer machinery adds nothing measurable to the existing hot paths
1343
1790
  (same-machine, same-checkout before/after vs `main`): `to_stream_replace`
1344
1791
  14.4k → 14.2k i/s (within ±3.7% noise), identity token 199.9k → 196.7k i/s
@@ -2947,7 +3394,9 @@ room for dogfood fixes.
2947
3394
  scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
2948
3395
  state-backed by default or record-backed with `--record`.
2949
3396
 
2950
- [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.9.0...HEAD
3397
+ [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.1...HEAD
3398
+ [0.14.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.14.0...v0.14.1
3399
+ [0.14.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.9.0...v0.14.0
2951
3400
  [0.9.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.6...v0.9.0
2952
3401
  [0.2.6]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.5...v0.2.6
2953
3402
  [0.2.5]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.4...v0.2.5