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