phlex-reactive 0.4.8 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +924 -4
  3. data/README.md +986 -32
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +52 -7
  6. data/app/javascript/phlex/reactive/compute.min.js +4 -0
  7. data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/confirm.min.js +4 -0
  9. data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
  10. data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
  11. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
  12. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
  13. data/lib/generators/phlex/reactive/component/USAGE +2 -1
  14. data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
  15. data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
  16. data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
  17. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
  18. data/lib/phlex/reactive/component/dsl.rb +249 -0
  19. data/lib/phlex/reactive/component/helpers.rb +595 -0
  20. data/lib/phlex/reactive/component/identity.rb +92 -0
  21. data/lib/phlex/reactive/component/registry.rb +200 -0
  22. data/lib/phlex/reactive/component.rb +30 -442
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +27 -9
  25. data/lib/phlex/reactive/js.rb +249 -0
  26. data/lib/phlex/reactive/log_subscriber.rb +64 -0
  27. data/lib/phlex/reactive/param_schema.rb +390 -0
  28. data/lib/phlex/reactive/reply.rb +5 -3
  29. data/lib/phlex/reactive/response.rb +160 -16
  30. data/lib/phlex/reactive/stream.rb +98 -0
  31. data/lib/phlex/reactive/streamable.rb +307 -43
  32. data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
  33. data/lib/phlex/reactive/test_helpers.rb +308 -0
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. data/lib/phlex/reactive.rb +333 -14
  36. data/lib/tasks/phlex_reactive.rake +14 -0
  37. metadata +19 -1
data/CHANGELOG.md CHANGED
@@ -6,8 +6,870 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] - 2026-07-03
10
+
11
+ Consolidates everything since 0.2.6; tags 0.2.7–0.4.8 shipped without changelog
12
+ sections, so this section spans the whole run to 0.9.0. The jump from 0.4.8 to
13
+ 0.9.0 signals the Tier-2 API wave (#79–#120) and the run-up to 1.0, leaving 0.9.x
14
+ room for dogfood fixes.
15
+
16
+ ### Upgrading from 0.4.x
17
+
18
+ - **BREAKING — an unknown param-type symbol now raises at boot, not at click
19
+ time (#109).** A `params:` schema declared with a type the library doesn't know
20
+ (a typo like `:strng`, or a type you meant to register) raises
21
+ `Phlex::Reactive::UnknownParamType` when the component class loads — the schema
22
+ is compiled once at declaration. Fix the typo or register the type. Previously a
23
+ bad type symbol slipped through to the request and failed obscurely per click.
24
+ - **Minimum Ruby is now 3.4** (was 3.2). Enabling the `it` block parameter
25
+ requires 3.4, so `required_ruby_version` is `>= 3.4.0`. Stay on the 0.3.x line
26
+ if you need Ruby 3.2/3.3.
27
+ - **`optimistic:` / `loading:` / `disable_with:` are now RESERVED `on(...)`
28
+ keywords** (#98, #99), joining `window:` / `once:` / `outside:` / `throttle:`
29
+ from #80. If an action of yours took a free param literally named `optimistic`,
30
+ `loading`, or `disable_with`, rename it — those names now configure the
31
+ client-side hint/loading behavior instead of passing through as an action param.
32
+ - **`flash_builder` → `stream_builder` rename (#113) — no action needed.** The
33
+ builder does far more than flashes, so it was renamed;
34
+ `Phlex::Reactive.flash_builder` and `reset_flash_builder!` remain **permanent
35
+ aliases** (no deprecation). Listed only for grep-ability if you referenced the
36
+ old names.
37
+ - **The client runtime now ships pre-minified (#148) — zero change for importmap
38
+ consumers.** The engine pins the `.min.js` build (106 KB → 22 KB, ~7.7 KB
39
+ gzipped) with a linked sourcemap, so devtools still shows readable source. If
40
+ you vendor the client file by hand, re-vendor from the minified build (the
41
+ dummy app's `spec/dummy/public/vendor/reactive_controller.js` is that build).
42
+
43
+ ### Security
44
+
45
+ - **The signed-token version (`v`) now fails closed on a malformed value.**
46
+ `upgrade_token` type-guards `v` before comparing it: a non-integer or negative
47
+ `v` (only producible with the signing key, or by operator error — `v` lives
48
+ inside the signed blob) now returns `nil` → `400` through the endpoint's
49
+ `|| raise(InvalidToken)`, instead of a 500 on the `v > TOKEN_VERSION`
50
+ comparison or silently treating a negative `v` as a legacy passthrough. The
51
+ fail-closed-on-rollback contract (a `v` newer than this code understands → 400)
52
+ is unchanged.
53
+ - **The raw-array `js([...])` / `broadcast_js_to([...])` escape hatch now
54
+ re-applies the attribute-name allowlist server-side.** The `JS` builder rejects
55
+ event-handler (`on*`), URL-bearing, and `style` attribute names at build time;
56
+ the raw `[[op, args], …]` form skipped that Ruby-side check and relied on the
57
+ client interpreter alone. `Phlex::Reactive::JS.assert_ops_allowed!` restores
58
+ full server-side parity (defense in depth — the client still enforces it too),
59
+ so a hand-built ops array can't emit an `onclick`/`href`/`style` attr op.
60
+
61
+ ### Changed
62
+
63
+ - **One inheritance-aware registry behind the Component DSL; `component.rb` split
64
+ into cohesive concerns (#115).** All five class-level registries
65
+ (`reactive_actions`, `reactive_state_keys`, `reactive_collections`,
66
+ `reactive_computes`, `reactive_record_key`) now resolve through
67
+ `Phlex::Reactive::Component::Registry` with ONE semantic:
68
+ resolve-through-superclass at read time, memoized per class against a
69
+ generation counter bumped on any registry write. **The one visible behavior
70
+ change** (previously divergent): a parent class declaring an action/state
71
+ key/collection/compute AFTER a subclass had been read is now visible to that
72
+ subclass — pre-#115 the four collection registries snapshot-dup'd the parent
73
+ on first access (the late declaration was silently invisible), while
74
+ `reactive_record_key` walked live; the hot-path identity memos could go stale
75
+ split-brain against the live key. All covered by the new registry-inheritance
76
+ contract suite. Zero public API change: every reader keeps its exact
77
+ signature, `reactive_compute_def(name)` is added as the reader form matching
78
+ `reactive_collection_def` (the `reactive_compute(name)` getter stays a
79
+ permanent alias), and the token hot path is measured byte-identical
80
+ (unchanged i/s within noise; identical allocations). `component.rb` is now an
81
+ aggregator over `component/{registry,dsl,identity,helpers}.rb` — pure code
82
+ motion, constant paths unchanged.
83
+
84
+ ### Performance
85
+
86
+ - **Ship a minified client runtime — `reactive_controller.js` 106 KB → 22 KB
87
+ (−79%; ~7.7 KB gzipped).** The authored client modules are comment-dense on
88
+ purpose (the source is the documentation, and the JS suite imports it), so the
89
+ gem no longer ships that source to browsers. `rake build:js` (bun) produces a
90
+ minified twin of each module — `reactive_controller.min.js`,
91
+ `confirm.min.js`, `compute.min.js` — each with a linked sourcemap that
92
+ embeds the original source, so devtools still shows the readable code. The
93
+ engine now pins and precompiles the `.min.js` (plus its `.map`); the bare
94
+ specifiers are unchanged, so **no consumer edit is required** — importmap apps
95
+ transparently load the small file. The confirm/compute override seams stay
96
+ separately pinned (not bundled in), so `import { setConfirmResolver } from
97
+ "phlex/reactive/confirm"` still works. The bun minifier output is
98
+ deterministic (byte-identical across bun patch releases), so the artifacts are
99
+ committed and shipped in the gem — consumers need no bun — and CI (`rake
100
+ build:js_check`) rebuilds and fails on drift. The system suite now runs the
101
+ vendored minified build in a real browser under both Puma and Falcon, so the
102
+ code that ships is the code that is proven. Measured on bun 1.3.14.
103
+ - **Multi-key broadcast fan-out is ~9.5× faster (#119).** Measured, transport
104
+ doubled out, K=10 stream keys: a hand loop over `broadcast_replace_to` →
105
+ `broadcast_replace_to_each` moves 2.88k i/s (347 μs) → 27.3k i/s (37 μs)
106
+ (**9.5×**, and within ~4% of a single 1-key broadcast — K renders + K HMACs
107
+ collapse to 1 + 1), allocations 1250 obj / 186 KB → 151 obj / 22 KB (**−88%**
108
+ objects, 0 retained). Same-machine before/after on the new
109
+ `benchmark/micro/broadcast.rb`; numbers on the performance docs page.
110
+ - **`#extractToken` per-id regex memoization (#118).** The client's
111
+ `#extractToken` (`reactive_controller.js`) compiled its two self-matching
112
+ `RegExp` objects — the `reactive:token` matcher and the self replace/update
113
+ matcher — on **every** response, even though the root's `id` is page-stable.
114
+ They are now memoized on the instance keyed on `this.element.id`, rebuilt only
115
+ when the id changes (a re-render that re-identifies the root must scan for the
116
+ new target, never a stale one — covered by a new id-change bun test). The regex
117
+ **patterns are byte-identical**; only their allocation moved, so token
118
+ self-matching semantics are unchanged (the #44/#46/#47 pins stay green).
119
+ **Measured, not assumed** (`rake bench:client`, bun 1.3 / M2 Max): before/after
120
+ is within run-to-run noise (~4.4 µs on a 2 KB body, ~9–11 µs on a 500 KB 200-row
121
+ collection, both sides), because `#extractToken` is already **~0.25% of the
122
+ `DOMParser` ceiling** (~4 ms on that 500 KB body) — removing two allocations per
123
+ call sits below the timing floor of the dispatch-driven bench. It is a
124
+ correctness/cleanliness win on a hot path, **not** a throughput change; per the
125
+ issue's decision rule, recorded as "measured — not worth further optimization"
126
+ and closed.
127
+
9
128
  ### Added
10
129
 
130
+ - **`broadcast_*_to_each` — render-once, multi-key broadcast fan-out (#119).**
131
+ `broadcast_*_to` concatenates its `*streamables` into ONE stream key, so
132
+ pushing the same component to K DIFFERENT keys (a per-tenant loop — "the list
133
+ page stream AND the dashboard stream") with a hand-written loop was K builds +
134
+ K renders + K identity HMACs for BYTE-IDENTICAL HTML. Every verb now has an
135
+ `_each` sibling — `broadcast_replace_to_each` / `_update_` / `_append_` /
136
+ `_prepend_` / `_remove_` — taking an enumerable of stream keys (each a
137
+ `[record, :symbol]` pair or a bare string): it renders the component **once**
138
+ and loops only the cheap channel call. Transport opts (`exclude:` /
139
+ `visible_to:`) and `morph:` forward **per key** exactly as the single-key verbs
140
+ do, so the pgbus path suppresses the actor's echo on every stream and the
141
+ Action Cable path is unchanged (the no-opts call passes no unknown keyword —
142
+ the pgbus-optionality invariant holds; the old-pgbus-shape double guards the
143
+ `ArgumentError` regression). Per-VIEWER `visible_to:` content (different HTML
144
+ per viewer) stays the irreducible render-per-call case. Pure composition of the
145
+ existing private helpers; no change to any single-key method.
146
+ - **`benchmark/micro/broadcast.rb` — the missing broadcast bench (#119).** The
147
+ broadcast render path was a named hot path (`.claude/rules/performance.md`)
148
+ with no bench. It doubles the transport out and measures the server-side
149
+ build + render + HMAC cost at 1 key, a hand K-key loop, and `_to_each` over K
150
+ keys — auto-discovered by `rake bench:micro`. It also benched `model_param_name`
151
+ (the audit's measure-first candidate: 815k i/s, 8 obj/call — immaterial next to
152
+ the ~37 μs build, so **measured and left alone**, no memoization).
153
+ - **`benchmark/micro/verify.rb` — isolate token verify/sign cost; the
154
+ digest/serializer question is now measured and closed (#120).** `verify` runs
155
+ before anything else on every reactive request (a garbage-token flood pays it),
156
+ and `sign` runs once per rendered component (N× for an N-row collection) — yet
157
+ neither had ever been measured in isolation (`token.rb` covers sign-side
158
+ *assembly*, not the HMAC), and the default digest/serializer was never compared.
159
+ The new bench (auto-discovered by `rake bench:micro`, no harness change) reports
160
+ four verify cost classes — **valid** state/record (~136k/147k i/s, 20 obj), a
161
+ **tampered** token that pays the full constant-time HMAC compare (~203k i/s,
162
+ ~6× slower than garbage), and a **garbage** flood that bails at format parsing
163
+ (~1.2M i/s, 4 obj) — plus `sign` ×1 (~155k i/s, 12 obj) and the **collection
164
+ cost** `sign` ×100 (~1.6k i/s, 1200 obj, linear). It also compares the app
165
+ default (SHA1, json+marshal) against SHA256 and a strict JSON serializer for
166
+ both verify and sign: **every variant falls within measurement noise**, so
167
+ phlex-reactive **ships no runtime change and no opt-in recipe** — the setter
168
+ (`Phlex::Reactive.verifier = …`) is documented as the escape hatch if your own
169
+ profile ever shows verify hot, but the data says it will not. Verification
170
+ semantics, purpose scoping, and default-deny are untouched — this issue
171
+ *measures*. Numbers and the "measured, closed" verdict are on the performance
172
+ page. Pure tooling + docs; no production-code change.
173
+
174
+ - **Client dispatch micro-bench harness — `rake bench:client` (#116).** The client
175
+ hot path (`app/javascript/.../reactive_controller.js`) was named a hot path in
176
+ `.claude/rules/performance.md` but had **no bench of any kind** — every claim
177
+ about `#extractToken` / `#collectFields` / `recompute` cost was a guess the
178
+ perf prime directive forbids, and it blocked the client-perf issues that need a
179
+ before/after. `benchmark/client/` now holds bun-runnable
180
+ [mitata](https://github.com/evanwashere/mitata) benches that drive those three
181
+ paths through the controller's **public surface only** (`dispatch()` /
182
+ `recompute()`) — **no `__bench` exports, nothing under `app/javascript/`
183
+ changes**, so the vendored-client re-sync rule is never tripped. `rake
184
+ bench:client` shells to `bun run benchmark/client/index.bench.js`, writes
185
+ `tmp/benchmarks/client.txt` alongside `micro.txt`, and **fails the task on a
186
+ crashed bench** (mitata runs with `throw: true`, matching the `bench:micro`
187
+ contract). New dev-only dependencies: **mitata + happy-dom** (a JS DOM engine
188
+ for the `collectFields`/`recompute` fixtures). Baselines are documented on the
189
+ performance page with honest framing: the happy-dom numbers are
190
+ **engine-relative** (valid for same-machine before/after deltas, not an
191
+ absolute browser cost), while the regex-only `#extractToken` numbers are
192
+ **engine-faithful** under bun/JSC. Pure tooling — no production-code change.
193
+
194
+ - **`morph:` on the update verbs (#113).** The `update` family now takes the same
195
+ `morph:` flag `replace` already had, closing the verb-matrix asymmetry: Turbo 8
196
+ supports `method="morph"` on `action="update"`, so an inner-HTML update can morph
197
+ in place instead of swapping. This matters most for a **cross-tab
198
+ `broadcast_update_to`** into a component a peer is editing — a plain update tore
199
+ down their focus/caret; `morph: true` patches the inner HTML in place and keeps
200
+ it. Threaded through every update surface:
201
+ - `Streamable.update(model, morph: true)` (class builder) and
202
+ `#to_stream_update(morph: true)` (instance primitive) — emit
203
+ `method="morph"`.
204
+ - `broadcast_update_to(*streamables, morph: true)` — carries the attr through
205
+ `attributes:` (the broadcast path has no `method:` kwarg), so it works on
206
+ **both** Action Cable and pgbus; the pgbus-absent fallback is unchanged.
207
+ - `Response.update(component, morph: true)` and `Reply#update(morph: true)` —
208
+ `reply.update(morph: true)`.
209
+
210
+ **Default `false` everywhere → byte-identical to today** (no `method=` attribute),
211
+ so existing components are unaffected. No new dependency — Idiomorph ships with
212
+ `turbo-rails >= 2.0`.
213
+
214
+ - **Component-aware `around_action` seam (#112).** Register a wrapper with
215
+ `Phlex::Reactive.around_action { |ctx, &action| … }` and it folds into the
216
+ endpoint **between** `with_connection_id` and the action's transaction — so it
217
+ sees the resolved component instance, the declared action name, and the
218
+ **coerced** params (a frozen `Phlex::Reactive::ActionContext`), and a rejection
219
+ never opens a transaction. This is the seam for audit logging, component-aware
220
+ rate limiting, and assertions; the base controller (`base_controller_name`)
221
+ stays the seam for HTTP-layer concerns (auth, CSRF, coarse per-IP throttling)
222
+ that don't need the resolved action. Contract: **each wrapper MUST return
223
+ `action.call`'s value** — the endpoint type-checks the action's return for a
224
+ `Phlex::Reactive::Response`, so a wrapper that returns its logger's result
225
+ silently downgrades every reply to the implicit self-replace. A wrapper raising
226
+ a registered `authorization_errors` error renders as 403; an unregistered raise
227
+ is a 500. Wrappers nest in registration order (last-registered outermost). The
228
+ empty stack (the default) adds only one `Array#empty?` check to the hot path;
229
+ `Phlex::Reactive.reset_around_actions!` resets it for tests. See the README
230
+ "Two seams" section and the security page.
231
+
232
+ - **Versioned identity-token payload + upgrader chain (#111).** Every signed
233
+ token now carries a `"v"` key (`Phlex::Reactive::TOKEN_VERSION`, currently `1`),
234
+ and `Phlex::Reactive.verify` runs the payload through an upgrader chain before
235
+ your component rebuilds from it. This is infrastructure for the NEXT breaking
236
+ shape change (a rename, per-token expiry, a nonce): instead of every open page
237
+ breaking at deploy — an old-shape token verifies fine, then blows up later in
238
+ `from_identity` — the old payload upgrades transparently. Contract:
239
+ - A token minted **before** versioning existed carries no `"v"`; it is read as
240
+ version 0 (today's shape) and passes through byte-identical — introducing
241
+ versioning **invalidated nothing in flight**.
242
+ - When you change the shape, bump `TOKEN_VERSION` and register
243
+ **`Phlex::Reactive.register_token_upgrader(old_version) { |payload| … }`**;
244
+ upgraders run oldest → current.
245
+ - A token from a **newer** version than the running code (a rolled-back deploy)
246
+ **fails closed** — `verify` returns `nil`, so the endpoint answers 400, the
247
+ same path as a tampered token. Never fail-open.
248
+
249
+ `from_identity` ignores the extra `"v"` key, so existing components are
250
+ unaffected. Perf: the one added key costs +3 objects / +480 bytes per
251
+ `reactive_token` (a single `Hash#merge`); HMAC signing dominates and is
252
+ unchanged, so throughput is within measurement noise.
253
+
254
+ - **Public `Phlex::Reactive::TestHelpers` + a no-HTTP `run_reactive` action
255
+ driver (#110).** The gem now ships the test surface it used to keep private.
256
+ Mix it in (`config.include Phlex::Reactive::TestHelpers`) for:
257
+ - **`run_reactive(component, :act, **params)`** — the no-HTTP unit driver. It
258
+ runs the action through the SAME security contract the endpoint enforces —
259
+ **default-deny** (an undeclared action raises
260
+ `Phlex::Reactive::TestHelpers::UndeclaredReactiveAction`), the **signed
261
+ identity round-trip** (a record-backed component's row is re-found; a deleted
262
+ record raises `ActiveRecord::RecordNotFound`, the endpoint's 404), **schema
263
+ coercion** (#109), and the same **transaction wrapper** — with no HTTP, and
264
+ returns a `Result`. A registered authorization error RAISES (the endpoint
265
+ maps it to 403). So a unit test can no longer pass on a component that would
266
+ fail a real click.
267
+ - **`Result`** — wraps the action's return: `replace?` / `remove?` /
268
+ `redirect?` / `redirect_url` / `streams` / `response`, plus `component` (the
269
+ instance rebuilt from identity — the one the action ran against). A legacy
270
+ action returning an arbitrary value reads as an implicit replace.
271
+ - **`reactive_token_for(component_or_class, payload = {})`** — mint a token the
272
+ way a component does (class form via the public `Phlex::Reactive.sign`;
273
+ instance form wraps the private `reactive_token`). No more
274
+ `component.send(:reactive_token)`.
275
+ - **`post_reactive_action` / `post_reactive_multipart`** — POST a signed token
276
+ to **`Phlex::Reactive.action_path`** (never a hardcoded `/reactive/actions`,
277
+ so a remounted path is honored) exactly as the client encodes it.
278
+ - **Matchers** `have_reactive_replace`, `have_reactive_remove`,
279
+ `have_reactive_token_for` (RSpec; loaded only when RSpec is present, so a
280
+ Minitest app asserts on `Result` predicates directly). The token matcher pins
281
+ the #44/#46 refresh regression class for adopters.
282
+
283
+ The gem's own request suite now runs THROUGH the public module (dogfooding),
284
+ and the component-generator spec template + the testing docs teach the public
285
+ API — every `send(:reactive_token)` instruction is gone.
286
+
287
+ - **`Phlex::Reactive::ParamSchema` — declaration-time validation + app-registerable
288
+ param types (#109).** The ~200 lines of param coercion moved out of
289
+ `ActionsController` into a standalone `Phlex::Reactive::ParamSchema`, **compiled
290
+ once** when you declare an action (`ParamSchema.compile(params)`) instead of
291
+ interpreted on every request. Two adopter-facing wins:
292
+ - **Typos fail loud, at boot.** A schema naming an unknown type symbol
293
+ (`params: { count: :interger }`) now raises
294
+ **`Phlex::Reactive::UnknownParamType`** (an `ArgumentError` subclass) at class
295
+ load — validated recursively through nested-hash and array-of-hash element
296
+ types — instead of silently coercing the value to a `String` at click time.
297
+ - **New built-in types + custom registration.** `:date`/`:datetime` (ISO8601,
298
+ dropped on a parse failure) and `:decimal` (`BigDecimal`, dropped on a
299
+ non-numeric value) join the built-ins, so a date/decimal param no longer needs
300
+ `:string` + hand-parsing. Register your own with
301
+ **`Phlex::Reactive.param_type(:money) { |v| … }`** in an initializer — return
302
+ `Phlex::Reactive::ParamSchema::DROP` to reject a value (the keyword default
303
+ then applies, keeping the drop-don't-fabricate contract). The registry is
304
+ frozen after boot, so registration is initializer-only.
305
+
306
+ Behavior is otherwise **byte-for-byte identical**: every existing built-in keeps
307
+ its exact semantics (`"abc".to_i → 0`, not a drop; the `:file` duck-type drop;
308
+ the array/hash drop rules; the `#16`/`#21`/`#24`/`#39` bracket-expansion and
309
+ deep-merge matrix), and the `verbose_errors` dropped-param collector (`#82`/`#87`)
310
+ threads through unchanged — a `nil` collector is still the zero-cost production
311
+ path. Coercion is now unit-tested directly (`spec/phlex/reactive/param_schema_spec.rb`)
312
+ in addition to the request-spec integration cover. Bench (`rake bench:one[coerce_params]`),
313
+ same machine: ~35.5k → ~36.0k i/s, 207 → 202 obj/call (the memoized `Boolean`
314
+ type replaces a per-call instantiation), 0 retained — unchanged within noise.
315
+
316
+ - **Client debug mode (devtools-lite) — `console.group` every dispatch (#108).**
317
+ The `LogSubscriber` (#107) is the *server* lens; this is the *client* one. Set
318
+ `Phlex::Reactive.debug = true` (default off; the initializer template suggests
319
+ `Rails.env.development?`) and `reactive_attrs`/`reactive_root` stamp
320
+ `data-reactive-debug="true"` on the root — the string `"true"`, not a
321
+ boolean-true attr, which Phlex renders valueless and the client would read as
322
+ falsy. The generic controller then `console.group`s **every dispatch** with the
323
+ action, the explicit param **names** + collected sibling-field **names** (never
324
+ their values — they may be sensitive), the request encoding (`json`/`multipart`),
325
+ the HTTP status, the response's stream actions + targets (parsed from the body
326
+ the controller **already read** — no re-fetch), whether a token refresh arrived
327
+ (a boolean — **never the token value**), and the round-trip ms:
328
+ `▼ reactive #todo_42 rename → 200 (48ms)`. This finally surfaces the
329
+ *successful-but-wrong* path (the #30/#44/#46/#50 token-threading series: which
330
+ streams arrived? did a refresh come?), which `console.error` + the #79 lifecycle
331
+ events never covered. **Zero cost when off** — one attribute check per dispatch,
332
+ no string building — measured on the render/token micro benches: **0 extra
333
+ allocations** per render and no throughput change. See the Client debug mode
334
+ section of the README.
335
+
336
+ - **`ActiveSupport::Notifications` on the hot paths + an opt-in `LogSubscriber`
337
+ (#107).** The gem now emits three events under the `phlex_reactive` namespace,
338
+ so an APM (AppSignal, Datadog, Skylight) sees reactive traffic at the
339
+ **component level** — which component/action a slow request was, render time,
340
+ and broadcast fan-out — where before it saw nothing:
341
+ - `action.phlex_reactive` — one per request; payload `component`, `action`,
342
+ `outcome` (`ok`/`denied_undeclared`/`invalid_token`/`not_found`/`unauthorized`).
343
+ - `render.phlex_reactive` — per render; payload `component`, `bytesize`.
344
+ - `broadcast.phlex_reactive` — per `broadcast_*_to` (fires on Action Cable
345
+ **and** pgbus); payload `component`, `stream_action`, `streamables` count.
346
+
347
+ Payloads carry **names, the outcome, and sizes only** — never the token,
348
+ params, or state, so an event can't leak a secret; an `invalid_token` event has
349
+ no trusted component name (the token didn't verify) and omits it. Statuses and
350
+ the endpoint flow are unchanged — the instrument only wraps the existing paths.
351
+ An unsubscribed `instrument` is cheap (a few objects per call, **zero
352
+ retained** — measured on the render micro bench) so the hot paths carry it
353
+ unconditionally. Flip on `Phlex::Reactive.log_events = true` (default off) to
354
+ get one compact debug line per event: `[reactive] Counter#increment ok (3.1ms)`.
355
+ See the Observability section of the README and the performance docs page.
356
+
357
+ - **`bin/rails phlex_reactive:doctor` — validate the whole install (#106).** Five
358
+ closed issues (#3 boot/eager-load, #26 route shadowing, #42 lost request, #48
359
+ unregistered controller, #57 importmap 404) were pure integration papercuts
360
+ that only surfaced **after** something already broke. The doctor turns "nothing
361
+ happens, why?" into an actionable checklist you run before or after setup. It
362
+ prints `✓/✗/?` with a fix for each failure and checks: the action route
363
+ resolves (reuses the boot route-shadow guard), the `reactive` controller is
364
+ registered in a Stimulus entrypoint (importmap **or** esbuild/bun **or** a
365
+ layout), `csrf_meta_tags` is referenced (**advisory `?`** — never a hard fail,
366
+ so a Phlex-only layout isn't false-flagged), the identity verifier round-trips,
367
+ `base_controller_name` constantizes, every declared `action :name` has a public
368
+ method (mirrors the endpoint's `public_send`), and every component resolves a
369
+ stable `#id` (a state-backed class with no `#id` is flagged; a record-backed
370
+ class on the #81 default is fine). It is **read-only** — no client surface, no
371
+ component instantiation, default-deny untouched — and exits non-zero on a hard
372
+ failure so CI or a setup script can gate on it.
373
+ - **Install generator hardening (#106).** The generator now also detects
374
+ `app/javascript/application.js` (esbuild/bun/webpack) as a Stimulus entrypoint,
375
+ not just the importmap-style `controllers/index.js`, and its post-install output
376
+ ends by telling you to run `bin/rails phlex_reactive:doctor` to verify.
377
+
378
+ ### Performance
379
+
380
+ - **Nested-root fast path for the client field walks (#117).** `#collectFields`,
381
+ `recompute`, and `#listnavOptions` computed field ownership (the issue #15
382
+ "is this element mine, not a nested reactive root's?" check) with a per-element
383
+ `el.closest('[data-controller~="reactive"]')` walk on every matched field —
384
+ `recompute`'s `#ownedField` did a **fresh** `querySelectorAll('[name="…"]')` +
385
+ `closest()` filter **per declared input AND per output on every keystroke**
386
+ (~60 DOM queries on a 30-field calculator). A new `#ownershipFilter()` hoists
387
+ the decision to **once per op**: with no nested reactive root (the common case)
388
+ it returns a constant-true predicate and the per-field `closest()` walk is
389
+ skipped entirely; when a nested root is present it falls back to the exact
390
+ `#ownsField` closest() check, so issue #15 scoping is byte-identical. `recompute`
391
+ additionally resolves its inputs and outputs through a per-call, **first-wins**
392
+ `byName` memo (`if (owns(el)) break` on the first owned match — last-wins would
393
+ change radio-group / Rails hidden+checkbox behavior). Measured same-machine
394
+ before/after (bun + happy-dom, engine-relative): **recompute ~31 µs → ~23 µs per
395
+ keystroke (~25%)** on the 30-input calculator, 0 retained bytes/call;
396
+ `#collectFields` (once per dispatch, dominated by the dispatch overhead) stays
397
+ within noise. A **method/keystroke-level** win, not a request-level one. Field
398
+ ownership is unchanged for every component; the issue #15 nested-root bun tests
399
+ pass untouched. New coverage: `spec/javascript/reactive_recompute_ownership.test.js`
400
+ proves first-wins resolution for duplicate owned names and nested-root rejection.
401
+
402
+ ### Changed
403
+
404
+ - **Structured `Phlex::Reactive::Stream` value object — the endpoint stops
405
+ regex-sniffing its own generated turbo-stream HTML (#114).** The action endpoint
406
+ used to reverse-engineer stream *semantics* out of *markup*: it substring-scanned
407
+ every stream for `data-reactive-token-value` and regexed the opening
408
+ `<turbo-stream>` tag's `action="…"` against a self-render allowlist to decide
409
+ whether a stream already refreshed the component's signed token. The gem was
410
+ parsing strings it had just built, and every new stream shape needed a new patch
411
+ to the heuristics. Now every `to_stream_*` instance primitive and every
412
+ `Streamable` class builder (`replace`/`update`/`append`/`prepend`/`remove`)
413
+ returns a `Phlex::Reactive::Stream` — an `ActiveSupport::SafeBuffer` subclass
414
+ that IS an html_safe String (so it drops into `render turbo_stream:`, ERB/Phlex
415
+ interpolation, and Turbo test-helper substring asserts unchanged) but also
416
+ carries `rx_action` / `rx_target` / `rx_renders_root?` set **structurally at
417
+ construction** plus `rx_carries_token?` computed **once from ground truth** (a
418
+ build-time scan of the actual bytes, never inferred from the builder kind — the
419
+ cosmos#1939 guard). The endpoint reads those fields instead of regexing markup;
420
+ `renders_root: false` on `append`/`prepend` encodes the #44 lesson (a child row's
421
+ own token in an append `<template>` can never count as the container's refresh)
422
+ in the *type*, not a regex. Raw strings (`reply.with(…)`, interpolated or
423
+ `gsub`'d streams) keep the pre-#114 regex path as a permanent fallback, gated on
424
+ `stream.is_a?(Stream)`. **The wire format is byte-identical** — same
425
+ token-refresh decisions (#30/#44/#46/#50 pins all green), different mechanism.
426
+ This is an architecture/correctness win, not a speedup: `rake bench:request`
427
+ before/after moves within measurement noise (allocations +1 obj/req state-backed,
428
+ +5 obj/req record-backed; throughput swings inside the ±15–28% error bars).
429
+
430
+ - **`Phlex::Reactive.flash_builder` → `stream_builder` (and `reset_flash_builder!`
431
+ → `reset_stream_builder!`) (#113).** The memoized `Turbo::Streams::TagBuilder`
432
+ is used well beyond flashes — `reactive_collection` row removal, count-companion
433
+ updates, `also_update` — so the `flash_` name misled. Renamed to `stream_builder`
434
+ / `reset_stream_builder!`. **`flash_builder` and `reset_flash_builder!` stay as
435
+ permanent aliases** (no deprecation warnings), so the engine's reload hook and
436
+ any app code keep working unchanged. Purely a rename — behavior is identical.
437
+
438
+ - **BREAKING (declaration-time): an unknown param type symbol now raises (#109).**
439
+ Before, a schema naming a type the coercer didn't recognize (a typo like
440
+ `params: { count: :interger }`, or a type never registered) fell through to a
441
+ silent `String` coercion. Now `Component.action` compiles the schema through
442
+ `Phlex::Reactive::ParamSchema.compile` and raises
443
+ **`Phlex::Reactive::UnknownParamType`** (an `ArgumentError` subclass) at class
444
+ load. The fix is to correct the typo or register the type
445
+ (`Phlex::Reactive.param_type(:name) { |v| … }` in an initializer). This only
446
+ affects schemas that were *already* silently mis-coercing; a valid schema is
447
+ unchanged.
448
+
449
+ - **`on(:typo)` fails at render, not click (#105).** A misspelled or forgotten
450
+ action used to render fine and only surface as an unexplained **403 on click**
451
+ (the endpoint's default-deny). Now, when **`Phlex::Reactive.verbose_errors`** is
452
+ on (the default in development and test via `Rails.env.local?`), **`on(:name)`
453
+ raises `Phlex::Reactive::Error`** at **render** time if `:name` isn't declared
454
+ on that component — the message lists the declared actions, the same loud-
455
+ failure courtesy `reactive_compute_attrs` already gives an undeclared compute.
456
+ You catch the typo the moment you load the page instead of hunting a mystery
457
+ 403.
458
+ - **Production is unchanged.** With `verbose_errors` off (the production
459
+ default) `on()` keeps today's permissive emit, so a stale page after a deploy
460
+ that removed an action **never 500s on render**.
461
+ - **Cross-component dispatch still works.** A component that declares **no
462
+ actions of its own** — a child row rendering a trigger for its container's
463
+ action and sending the container's token (e.g. a notification row → the list's
464
+ `:dismiss`) — is skipped: it can't self-validate against a registry it doesn't
465
+ own. `on_client` triggers are never checked (they aren't declared actions).
466
+ - **Not the security boundary.** This is a dev-time aid; the server's
467
+ default-deny stays the enforcement. The check is one flag-gated hash lookup
468
+ with **zero added allocations** on the `on()`/render/token hot paths (measured:
469
+ `on(:increment)` and `to_stream_replace` allocate byte-identically before and
470
+ after; the prod path short-circuits on the flag before the lookup).
471
+
472
+ ### Added
473
+
474
+ - **`reactive_text` mirrors + typed compute inputs (#104).** Mirror a form field
475
+ into a **text node** — a live preview heading, a character counter,
476
+ `"Hello, {name}"` — with **no round trip and no bespoke Stimulus controller**.
477
+ Datastar's `data-text` / Alpine's `x-text`, but declared on the component.
478
+ - **`reactive_text(:name, initial)`** renders `span(data: { reactive_text:
479
+ name }) { initial }` — the **text sibling of `reactive_field`**. The client
480
+ writes it via **`textContent`** (XSS-safe by construction, never `innerHTML`).
481
+ It carries **no `name`**, so `#collectFields` never sweeps it into the POSTed
482
+ params.
483
+ - **Typed compute inputs.** `reactive_compute :x, inputs: { title: :string,
484
+ qty: :number }, outputs:` types each input: a `:number` is coerced through
485
+ `Number` (blank/NaN → 0), a `:string` reaches the reducer **raw** — so a live
486
+ text preview reads real text, not `NaN`. The **array form** (`inputs: %i[a
487
+ b]`) stays all-numeric and **byte-identical on the wire** (a JSON array); the
488
+ hash form emits a JSON object of name→type.
489
+ - **Output resolution.** A compute output whose name matches an owned form
490
+ field writes that field's `.value` (the existing change-guarded `input`
491
+ dispatch). An output with **no** matching field writes every owned
492
+ `[data-reactive-text="<name>"]` node via `textContent` — change-guarded too,
493
+ but **no** input dispatch (a text node has no listener contract).
494
+ - **Reducer-less identity mirrors.** A separate always-run pass syncs each
495
+ declared **input**'s raw value into its own `reactive_text(:same_name)` node
496
+ on every keystroke — so `reactive_text(:title)` is a live field echo with **no
497
+ registered reducer at all**.
498
+ - **Seed the server render.** `view_template` must seed each mirror with the
499
+ same derived value the reducer would, or a later morph repaints stale text —
500
+ the same reconcile contract the new-vs-persisted split already documents.
501
+
502
+ - **Dirty-field tracking + unsaved-changes guard (#103).** Show "unsaved
503
+ changes", enable **Save** only when something changed, or warn before navigating
504
+ away — Livewire's `wire:dirty` — **without shipping any client state**. The
505
+ browser already holds the last server-rendered value with zero extra bytes:
506
+ `input.defaultValue` / `defaultChecked` / `option.defaultSelected` **are** the
507
+ attributes from the last render, so **dirty = current ≠ default**, read straight
508
+ from the DOM.
509
+ - **`reactive_root(track_dirty: true)`** wires every input under the root to a
510
+ full re-scan on change; **`reactive_field(:title, dirty: true)`** opts a single
511
+ field in. On each change the client re-scans **every owned field in one pass**
512
+ (the ownership guard excludes nested reactive roots) and marks each changed
513
+ field `data-reactive-dirty="true"` and the root `data-reactive-dirty="<count>"`
514
+ (**absent at zero**). The full pass — not a per-field toggle — is required for
515
+ radio groups: the deselected radio fires no event, so only re-scanning
516
+ everything keeps its flag honest. File inputs and `contenteditable` editors
517
+ (no `default*` baseline) are out of scope in v1.
518
+ - **CSS vocabulary, zero Ruby.** `[data-reactive-dirty] .unsaved-badge { … }`
519
+ reveals a badge; `[data-reactive-dirty]` on a field outlines just the changed
520
+ control — the same pure-CSS pattern as `[data-reactive-busy]`.
521
+ - **Baselines reset on the server re-render.** A plain replace re-connects and
522
+ re-scans in `connect()`; an in-place morph keeps the element connected and
523
+ fires no Stimulus lifecycle, so a `turbo:morph-element` listener re-scans after
524
+ the morph writes fresh `default*` attributes — a `reply.morph` save clears the
525
+ markers with no reload. (`reactive:applied` fires *before* the DOM mutation, so
526
+ it is **not** a valid reset hook and is deliberately not used.)
527
+ - **`warn_unsaved: true`** arms a navigate-away guard gated on the **live** dirty
528
+ count — `beforeunload` and `turbo:before-visit`; a clean form never blocks.
529
+ Documented caveats: browsers show their own generic `beforeunload` copy, and
530
+ `turbo:before-visit` does not fire on restoration (Back/Forward) visits.
531
+ - Both `reactive_root` kwargs are **consumed before the `mix`** (otherwise an
532
+ unconsumed kwarg would render as a literal HTML attribute); `track_dirty` and
533
+ `dirty:` **token-join** their `input->reactive#trackDirty` descriptor onto any
534
+ existing `data-action` (they don't clobber it), so combining with your own
535
+ `data:`/`on(...)` still needs `mix(...)`.
536
+
537
+ - **Latency simulator dev aid — `PhlexReactive.enableLatencySim(ms)` /
538
+ `disableLatencySim()` (#102).** On localhost the click→morph round trip is
539
+ ~5 ms, so the pending/loading/optimistic affordances added in #98/#99/#100
540
+ (`aria-busy`, `disable_with:`, `busy_on`, optimistic hints) flash by too fast to
541
+ see while developing or demoing them — the reason LiveView ships
542
+ `liveSocket.enableLatencySim(ms)`. Two named exports from the client controller
543
+ (the `setConfirmResolver` precedent) persist a per-action delay to
544
+ `sessionStorage` under `"phlex-reactive:latency"`; `#perform` reads it live
545
+ right before the `fetch` — after the busy window has already opened at enqueue —
546
+ and awaits it, so the affordances become observable. The delay is session-scoped
547
+ (clears when the tab closes, so you can't leave it on across sessions), read
548
+ fresh per request (toggling takes effect on the next action with no reload), and
549
+ a one-time console banner reminds you while it's active.
550
+ - **Console handle, dev-gated.** importmap module exports are unreachable from
551
+ the DevTools console, so the bootstrap attaches
552
+ `window.PhlexReactive = { enableLatencySim, disableLatencySim }` — but **only**
553
+ when the app authored `<meta name="phlex-reactive-env" content="development">`.
554
+ The meta is app-authored (the engine can't inject into the host layout); the
555
+ install generator's initializer ships the commented snippet, and the README
556
+ documents it. Without the meta there is **no global handle** and `#perform`
557
+ short-circuits on the `null` `sessionStorage` read — **zero production
558
+ surface**. This finally lets a system spec observe `aria-busy` in a real
559
+ browser (previously impossible on a ~5 ms trip), under both Puma and Falcon.
560
+
561
+ - **Request timeout + offline handling — `reactive:error` kinds `timeout` and
562
+ `offline` (#101).** A server that never responded used to wedge a component's
563
+ request queue *forever* — each action chains on the previous one, so one hung
564
+ `fetch` froze every future action and the `finally` that clears `aria-busy` /
565
+ the loading state never ran. Going offline just fail-fast-looped each click as
566
+ `kind: "network"`. Two mechanisms close this:
567
+ - **Timeout.** The fetch is bounded by `AbortSignal.timeout(ms)` — default
568
+ **30 s**, configurable via a page-authored `<meta name="phlex-reactive-timeout">`
569
+ (same pattern as the action-path meta; no server setting). On abort it fires
570
+ `reactive:error` `kind: "timeout"` (retriable) and the queue advances, so the
571
+ component recovers. `AbortSignal.timeout()` rejects with a `TimeoutError`
572
+ `DOMException` (NOT `AbortError`), correctly distinguished from a genuine
573
+ network `TypeError` in the fetch catch.
574
+ - **Offline.** A gate at the **network boundary** (`#perform`, send time — so a
575
+ request that enqueued while online but reaches the wire after a drop is still
576
+ caught) short-circuits when `navigator.onLine === false`: it fires
577
+ `reactive:error` `kind: "offline"` (retriable) and never sends, so the edit is
578
+ not half-sent. `data-reactive-offline` is mirrored onto `<html>` (kept in sync
579
+ by the `online`/`offline` events) as a pure-CSS hook, zero app JS.
580
+ - **Explicit non-goal:** no automatic replay. A timed-out POST may have
581
+ succeeded server-side, so even manual `retry()` can double-apply a
582
+ non-idempotent action — make retryable actions idempotent or gate retry UI.
583
+ - **User-visible failure surface — render error bodies, `error_flash`,
584
+ `dismiss_after:` (#100).** A failing action used to show the user *nothing*:
585
+ the client read a non-OK body only for the console and discarded it, the
586
+ endpoint's rescue paths could log but not display anything, a network failure
587
+ had no server to render, and flashes never cleaned themselves up. Four pieces
588
+ close the gap, all opt-in and status-preserving:
589
+ - **Client renders non-OK turbo-stream bodies.** When `!response.ok` but the
590
+ Content-Type is a turbo-stream, the body is now applied (an `error_flash`, or
591
+ a plain controller's `status: :unprocessable_entity` validation reply, is
592
+ SHOWN). The root gets `data-reactive-error="<kind>"` (styleable in pure CSS),
593
+ cleared on the next success. Token safety is preserved: a 400 body never
594
+ refreshes the held identity token (`#extractToken` no-ops unless a stream
595
+ re-renders this element's id).
596
+ - **`Phlex::Reactive.error_flash`** (default `nil`) — a `->(kind) { "message" }`
597
+ lambda. When set, every rescue path (400/403/404) renders a turbo-stream flash
598
+ into `flash_target` at the **same status** it returns today. Composes with
599
+ `verbose_errors`: the flash wins the response body, the diagnostic still logs.
600
+ A lambda that raises degrades gracefully (falls back to the bare/diagnostic
601
+ body — never a 500).
602
+ - **Offline fallback** — a server-rendered `<template data-reactive-error-flash>`
603
+ the client clones into the flash region on a `network` failure (trusted
604
+ markup, cloned verbatim — no client templating).
605
+ - **`dismiss_after:` on `reply.flash`** — `reply.replace.flash(:error, msg,
606
+ dismiss_after: 4000)` self-removes the flash after the timeout via a
607
+ **document-level** handler (so it self-cleans broadcast-delivered flashes too).
608
+ Wraps string content; a verbatim Phlex component owns its own lifecycle.
609
+ - **Declarative loading states — `loading:` / `disable_with:` on `on(...)` +
610
+ `busy_on(:action)` (#99).** Between the click and the morph the UI was fully
611
+ live and unchanged: no per-trigger feedback, buttons stayed enabled, and a
612
+ rapid double-click enqueued a full duplicate POST (the queue serializes tokens,
613
+ it does not dedupe). This adds Livewire's `wire:loading` + `phx-disable-with`
614
+ without a Stimulus controller. `disable_with: "Saving…"` disables the trigger
615
+ and swaps its text while pending — and because a disabled button fires no
616
+ further clicks, a double-click now enqueues exactly **one** POST (the disable
617
+ is the dedup). `loading: { disable:, class:, text:, to: }` is the full form:
618
+ a loading class on the trigger or a `to:` target, plus disable + text. Both
619
+ apply at **enqueue** (covering the queue wait, not just the fetch) and revert
620
+ on settle — **guarded** so a disconnected trigger is skipped and a
621
+ server-rendered new label is never clobbered; the disable/text swap deliberately
622
+ do NOT apply during a `debounce:` quiet period, so a debounced `input` is not
623
+ disabled mid-typing. Independently, **every** round trip now carries an
624
+ always-on busy vocabulary for the whole enqueue→settle window — no Ruby needed:
625
+ `data-reactive-busy="<action>"` on the trigger and root (a **space-separated
626
+ set** on the root so concurrent actions don't clobber), `aria-busy` on the root
627
+ (via a **pending counter**, cleared only when the last request settles), and
628
+ `busy_on(:action)` to scope `data-reactive-busy` onto a spinner only while that
629
+ action is in flight. Style it with pure CSS (`[data-reactive-busy] .spinner { … }`).
630
+ The trigger is now captured from `event.currentTarget` (not `event.target`), so
631
+ a `<button><span>` click disables/relabels the button, not the inner span.
632
+ Emitted as `data-reactive-loading-param` (JSON) via the guarded-append pattern,
633
+ so the bare-`on()` hot path stays byte-identical. **`loading:` and
634
+ `disable_with:` are now RESERVED keyword names on `on(...)`** — like
635
+ `debounce:`/`confirm:`/`throttle:`/`optimistic:`, they can no longer be used as
636
+ free action params.
637
+ - **`optimistic:` on `on(...)` — declared, reversible visual hints (#98).**
638
+ Reactive interactions no longer wait a full round trip for their first visual
639
+ change — and a checkbox no longer fails to flip at all (the client's
640
+ unconditional `preventDefault` used to suppress the native flip until the
641
+ morph). `optimistic:` applies a small, always-reversible, **cosmetic**
642
+ vocabulary the instant the trigger fires and **reverts** it if the action
643
+ fails — Livewire's "flip it client-side, let the morph correct". Supported
644
+ hints: `toggle_class:`/`add_class:`/`remove_class:` (on the trigger, or a `to:`
645
+ selector scoped to the root), `checked: :keep` (a click-bound checkbox/radio
646
+ skips `preventDefault` so it flips natively now), and `hide: true`. Hints are
647
+ visual only (never data, never client state), applied **once per flushed
648
+ enqueue** (a debounced trigger can't flap per keystroke), and reverted from
649
+ every failure branch (redirected/http/content-type/network + client apply),
650
+ guarded by `isConnected`. On **success there is no cleanup**: a root re-render
651
+ overwrites the hint with server truth, while a reply that does not re-render
652
+ the root (`reply.remove`, streams-only) leaves it standing — that's the
653
+ `hide: true` + `reply.remove` instant-delete recipe. Emitted as
654
+ `data-reactive-optimistic-param` (JSON) following the guarded-append pattern of
655
+ `debounce:`/`confirm:`/`throttle:`, so the bare-`on()` hot path stays
656
+ byte-identical. **`optimistic:` is now a RESERVED keyword name on `on(...)`** —
657
+ like `debounce:`/`confirm:`/`throttle:`/`listnav:`, it can no longer be used as
658
+ a free action param.
659
+ - **`on_client(event, ops)` + the `js` op builder — client-side DOM commands
660
+ with ZERO round trips (#95).** Purely-visual interactions (tabs, dropdowns,
661
+ accordions, class toggles) no longer cost a signed server round trip or a
662
+ hand-written Stimulus controller. `js` is an immutable, chainable builder of
663
+ declared DOM operations — `show`/`hide`/`toggle` (the `hidden` attribute) and
664
+ `add_class`/`remove_class`/`toggle_class` — and `on_client(:click, js.…)`
665
+ binds them to a DOM event the ONE generic controller applies locally via a
666
+ new `runOps` action: **no token, no params, no fetch, ever** (the system spec
667
+ wraps `window.fetch` with a counter and asserts zero). The op vocabulary is a
668
+ fixed whitelist mirrored client-side: an unknown op name warns and is skipped
669
+ while the rest of the chain still applies (client-side default-deny —
670
+ `Object.hasOwn`-guarded so inherited Object members can't masquerade as ops),
671
+ and malformed ops JSON degrades to a no-op. Targets are CSS selectors
672
+ resolved WITHIN the component's root — nested reactive roots are never
673
+ touched (the issue-#15 ownership rule) — with `:root` for the root element
674
+ itself and a per-op `global: true` document escape. `window:`/`once:`/
675
+ `outside:` compose exactly like `on(...)`'s #80 modifiers (outside-click
676
+ closes a dropdown; window-bound triggers never `preventDefault`). Builder
677
+ validation is loud at render time (a non-selector target or an empty class
678
+ list raises; `on_client` rejects a non-`Phlex::Reactive::JS` or empty chain)
679
+ rather than silent in the browser. Client ops are EPHEMERAL UI by design: any
680
+ server re-render resets them — the LiveView JS-commands caveat, documented in
681
+ the README — so state that must survive a re-render stays a signed `action`.
682
+ Same-machine `rake bench` before/after: the token/render/coerce hot paths are
683
+ untouched (state-backed token 201k → within noise; allocations byte-identical
684
+ at 11/47 per token, 113 per render) — `on_client` is a new, separate path.
685
+ - **`js` client ops: allowlisted attribute ops, focus, dispatch, and animated
686
+ transitions (#96).** The client-only vocabulary from #95 grows to cover the
687
+ interactions apps otherwise fall back to hand-written Stimulus for. New builder
688
+ verbs: `set_attr(to, name, value)` / `remove_attr(to, name)` /
689
+ `toggle_attr(to, name)` for attribute state (the value rides as a string, so a
690
+ boolean flag is a real `"true"`, never a valueless attribute); `focus(to)` and
691
+ `focus_first(to)` (the first focusable descendant of the match — the opened-menu
692
+ → first-menuitem case); and `dispatch(name, to: nil, detail: {})`, a **bubbling
693
+ `CustomEvent`** other components/controllers can react to (raw
694
+ `element.dispatchEvent` — the shared controller SHADOWS Stimulus's `this.dispatch`
695
+ helper). `show`/`hide`/`toggle` gain a `transition: [during, from, to]` keyword:
696
+ the class lists are applied around the visibility flip and cleaned up on
697
+ `animationend`, with a `setTimeout` fallback so a non-animated element never
698
+ leaves them stuck and never hangs the chain (later ops run immediately —
699
+ cleanup is fire-and-forget). The **attribute-name allowlist is the
700
+ security-critical part, enforced on BOTH sides (two-sided default-deny)**: at
701
+ build time in `js.rb` (an offending name raises `ArgumentError`) and again in the
702
+ client interpreter (a forged/hand-built op is `console.warn`-ed and skipped, so
703
+ it can't bypass the Ruby guard). Refused, case-insensitively: event handlers
704
+ (`/\Aon/i` → XSS), the URL-bearing set (`href`, `src`, `srcdoc`, `action`,
705
+ `formaction`, `xlink:href` → a `javascript:` navigation surface), and `style`
706
+ (CSS injection). The intended surface — class ops plus `hidden`, `disabled`,
707
+ `open`, `selected`, `aria-*`, `data-*` — is documented in the README. Covered by
708
+ Ruby specs (each verb's JSON shape; the allowlist raises for every refused name,
709
+ case-insensitively), bun tests (attr ops apply; the interpret-time allowlist
710
+ warns + skips a forged op; focus lands; `dispatch` emits a bubbling event with
711
+ detail; the transition swaps `from`→`to` on the next frame and cleans up on both
712
+ `animationend` and the timeout fallback), and a system spec that opens a drawer
713
+ with a real fade, sets `aria-expanded`, and focuses the first control under Puma
714
+ AND Falcon. Refs #96.
715
+ - **`reply.js(...)` + `broadcast_js_to` — server-pushed DOM ops over a
716
+ `reactive:js` stream action (#97).** The server can now tell the client to do
717
+ something other than swap HTML — focus the next field after a save, dispatch an
718
+ app event to a toast host, add an unread badge in every viewer's tab — WITHOUT
719
+ re-rendering to make it happen. `Response#js(ops)` (surfaced on the reply facade
720
+ as `reply.<verb>.js(ops)`) chains a `reactive:js` op stream onto ANY reply via
721
+ the immutable `stream()` plumbing, and `Streamable.broadcast_js_to(*streamables,
722
+ ops, exclude:, visible_to:, target:)` pushes the SAME ops to every subscriber of
723
+ a stream over `Turbo::StreamsChannel` (Action Cable AND pgbus — transport opts
724
+ pass through `broadcast_transport_opts` like every other broadcast). `ops` is a
725
+ `js` chain (or a raw `[[op, args]]` array), interpreted by the SAME frozen
726
+ `CLIENT_OPS` whitelist as `on_client` through a THIRD custom stream action
727
+ (`registerReactiveJs`, a sibling of `reactive:visit`/`reactive:token`): an
728
+ unknown op warns + is skipped (client-side default-deny). **The ordering contract
729
+ is correctness-critical**: the op stream is emitted AFTER all render streams, so
730
+ a `focus("[name=next]")` op sees the post-render/post-morph DOM (Turbo applies
731
+ streams in document order) — a system spec proves focus lands on a freshly
732
+ morphed field under Puma AND Falcon. **The broadcast builder REJECTS focus-class
733
+ ops** (`focus`/`focus_first` → `ArgumentError`): broadcasting focus would steal
734
+ it in every subscriber's tab, so focus is an actor-reply concern only. The ops
735
+ attribute is HTML-escaped exactly like `to_stream_token` (a raw interpolation
736
+ would be an injection vector), and `reactive:js` is NOT a self-render — it never
737
+ trips `#extractToken` or `carries_token_for?`, so the reply's token refresh is
738
+ untouched (pinned by a bun test and a request spec). Covered by Ruby specs
739
+ (immutable chaining; ops-last ordering; escaping; the broadcast focus rejection;
740
+ transport-opt pass-through with the pgbus-absent path unchanged), bun tests (the
741
+ handler applies ops via the shared interpreter; `target` root scoping; token
742
+ safety), a request spec (both streams in order with the token intact), and the
743
+ focus system spec. Refs #97.
744
+
745
+ - **`on(...)` event modifiers — `window:`, `once:`, `outside:`, `throttle:`
746
+ (#80).** Four trigger patterns that previously forced a hand-written Stimulus
747
+ controller are now declarable on `on(...)`. `outside: true` fires the action
748
+ only for events whose target is OUTSIDE the component's root — the
749
+ close-a-dropdown-on-outside-click pattern; an event inside the root is a
750
+ complete client-side no-op (bailing before `preventDefault` and before the
751
+ `reactive:before-dispatch` lifecycle event). It implies `window: true`, which
752
+ binds the trigger via Stimulus's native `@window` descriptor for page-level
753
+ events (`scroll`/`resize`). Window-bound triggers are NEVER
754
+ `preventDefault`-ed — a mounted dropdown must not kill native link clicks
755
+ elsewhere on the page — and skip the forced `type="button"`; the client
756
+ decides this from `data-reactive-window-param` (emitted as an explicit
757
+ `"true"` string — Phlex renders a boolean `true` attribute VALUELESS, which
758
+ Stimulus's param reader sees as `""`, falsy). `once: true` appends Stimulus's
759
+ `:once` action option (fire at most once, then unbind). `throttle:`
760
+ (milliseconds) rate-limits LEADING-EDGE — the first event fires immediately,
761
+ further events are dropped until the window elapses — the mirror of
762
+ `debounce:` (trailing-edge); passing both raises `ArgumentError`. Suppression
763
+ timers are keyed on action + target (window scroll events all share
764
+ `event.target === document`, so two window-bound triggers on one component
765
+ must not collide) and torn down on `disconnect()`. The bare `on(:x)` emission
766
+ is pinned byte-identical by spec — the four names become RESERVED `on(...)`
767
+ kwargs, no longer usable as free action params.
768
+
769
+ - **Single include + a default `#id` for record-backed components (#81).**
770
+ `include Phlex::Reactive::Component` now pulls in
771
+ `Phlex::Reactive::Streamable` automatically (ActiveSupport::Concern's
772
+ dependency mechanism includes Streamable into the base FIRST — exactly the
773
+ manual order the old two-include ceremony established), so one include is
774
+ enough; the legacy explicit double include remains a harmless no-op. And a
775
+ record-backed component (`reactive_record :todo`) gets `#id` for free:
776
+ `dom_id(record)` via the render-context-free `Streamable#dom_id` — the id
777
+ virtually every such component wrote by hand. An explicit `def id` always
778
+ wins (normal method lookup). Deliberately NO class-name default for
779
+ state-backed components — they're frequently multi-instance, so a class-name
780
+ id would silently collide; they (and bare Streamable classes) keep the loud
781
+ `NotImplementedError`, now with a message that says how to fix it. Caveat:
782
+ two different component classes rendering the SAME record on one page
783
+ collide on the default — give one a prefixed id
784
+ (`def id = dom_id(@todo, "rich")`). The component generator emits the new
785
+ short form. Same-machine `rake bench` before/after: allocations on the
786
+ render/token hot paths are byte-identical (192 obj per `to_stream_replace`,
787
+ 100 per `render_component`, 47 per record-backed token, retained 0) and
788
+ throughput is unchanged within run-to-run noise — the default costs one
789
+ `respond_to?` + two memoized reads, and only for components that didn't
790
+ define `#id`.
791
+
792
+ - **`reactive_compute` reducers are told which field changed — one reducer can
793
+ express a multi-way/mutual rebalance (#75).** A compute reducer now receives a
794
+ second argument, `meta = { changed }`: the name of the declared input the
795
+ triggering `input` event edited, or `null` (a direct `recompute()` call, or a
796
+ target this root doesn't own / didn't declare as an input — nested reactive
797
+ roots stay excluded per #15). That's exactly what the mutual-rebalance shape
798
+ ("three fields that must always sum to a total") needs — given the same value
799
+ snapshot, the reducer branches on WHICH field is the free/derived one:
800
+
801
+ ```js
802
+ setComputeReducer("three_way_split", ({ field_a, field_b, field_c, total }, { changed }) => {
803
+ if (changed === "field_c") return { field_a: total - field_c - field_b }
804
+ return { field_c: total - field_a - field_b }
805
+ })
806
+ ```
807
+
808
+ Fully backward compatible: a one-argument reducer just ignores `meta` — no
809
+ Ruby DSL change, no markup change. One contract to know: because an output
810
+ write dispatches a real change-guarded `input` event (#76), `recompute`
811
+ re-enters with `changed` = the OUTPUT field's name (when it's also a declared
812
+ input), so a branching reducer must be **convergent** — the re-entrant pass
813
+ must compute the values already in the DOM so the change guard settles the
814
+ chain (the example above does: deriving `field_a` back from the just-written
815
+ `field_c` reproduces its current value; no write, no event, settled in one
816
+ bounce). Documented in `compute.js`'s header and the `reactive_compute` docs;
817
+ covered by bun unit tests including the issue's three-way rebalance verbatim
818
+ with a bounded reducer-call count.
819
+
820
+ - **`Phlex::Reactive.verbose_errors` — diagnostic endpoint error bodies +
821
+ dropped-param logging (#82).** An endpoint failure used to be a bare
822
+ `head 400/403/404` and a silently-dropped param left no trace — debugging
823
+ "nothing happens" meant reading the gem source. Now every failure is
824
+ warn-logged as `[phlex-reactive] …` in EVERY environment, and with
825
+ `verbose_errors` on (default `Rails.env.local?` — development AND test; off
826
+ in production) the response also carries a plain-text diagnostic body the
827
+ client already prints via `console.error`: a tampered/stale token (400,
828
+ distinguishing signature-invalid from a token class that no longer resolves
829
+ from a class that isn't reactive — `InvalidToken` now carries a `diagnostic`),
830
+ an undeclared action (403, listing the declared actions), a registered
831
+ authorization error (403, naming the error class and the action), and a
832
+ missing record (404, naming the GlobalID). Param coercion additionally logs
833
+ every dropped key with its full bracketed path and reason
834
+ (`undeclared` / `uncoercible`), hinting when a flat name looks like the
835
+ bracketed twin of a declared nested key (the #16/#21 confusion). Statuses
836
+ never change with the flag, the client needs no changes, and the production
837
+ coercion path does zero extra work (nil collector, early-return guards) —
838
+ `rake bench:one[coerce_params]` before/after is unchanged within noise
839
+
840
+ - **Client lifecycle CustomEvents — `reactive:before-dispatch` /
841
+ `reactive:applied` / `reactive:error` with `retry()` (#79).** The generic
842
+ controller now dispatches three bubbling, composed events around every action
843
+ round trip, so an app can toast an error, veto a dispatch, instrument latency,
844
+ or build retry UI without forking the one controller.
845
+ `reactive:before-dispatch` is cancelable and fires once per user gesture —
846
+ post-`preventDefault`, post-`confirm:`, PRE-debounce — with
847
+ `{ action, params, element }`; `event.preventDefault()` skips the round trip
848
+ entirely (nothing is scheduled, debounced or not). `reactive:applied` fires
849
+ with `{ action, params, html }` after the fresh token was captured and the
850
+ streams were handed to `Turbo.renderStreamMessage` (Turbo applies them
851
+ asynchronously — listen to Turbo's own events for post-morph timing).
852
+ `reactive:error` fires in every failure branch with
853
+ `{ action, params, kind, status?, body?, retry? }` where `kind` is one of
854
+ `redirected | http | content-type | network` (all retriable) or `apply` —
855
+ the fetch itself succeeded and the server already completed the mutation,
856
+ but something AFTER it threw INSIDE THE CONTROLLER (a malformed response, a
857
+ Turbo render error — NOT a throwing `reactive:applied` listener, whose
858
+ exception `dispatchEvent` never propagates back per the DOM spec, so it
859
+ can't reach this catch); `apply` carries NO `retry` at all, since retrying
860
+ would re-POST an action that already succeeded. `retry()`
861
+ (when present) re-enters the request queue — re-reading the freshest signed
862
+ token and re-collecting the fields at send time, refiring no second
863
+ before-dispatch — and no-ops with a `console.warn` once the component left
864
+ the DOM. Events go out via raw
865
+ `dispatchEvent` (Stimulus's `this.dispatch` helper is shadowed by the
866
+ controller's own `dispatch` action method) on the root element, falling back
867
+ to `document` when a plain replace detached it; the existing `console.error`
868
+ logging is unchanged. Composes with plain Stimulus listening —
869
+ `data-action="reactive:error->toast#show"` on an ancestor. Covered by unit
870
+ (JS) and real-browser system specs; README "Failure UX & lifecycle events"
871
+ and the security docs page document the contract.
872
+
11
873
  - **Combobox keyboard navigation — `on(:search, …, listnav: "[role=option]")` (#72).**
12
874
  A search/combobox trigger can now declare client-side list navigation: Arrow
13
875
  Up/Down move a highlight among the option elements IN-BROWSER (no round trip),
@@ -58,9 +920,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
58
920
  ```
59
921
 
60
922
  The generic controller runs the named reducer on `input`, writes only the
61
- declared outputs (leaving the edited field + caret alone), and fires `input` on
62
- each field it sets so a chained summary repaints matching the server's
63
- `set_value` + `dispatch("input")` contract. A missing/unregistered reducer is a
923
+ declared outputs (leaving the edited field + caret alone), and for each
924
+ output whose value actually changed dispatches a bubbling `input` event on
925
+ the field so a chained summary repaints, matching the server's `set_value` +
926
+ `dispatch("input")` contract (see #76). A missing/unregistered reducer is a
64
927
  no-op (a page never breaks because a binding wasn't wired up). When the same
65
928
  component ALSO carries `on(...)` (a persisted record, or a draft you sync), that
66
929
  debounced POST still fires and the server reply reconciles — so `reactive_compute`
@@ -127,6 +990,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
127
990
 
128
991
  ### Documentation
129
992
 
993
+ - **Corrected the broadcast render-cost mental model — "N subscribers = N
994
+ renders" was wrong (#78).** A `broadcast_*_to` call renders the component
995
+ ONCE and passes the finished `html:` to `Turbo::StreamsChannel`, so every
996
+ subscriber of that stream shares one payload (the per-subscriber cost is
997
+ transport-side). The render cost is per CALL — broadcasting one change to K
998
+ different stream keys is K builds + K renders + K token signings of
999
+ byte-identical HTML — and per-viewer rendering (`visible_to:`-style content)
1000
+ is the irreducible render-per-viewer case. Fixed in the performance docs
1001
+ page, the render micro-bench comment, and CLAUDE.md; also repointed stale
1002
+ `docs/performance.md` references to the docs app's performance page
1003
+ (`docs/app/views/docs/pages/performance.rb`). No behavior changed.
1004
+
130
1005
  - **The auto-collected-params contract, spelled out (#64, #65, #66, #67).** Four
131
1006
  gaps surfaced from building one model-scoped form (numeric fields that rebalance
132
1007
  live). No behavior changed — the README now documents what the code already
@@ -206,6 +1081,48 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
206
1081
 
207
1082
  ### Fixed
208
1083
 
1084
+ - **`reactive_compute` output writes never fired real `input` events — chained
1085
+ repaints were dead in production (#76).** The controller's `recompute()` wrote
1086
+ each output with a bare `field.value = …` under a comment claiming the write
1087
+ fires the field's `input` listeners. Only the bun-test fake's custom `value`
1088
+ setter did that — real browsers never fire `input` on a programmatic `.value`
1089
+ write — so anything listening on a computed field (a chained summary repaint,
1090
+ a second compute) silently never ran outside the test suite. The controller now
1091
+ dispatches a real bubbling `new Event("input")` after each output write, and
1092
+ the write is **change-guarded**: a field is written (and the event dispatched)
1093
+ ONLY when the reducer's value differs from the field's current value
1094
+ (String-compared). The guard is load-bearing, not an optimization — the shipped
1095
+ `payment_split` example declares overlapping inputs/outputs, so an
1096
+ unconditional dispatch would re-enter `input->reactive#recompute` forever;
1097
+ with the guard, chains settle deterministically (the re-entrant pass writes
1098
+ nothing and stops). The bun-test fake now matches real DOM (no auto-fire on
1099
+ `.value` assignment; listeners run only through the controller's explicit
1100
+ `dispatchEvent`), with unit coverage for the unchanged-write no-op, the
1101
+ single bubbling dispatch, loop termination on the payment_split shape, and a
1102
+ chained listener firing via the dispatched event — plus a real-browser system
1103
+ assertion that a derived field repaints after typing. The misleading comments
1104
+ in `reactive_controller.js` and `compute.js` now describe the real contract.
1105
+
1106
+ - **`reply.flash` discarded its level — `:error` and `:notice` emitted
1107
+ byte-identical streams (#77).** `Response.flash_stream` declared the level as
1108
+ `_level` and never used it, so an app could not style errors red without
1109
+ abandoning the flash helper — while the public API and every README example
1110
+ pass a level. String flash content is now wrapped in
1111
+ `<div class="reactive-flash reactive-flash--{level}"
1112
+ data-reactive-flash-level="{level}">…</div>`, so the level reaches the wire as
1113
+ a style hook (class) and a script/test hook (data attribute); the level is
1114
+ HTML-escaped before landing in either attribute. **This intentionally changes
1115
+ the wire output for string flashes** (previously the bare string was appended
1116
+ with no wrapper) — restyle against `.reactive-flash--{level}` if you targeted
1117
+ the raw text node. The string itself keeps the exact pre-existing injection
1118
+ contract, now applied inside the wrapper: a plain String is HTML-escaped, an
1119
+ `html_safe` String passes verbatim. Phlex **component** content still renders
1120
+ VERBATIM — byte-identical to before, no wrapper (the caller owns the markup;
1121
+ a spec pins it). New config `Phlex::Reactive.flash_component = MyFlash`
1122
+ (default nil) renders string flashes through your own component instead of
1123
+ the default wrapper — instantiated `MyFlash.new(level:, content:)` and
1124
+ rendered through the existing render path; component content bypasses it.
1125
+
209
1126
  - **`reactive_controller.js` used a relative `./confirm.js` import that 404'd under
210
1127
  importmap-rails + Propshaft — taking down every Stimulus controller on the page (#57).**
211
1128
  The #55 confirm resolver added `import { confirmResolver } from "./confirm.js"` to the
@@ -687,7 +1604,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
687
1604
  scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
688
1605
  state-backed by default or record-backed with `--record`.
689
1606
 
690
- [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.3...HEAD
1607
+ [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.9.0...HEAD
1608
+ [0.9.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.6...v0.9.0
1609
+ [0.2.6]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.5...v0.2.6
1610
+ [0.2.5]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.4...v0.2.5
691
1611
  [0.2.4]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.3...v0.2.4
692
1612
  [0.2.3]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.2...v0.2.3
693
1613
  [0.2.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.1...v0.2.2