phlex-reactive 0.13.3 → 0.14.0

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