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/README.md CHANGED
@@ -12,8 +12,7 @@ hand-picking Turbo Stream targets.**
12
12
 
13
13
  ```ruby
14
14
  class Counter < ApplicationComponent
15
- include Phlex::Reactive::Streamable
16
- include Phlex::Reactive::Component
15
+ include Phlex::Reactive::Component # pulls in Streamable too
17
16
 
18
17
  reactive_state :count
19
18
  action :increment
@@ -92,6 +91,14 @@ That's all for **importmap** apps: the engine mounts the action endpoint at
92
91
  `/reactive/actions` and auto-pins (and preloads) the client runtime, and the
93
92
  installer adds the eager registration below to your Stimulus entrypoint.
94
93
 
94
+ Verify the whole install any time with the doctor — it checks the route, the
95
+ Stimulus registration, CSRF, the identity verifier, and every component, printing
96
+ `✓/✗/?` with a fix for each failure:
97
+
98
+ ```bash
99
+ bin/rails phlex_reactive:doctor
100
+ ```
101
+
95
102
  <details>
96
103
  <summary>What the installer wires (or do it by hand)</summary>
97
104
 
@@ -128,8 +135,10 @@ import ReactiveController from "phlex/reactive/reactive_controller"
128
135
  application.register("reactive", ReactiveController)
129
136
  ```
130
137
 
131
- The JS ships at `app/javascript/phlex/reactive/reactive_controller.js` in the
132
- gem; point your bundler at the gem path or copy it in. See
138
+ The gem ships a prebuilt, minified `reactive_controller.min.js` (~22 KB vs the
139
+ ~106 KB commented source) with a linked sourcemap, and auto-pins it for importmap
140
+ apps — so browsers load the small file while devtools still shows the real code.
141
+ Point your bundler at the gem path or copy the `.min.js` (+ its `.map`) in. See
133
142
  [docs/installation.md](https://phlex-reactive.zoolutions.llc/docs/installation).
134
143
  </details>
135
144
 
@@ -141,7 +150,9 @@ for broadcasting.
141
150
 
142
151
  Two host-app setups make the first reactive component *silently do nothing* —
143
152
  components render, but no action ever fires, with no error pointing at the cause.
144
- The gem now logs a warning for each, but here are the fixes:
153
+ Run `bin/rails phlex_reactive:doctor` first it flags both of these (and more)
154
+ with the fix inline. The gem also logs a warning for each at boot; here are the
155
+ fixes:
145
156
 
146
157
  **A catch-all route shadows `POST /reactive/actions`.** The engine appends its
147
158
  route *after* everything in your `config/routes.rb`, so a bottom-of-file
@@ -224,15 +235,13 @@ GlobalID; the server re-finds it on each action. **Always prefer this.**
224
235
 
225
236
  ```ruby
226
237
  class Todos::Item < ApplicationComponent
227
- include Phlex::Reactive::Streamable
228
238
  include Phlex::Reactive::Component
229
239
 
230
- reactive_record :todo
240
+ reactive_record :todo # identity AND the default #id: dom_id(@todo)
231
241
  action :toggle
232
242
  action :rename, params: { title: :string }
233
243
 
234
244
  def initialize(todo:) = @todo = todo
235
- def id = dom_id(@todo) # stable per-record DOM id == Turbo target
236
245
 
237
246
  def toggle
238
247
  authorize! @todo, :update? # YOU authorize — the token only proves identity
@@ -253,6 +262,17 @@ class Todos::Item < ApplicationComponent
253
262
  end
254
263
  ```
255
264
 
265
+ > **One include, default `#id` (issue #81).** `include Phlex::Reactive::Component`
266
+ > pulls in `Streamable` automatically (the explicit two-include form still works
267
+ > and is a harmless no-op). A record-backed component also gets `#id` for free —
268
+ > `dom_id(record)`, exactly the id nearly every one wrote by hand — so `def id`
269
+ > is only needed to override it, and an explicit `def id` always wins.
270
+ > **Caveat:** two *different* component classes rendering the *same* record on
271
+ > one page both default to the same `dom_id` and collide — give one an explicit
272
+ > prefixed id: `def id = dom_id(@todo, "rich")`. State-backed components still
273
+ > must define `#id` (they're frequently multi-instance, so a class-name default
274
+ > would silently collide; the loud `NotImplementedError` stays).
275
+
256
276
  ### 2. State-backed (signed instance vars)
257
277
 
258
278
  Sign small, JSON-serializable instance vars into the token. Use it **alone** for
@@ -275,14 +295,21 @@ The [inline edit example](https://phlex-reactive.zoolutions.llc/docs/example-inl
275
295
  | Example | What it shows |
276
296
  |---|---|
277
297
  | [Counter](https://phlex-reactive.zoolutions.llc/docs/example-counter) | State-backed, the smallest reactive component |
278
- | [Payment split](https://phlex-reactive.zoolutions.llc/docs/example-payment-split) | Live sum-to-total rebalancer nested bracketed params, a disabled computed field, auto-collected siblings (#64–#67) |
298
+ | [Payment split](https://phlex-reactive.zoolutions.llc/docs/example-payment-split) | Nested bracketed params, auto-collected siblings, and a live `reactive_compute` + `reactive_text` preview (#64–#67, #104) |
279
299
  | [Cross-tab chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) | Record-backed action **+ pgbus broadcast** → live sync across tabs/browsers |
280
- | [Live todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) | Per-row components, add/toggle/rename/delete, Enter-to-add, broadcast on change |
281
- | [Inline edit](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) | Show ↔ edit mode toggle, replacing a Stimulus controller + 3 routes |
282
- | [Notifications / badges](https://phlex-reactive.zoolutions.llc/docs/example-notifications) | Pure broadcast (no client action) — a job pushes a re-render |
283
-
284
- The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase see
285
- [docs/examples/chat.md](https://phlex-reactive.zoolutions.llc/docs/example-chat).
300
+ | [Live todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) | Per-row components, add/toggle/rename/archive, optimistic toggle + delete, Enter-to-add, broadcast on change |
301
+ | [Inline edit + dirty tracking](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) | Show ↔ edit toggle plus an "Unsaved" badge + leave-guard, with zero shipped state |
302
+ | [Notifications / badges](https://phlex-reactive.zoolutions.llc/docs/example-notifications) | Pure broadcast — a background event pushes a re-render, plus a `broadcast_js_to` cross-tab pulse |
303
+ | [Reactive collections](https://phlex-reactive.zoolutions.llc/docs/example-collections) | Add/remove rows + a running count + an empty state, declared **once** with `reactive_collection`, optimistic dismiss |
304
+ | [Loading states](https://phlex-reactive.zoolutions.llc/docs/example-loading-states) | `disable_with:` + `busy_on` + `aria-busy`, with a latency toggle to make the pending window visible |
305
+ | [Client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops) | `on_client` tabs / outside-close menu / accessible drawer — zero fetches, zero custom JS |
306
+ | [Failure surface](https://phlex-reactive.zoolutions.llc/docs/example-failure) | `error_flash` + `data-reactive-error` + `dismiss_after:` — what you get for free when an action fails |
307
+ | [Team inbox (flagship)](https://phlex-reactive.zoolutions.llc/docs/example-team-inbox) | The whole toolkit in one UI: collection rows, optimistic archive that **reverts on failure**, cross-tab broadcast, an `on_client` kebab, error flashes |
308
+
309
+ Every page renders its **real** reactive component inline (source read straight
310
+ off the file), so the demo and the code can never drift. The
311
+ [Team inbox](https://phlex-reactive.zoolutions.llc/docs/example-team-inbox) is the
312
+ flagship — every feature composed into one believable UI.
286
313
 
287
314
  ---
288
315
 
@@ -292,12 +319,16 @@ The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
292
319
 
293
320
  | Method | Use |
294
321
  |---|---|
295
- | `#id` (you implement) | Stable DOM id == Turbo Stream target. Must match the root element's `id`. |
322
+ | `#id` | Stable DOM id == Turbo Stream target. Must match the root element's `id`. Record-backed components default to `dom_id(record)` (issue #81); everything else implements it (`def id`). An explicit `def id` always wins. |
296
323
  | `.replace(model = nil, morph: false, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component; `morph: true` adds `method="morph"` |
297
- | `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
324
+ | `.update(model = nil, morph: false, **opts)` | `<turbo-stream action=update target=id>` (inner-HTML update); `morph: true` adds `method="morph"` so Turbo morphs the inner HTML in place (issue #113) |
325
+ | `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
298
326
  | `.broadcast_replace_to(*streamables, model:, morph: false)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable); `morph: true` morphs in place |
299
- | `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |
300
- | `#to_stream_replace` / `#to_stream_morph` / `#to_stream_update` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `reply`); `#to_stream_morph` morphs in place |
327
+ | `.broadcast_update_to(*streamables, model:, morph: false)` | Broadcast an inner-HTML update; `morph: true` morphs in place, so a peer editing the component keeps its focus/caret (issue #113) |
328
+ | `.broadcast_append_to(*streamables, target:, model:)` / `_prepend_` / `_remove_` | The other broadcast variants |
329
+ | `.broadcast_replace_to_each(stream_keys, model:, morph: false, exclude:, visible_to:)` / `_update_` / `_append_` / `_prepend_` / `_remove_` | **Render once, fan out** the *same* payload to K different stream keys — a per-tenant loop. K renders + K HMACs collapse to 1 + K cheap channel calls (~9.5× at K=10). Each key is a `[record, :symbol]` pair (or a bare string). Transport opts + `morph:` forward per key. Per-viewer `visible_to:` content stays render-per-call. See [Broadcasting](https://phlex-reactive.zoolutions.llc/docs/broadcasting). |
330
+ | `#to_stream_replace` / `#to_stream_morph` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `reply`); `#to_stream_morph` morphs in place |
331
+ | `#to_stream_update(morph: false)` | Inner-HTML update of the *already-built* instance; `morph: true` morphs in place (issue #113) |
301
332
 
302
333
  Use in controllers: `render turbo_stream: Counter.replace(counter)`.
303
334
 
@@ -305,7 +336,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
305
336
 
306
337
  | Macro / helper | Use |
307
338
  |---|---|
308
- | `reactive_record :name` | Record-backed identity (GlobalID). State = the DB. |
339
+ | `reactive_record :name` | Record-backed identity (GlobalID). State = the DB. Also defaults `#id` to `dom_id(record)`. |
309
340
  | `reactive_state :a, :b` | Signed instance-var identity. Standalone, or combined with `reactive_record` to sign transient UI state alongside the row. |
310
341
  | `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
311
342
  | `reactive_root(**overrides)` | Spread onto the root element: emits the component `id` **and** `reactive_attrs` together, so the controller root always carries `#id`. Preferred over `id:` + `reactive_attrs`. `**overrides` (`class:`/`data:`) deep-merge. |
@@ -315,15 +346,53 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
315
346
  | `on(:action, event: "keydown.enter")` | Fire only on a specific key — Enter-to-submit / Escape-to-cancel — via Stimulus's native keyboard filter (`event:` passes straight through). See [Keyboard triggers](#keyboard-triggers-enter-to-submit--escape-to-cancel). |
316
347
  | `on(:action, confirm: "Sure?")` | Gate a destructive trigger behind a confirmation. Defaults to `window.confirm`; override the dialog with [`setConfirmResolver`](#custom-confirmation-dialogs-setconfirmresolver). |
317
348
  | `on(:search, listnav: "[role=option]")` | Add combobox keyboard navigation — Arrow keys move a client-side highlight, Enter picks (clicks the option's own trigger), Escape clears. See [Combobox keyboard navigation](#combobox-keyboard-navigation-listnav). |
349
+ | `on(:close_menu, outside: true)` | Fire only for events **outside** this component's root (close-a-dropdown-on-outside-click). Window-bound; never `preventDefault`s, so links elsewhere keep navigating. |
350
+ | `on(:track, event: "scroll", window: true, throttle: 250)` | `window:` binds the trigger to the window (page-level scroll/resize); `throttle:` rate-limits leading-edge — first event fires, the rest drop until the window elapses. Mutually exclusive with `debounce:`. |
351
+ | `on(:toggle, optimistic: { checked: :keep })` | Apply a reversible **visual hint** the instant the trigger fires (before the round trip); revert it if the action fails. Cosmetic only. See [Optimistic hints](#optimistic-visual-hints-optimistic). |
352
+ | `on(:save, disable_with: "Saving…")` | Disable the trigger + swap its text while the action is pending (a disabled button also swallows a rapid double-click). Shorthand for `loading: { disable: true, text: "Saving…" }`. See [Loading states](#declarative-loading-states-loading--disable_with). |
353
+ | `on(:save, loading: { disable: true, class: "opacity-50", text: "…" })` | Full loading form: `disable:`, a loading `class:` (on the trigger or a `to:` target), a `text:` swap. Reverts on settle. |
354
+ | `busy_on(:save)` | Mark any element so it carries `data-reactive-busy` **only while `save` is in flight** — a spinner styled with pure CSS, zero Ruby. See [Loading states](#declarative-loading-states-loading--disable_with). |
355
+ | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
356
+ | `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers. See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
357
+ | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, and `dispatch` — chainable. |
318
358
  | `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
319
359
  | `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
360
+ | `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
361
+ | `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`. The array form (`inputs: %i[a b]`) stays all-numeric. |
362
+ | `reactive_root(track_dirty: true, warn_unsaved: true)` / `reactive_field(:param, dirty: true)` | **Dirty tracking** against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-dirty--track_dirty--warn_unsaved). |
320
363
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
321
364
  | `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
322
- | `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` | Return from an action to control the reply (flash, remove, redirect, multi-stream). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
365
+ | `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` / `.js(ops)` | Return from an action to control the reply (flash, remove, redirect, multi-stream, server-pushed client ops). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
323
366
  | `reply.append(name, model)` / `.prepend(...)` / `.remove(name, model)` | Add/remove a row in a declared `reactive_collection` (row + count + empty-state in one reply). |
324
367
 
325
- Param types: `:string` (default), `:integer`, `:float`, `:boolean`, `:file`.
326
- Anything not in the schema is dropped before reaching your method.
368
+ Param types: `:string` (default), `:integer`, `:float`, `:boolean`, `:file`,
369
+ `:date`, `:datetime`, `:decimal`. Anything not in the schema is dropped before
370
+ reaching your method. `:date`/`:datetime` parse ISO8601 (a value that won't
371
+ parse is dropped — the keyword default applies); `:decimal` parses through
372
+ `BigDecimal` (dropped on a non-numeric value). The schema is **compiled once at
373
+ declaration**: a typo'd type symbol (`params: { count: :interger }`) raises
374
+ `Phlex::Reactive::UnknownParamType` at class load, not silently coercing to a
375
+ string at click time.
376
+
377
+ **Custom param types (`Phlex::Reactive.param_type`).** Register your own type in
378
+ an initializer — the block receives the raw client value and returns the coerced
379
+ value, or `Phlex::Reactive::ParamSchema::DROP` to reject it (the keyword default
380
+ then applies, keeping the drop-don't-fabricate contract):
381
+
382
+ ```ruby
383
+ # config/initializers/phlex_reactive.rb
384
+ Phlex::Reactive.param_type(:money) do |value|
385
+ /\A\d+(\.\d{1,2})?\z/.match?(value.to_s) ? BigDecimal(value) : Phlex::Reactive::ParamSchema::DROP
386
+ end
387
+
388
+ # then, in any component:
389
+ action :charge, params: { amount: :money }
390
+ def charge(amount:) = @invoice.charge!(amount) # amount is a BigDecimal or unset
391
+ ```
392
+
393
+ Register during boot only: the registry is **frozen after initialization**, so a
394
+ runtime `param_type` call raises. A schema referencing a registered type is
395
+ validated at declaration like any built-in.
327
396
 
328
397
  **File uploads (`:file`).** Declare `:file` (or `[:file]` for multiple) to accept
329
398
  an uploaded file in a reactive action — attach a document/receipt/image to the
@@ -426,6 +495,304 @@ Omit `debounce:` for the immediate-dispatch default.
426
495
  input(**mix(on(:update, event: "input", debounce: 300), name: "quantity", value: @item.quantity))
427
496
  ```
428
497
 
498
+ **Event modifiers — `outside:`, `window:`, `once:`, `throttle:`.** Four more
499
+ `on(...)` options cover the page-level trigger patterns that otherwise need a
500
+ hand-written Stimulus controller:
501
+
502
+ - `outside: true` fires the action only for events whose target is **outside**
503
+ this component's root — the close-a-dropdown-on-outside-click pattern. An
504
+ event inside the root is a complete client-side no-op. Implies `window:`.
505
+ - `window: true` binds the trigger to the window (Stimulus's native `@window`)
506
+ for page-level events like `scroll`/`resize`. Window-bound triggers are
507
+ **never `preventDefault`-ed** — a mounted dropdown must not kill link clicks
508
+ elsewhere on the page — and skip the forced `type="button"`.
509
+ - `once: true` fires at most once, then unbinds (Stimulus's `:once`).
510
+ - `throttle: 250` rate-limits **leading-edge**: the first event fires
511
+ immediately, further events are dropped until the window elapses. The mirror
512
+ of `debounce:` (trailing-edge) — passing both raises `ArgumentError`.
513
+
514
+ ```ruby
515
+ # A dropdown that closes itself on any click outside — no Stimulus controller.
516
+ div(**mix(reactive_root, on(:close_menu, outside: true))) do
517
+ button(**on(:toggle_menu)) { "Menu" }
518
+ ul { menu_items } if @open
519
+ end
520
+
521
+ # Throttled page-scroll tracking.
522
+ div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 500)))
523
+ ```
524
+
525
+ These four (like `debounce:`/`confirm:`/`listnav:`) are **reserved keyword
526
+ names** on `on(...)` — no longer usable as free action params.
527
+
528
+ ### Optimistic visual hints (`optimistic:`)
529
+
530
+ Every reactive action waits a full round trip for its visual change — and it's
531
+ worse than neutral for a checkbox: the client `preventDefault`s the trigger, so
532
+ an `on(:toggle)` checkbox never even **flips** until the morph arrives.
533
+ `optimistic:` gives Livewire's "flip it client-side, let the morph correct" (and
534
+ React's `useOptimistic`): a small, **always-reversible**, cosmetic vocabulary
535
+ applied the instant the trigger fires and **reverted** if the round trip fails.
536
+
537
+ Hints are visual **only** — never data, never a computed value (that would be
538
+ client state the DOM can't be trusted to hold). Supported ops in the hint hash:
539
+
540
+ - `toggle_class:` / `add_class:` / `remove_class:` — a class string or array,
541
+ applied to the **trigger** by default, or to a `to:` selector scoped to the
542
+ root (`to: :root` targets the root element itself).
543
+ - `checked: :keep` — for a **click-bound** checkbox/radio, the client skips its
544
+ unconditional `preventDefault` so the **native flip happens now** (a bare
545
+ toggle click has no navigation default to lose). `on(...)` also skips the
546
+ forced `type="button"` it normally adds to click triggers — that would destroy
547
+ the very checkbox being toggled — so you supply the real `type="checkbox"` /
548
+ `type="radio"`. On a `change`-bound control the flip is already native
549
+ (`change` isn't cancelable) — `:keep` then only contributes the failure revert.
550
+ - `hide: true` — hides the target immediately.
551
+
552
+ ```ruby
553
+ # A checkbox that flips instantly; the label paints in the same gesture. The
554
+ # morph reconciles from server truth; a failure snaps both back.
555
+ input(type: "checkbox", checked: @todo.done,
556
+ **mix(on(:toggle, event: "change", optimistic: { checked: :keep, toggle_class: "is-done", to: ".status" }),
557
+ name: "done"))
558
+
559
+ # Instant delete: hide the row NOW, remove it on the reply.
560
+ button(**on(:destroy, confirm: "Delete?", optimistic: { hide: true, to: :root })) { "Delete" }
561
+ ```
562
+
563
+ **The success/failure contract (load-bearing):**
564
+
565
+ - **On failure** — any branch (`redirected` / `http` / `content-type` /
566
+ `network`, plus a client-side `apply` throw) — the client replays the exact
567
+ **inverse** ops, guarded by `isConnected` (a detached row is skipped, it's
568
+ gone anyway). The hint stored on the queued request, so the serialized
569
+ per-controller queue reverts the **right** request's hint.
570
+ - **On success there is NO cleanup.** A reply that re-renders the root
571
+ **overwrites** the hint with server truth. A reply that does **not** re-render
572
+ the root (`reply.remove`, streams-only) **leaves the hint standing** — that's
573
+ the `hide: true` + `reply.remove` instant-delete recipe working as intended:
574
+ the row hides, then removes, and never flashes back.
575
+
576
+ `optimistic:` (like `debounce:`/`confirm:`/`throttle:`) is a **reserved keyword
577
+ name** on `on(...)`. An unknown hint op, a `checked:` value other than `:keep`,
578
+ or a non-hash raises at render — a dead hint fails loudly, never silently.
579
+
580
+ ### Declarative loading states (`loading:` / `disable_with:`)
581
+
582
+ Between the click and the morph, the user needs to see that something is
583
+ happening — and a mutating button needs to stop a rapid double-click from firing
584
+ twice. `loading:` / `disable_with:` are Livewire's `wire:loading` +
585
+ `phx-disable-with` and LiveView's `phx-click-loading`, without a Stimulus
586
+ controller. Both are **reserved keyword names** on `on(...)`.
587
+
588
+ The moment a request is **enqueued** — covering the queue wait, not just the
589
+ fetch — the trigger and root get the always-on busy vocabulary, and (if declared)
590
+ the loading hint applies; everything reverts when the round trip settles
591
+ (success **or** failure), guarded so a re-rendered trigger is never clobbered.
592
+
593
+ **`disable_with:` — the common case.** Disable the trigger and swap its text
594
+ while pending. A disabled button fires no further clicks, so a rapid double-click
595
+ enqueues exactly **one** POST (the queue only serializes tokens — it does not
596
+ dedupe; the disable is what dedupes):
597
+
598
+ ```ruby
599
+ button(**on(:save, disable_with: "Saving…")) { "Save" }
600
+ ```
601
+
602
+ **`loading:` — the full form.** A hash of:
603
+
604
+ - `disable: true` — disable the trigger while pending.
605
+ - `class: "…"` / `[ … ]` — a loading class on the **trigger**, or on a `to:`
606
+ selector scoped to the root (`to: :root` targets the root element itself).
607
+ - `text: "Saving…"` — swap the trigger's `textContent` while pending.
608
+
609
+ ```ruby
610
+ button(**on(:destroy, confirm: "Sure?", loading: { disable: true, class: "opacity-50 pointer-events-none" })) { "Delete" }
611
+ ```
612
+
613
+ `disable_with: "Saving…"` is the shorthand for `{ disable: true, text: "Saving…" }`
614
+ and, if you pass both, merges over an explicit `loading:` (its `text`/`disable`
615
+ win). An unknown loading key or a non-hash `loading:` raises at render.
616
+
617
+ **The `aria-busy` + `data-reactive-busy` contract (always on — zero Ruby).**
618
+ Independent of any `loading:` hint, **every** reactive round trip marks the DOM
619
+ for the whole enqueue→settle window, so you can style spinners and dimming with
620
+ pure CSS:
621
+
622
+ - the **trigger** and the **root** carry `data-reactive-busy="<action>"` (a
623
+ **space-separated set** on the root, so two concurrent actions never clobber);
624
+ - the **root** carries `aria-busy="true"` (driven by a pending counter — it
625
+ clears only when the last in-flight request settles, so overlapping actions
626
+ don't clear it early);
627
+ - `busy_on(:action)` marks any element inside the root so **it** gets
628
+ `data-reactive-busy` toggled **only while that action is in flight** — a scoped
629
+ spinner:
630
+
631
+ ```ruby
632
+ button(**on(:save, disable_with: "Saving…")) { "Save" }
633
+ span(**busy_on(:save), class: "spinner")
634
+ ```
635
+
636
+ phlex-reactive ships **no CSS** for these — you own the styling. A minimal
637
+ example (not shipped — copy into your app's stylesheet):
638
+
639
+ ```css
640
+ /* Reveal a busy_on / aria-busy spinner only while its action is in flight. */
641
+ .spinner { display: none; }
642
+ [data-reactive-busy] .spinner,
643
+ [data-reactive-busy].spinner { display: inline-block; }
644
+
645
+ /* Dim the whole component during any round trip. */
646
+ [aria-busy="true"] { opacity: 0.6; transition: opacity 120ms ease; }
647
+ ```
648
+
649
+ The disable + text swap apply **only at enqueue**, never during a `debounce:`
650
+ quiet period — so a debounced `input` trigger (whose element *is* the text field)
651
+ is not disabled mid-typing. On settle the text is restored only if it still
652
+ matches what was swapped in; a morph that rendered a **new** server label is left
653
+ alone.
654
+
655
+ ### Dirty-field tracking (`dirty:` / `track_dirty:` + `warn_unsaved:`)
656
+
657
+ Show "unsaved changes", enable **Save** only when something changed, or warn
658
+ before navigating away — Livewire's `wire:dirty` — **without shipping any client
659
+ state**. The trick: the browser already holds the last server-rendered value with
660
+ zero extra bytes. `input.defaultValue` / `defaultChecked` / `option.defaultSelected`
661
+ **are** the attributes from the last render. So **dirty = current ≠ default**, and
662
+ phlex-reactive reads that baseline straight from the DOM — nothing to snapshot,
663
+ nothing to sign.
664
+
665
+ ```ruby
666
+ div(**reactive_root(track_dirty: true, warn_unsaved: true)) do
667
+ input(**reactive_field(:title, value: @todo.title, dirty: true))
668
+ span(class: "unsaved-badge") { "Unsaved" } # shown via [data-reactive-dirty] CSS
669
+ button(**on(:save, disable_with: "Saving…")) { "Save" }
670
+ end
671
+ ```
672
+
673
+ - **`track_dirty: true`** on the root wires every input under it to a full
674
+ re-scan on change. **`dirty: true`** on a single `reactive_field` opts that one
675
+ field in (use it when only some fields should count).
676
+ - On each change the client re-scans **every field the root owns** in one pass and
677
+ marks:
678
+ - each changed field with **`data-reactive-dirty="true"`**, and
679
+ - the root with **`data-reactive-dirty="<count>"`** (the attribute is **absent
680
+ at zero** — so `[data-reactive-dirty]` matches iff the form is dirty).
681
+ - The re-scan is a **full pass**, not a per-field toggle — required for radio
682
+ groups: deselecting a radio fires no event on the *deselected* one, so only
683
+ re-scanning everything keeps its flag honest.
684
+
685
+ **The CSS vocabulary (you own the styling — phlex-reactive ships none):**
686
+
687
+ ```css
688
+ /* Reveal an "unsaved" badge only while the form has changes. */
689
+ .unsaved-badge { display: none; }
690
+ [data-reactive-dirty] .unsaved-badge { display: inline; }
691
+
692
+ /* Outline just the fields that changed. */
693
+ [data-reactive-dirty] { outline: 2px solid gold; }
694
+
695
+ /* Enable Save only when dirty (pair with a :disabled default). */
696
+ [data-reactive-dirty] button[data-testid="save"] { pointer-events: auto; opacity: 1; }
697
+ ```
698
+
699
+ **Baselines reset on the server's re-render.** A plain replace re-connects the
700
+ controller (re-scan on `connect`); an in-place **morph** keeps the element
701
+ connected and fires no Stimulus lifecycle, so the client also re-scans on
702
+ `turbo:morph-element` after the morph writes fresh `default*` attributes. So a
703
+ `reply.morph` save renders the field with the new value as its **new default**,
704
+ and the badge clears with no reload. (Turbo 8 morph preserves a focused field's
705
+ in-progress value while writing the fresh defaults — the post-morph re-scan is
706
+ what keeps the root count honest in that state.)
707
+
708
+ **`warn_unsaved: true`** arms a navigate-away guard gated on the **live** dirty
709
+ count: `beforeunload` (a real browser unload) and `turbo:before-visit` (a Turbo
710
+ in-app navigation). A clean form never blocks. **Caveats:** browsers show their
711
+ own generic `beforeunload` copy (your message string is legacy and ignored), and
712
+ `turbo:before-visit` **does not fire on restoration visits** (Back/Forward) — a
713
+ documented Turbo gap, not a phlex-reactive one.
714
+
715
+ **Out of scope (v1):** rich-text / `contenteditable` editors have no `default*`
716
+ baseline and are **not** tracked. Combining `reactive_field(dirty:)` with your own
717
+ `data:`/`on(...)` still needs `mix(...)` (a bare merge clobbers the `data-action`
718
+ the descriptor rides on — the same rule as everywhere else).
719
+
720
+ ### Client-only ops (`on_client` + `js`) — zero round trips
721
+
722
+ Not every interaction needs the server. A tab switch, a dropdown, an accordion
723
+ — purely visual state — used to mean either a wasteful signed round trip or the
724
+ very Stimulus controller this gem exists to eliminate. `on_client` binds a DOM
725
+ event to a chain of **declared DOM operations** that the one generic controller
726
+ applies locally: **no token, no params, no POST, ever.**
727
+
728
+ ```ruby
729
+ def view_template
730
+ div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true))) do
731
+ # Tabs: one op chain per tab — hide all panels, show one, restyle the tabs.
732
+ button(**on_client(:click, js.hide(".panel").show("#panel-2")
733
+ .remove_class(".tab", "active").add_class("#tab-2", "active"))) { "Tab 2" }
734
+
735
+ # A menu that opens client-side and closes on ANY outside click (the root
736
+ # carries the window-bound trigger above).
737
+ button(**on_client(:click, js.show("#menu"))) { "Menu" }
738
+ div(id: "menu", hidden: true) { menu_items }
739
+ end
740
+ end
741
+ ```
742
+
743
+ The `js` builder is immutable (each verb returns a new chain) and its
744
+ vocabulary is a fixed whitelist mirrored by the client: `show`/`hide`/`toggle`
745
+ flip the `hidden` attribute; `add_class`/`remove_class`/`toggle_class` take one
746
+ or more classes. Targets are CSS selectors resolved **within the component's
747
+ root** (nested reactive components are never touched — same ownership rule as
748
+ field collection); `:root` targets the root element itself; `global: true` on
749
+ an op escapes to the whole document. An op name the client doesn't recognize
750
+ logs a warning and is skipped — the rest of the chain still applies.
751
+
752
+ **Attributes, focus, dispatch, and transitions.** Beyond visibility and classes,
753
+ the same chain covers the rest of the client-only vocabulary:
754
+
755
+ ```ruby
756
+ button(**on_client(:click, js
757
+ .toggle("#menu", transition: %w[transition-opacity opacity-0 opacity-100])
758
+ .toggle_attr(:root, "aria-expanded") # accessible disclosure state
759
+ .focus_first("#menu") # move focus into the opened menu
760
+ .dispatch("app:menu-toggled", detail: { open: true }))) { "Menu" }
761
+ ```
762
+
763
+ - **`set_attr(to, name, value)` / `remove_attr(to, name)` / `toggle_attr(to, name)`**
764
+ mutate an attribute. The **attribute name is allowlisted, enforced twice** — at
765
+ build time in Ruby (an offending name raises) and again in the client
766
+ interpreter (a hand-built op is warned and skipped). Refused: **event handlers**
767
+ (`on*` → XSS), **URL-bearing** names (`href`, `src`, `srcdoc`, `action`,
768
+ `formaction`, `xlink:href` → a `javascript:` navigation surface), and **`style`**
769
+ (CSS injection — use classes). The **intended surface** is class ops plus
770
+ `hidden`, `disabled`, `open`, `selected`, and any `aria-*` / `data-*` attribute.
771
+ - **`focus(to)`** focuses the first match; **`focus_first(to)`** focuses the first
772
+ focusable descendant of the match (e.g. the first menuitem inside an opened
773
+ menu).
774
+ - **`dispatch(name, to: nil, detail: {})`** emits a **bubbling `CustomEvent`** so
775
+ another component or a plain Stimulus controller can react to a client-only
776
+ interaction — `to:` picks the element (default: the component root), `detail:`
777
+ is the payload.
778
+ - **`transition: [during, from, to]`** on `show`/`hide`/`toggle` animates the
779
+ visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next
780
+ frame, and the helper classes are cleaned up on `animationend` (with a timeout
781
+ fallback, so an element with no animation never leaves them stuck). The op chain
782
+ is never blocked — later ops (a `focus`, a `dispatch`) run immediately.
783
+
784
+ `window:`, `once:`, and `outside:` compose exactly like `on(...)`'s event
785
+ modifiers: the dropdown above closes on any click outside the component, and
786
+ window-bound triggers never `preventDefault`, so links elsewhere keep working.
787
+
788
+ **Client ops are ephemeral UI — the one contract to internalize.** Any server
789
+ re-render of the component (an action reply, a broadcast, a morph) rebuilds
790
+ from server state and resets whatever the ops toggled: the menu closes, the tab
791
+ snaps back. That is by design — the same caveat LiveView's JS commands carry.
792
+ For state that must survive a re-render (an edit mode, a selection the server
793
+ should know about), use a signed `action` instead; `on_client` is for state the
794
+ server should never care about.
795
+
429
796
  **Auto-collected sibling fields — the read contract.** A reactive action doesn't
430
797
  just receive its own trigger's value: the client gathers **every named control**
431
798
  in the reactive root (`input[name]`, `select[name]`, `textarea[name]`, and named
@@ -482,6 +849,53 @@ free as an ordinary action-param name (`on(:switch, key: "pgbus")` still passes
482
849
  > trigger to its own element (the field saves on Enter; a Cancel button — or the
483
850
  > field's own blur — handles Escape), as above.
484
851
 
852
+ ### Client-side computes (`reactive_compute` + `reactive_text`)
853
+
854
+ Some math should feel instant with **no round trip** — a NEW, unsaved record's
855
+ running total, a live title preview, a character counter. `reactive_compute`
856
+ declares a client-side reducer (a plain JS function registered once) that runs on
857
+ `input` and writes derived values straight into the DOM. When the component also
858
+ carries `on(...)`, the debounced POST still fires and the server reply reconciles
859
+ — the compute just paints first.
860
+
861
+ ```ruby
862
+ reactive_compute :preview,
863
+ inputs: { title: :string, qty: :number }, # typed: :string raw, :number → Number
864
+ outputs: %i[title_preview char_count] # written with no round trip
865
+
866
+ div(**mix(reactive_root, reactive_compute_attrs(:preview))) do
867
+ input(**mix(reactive_field(:title, value: @post.title),
868
+ data: { action: "input->reactive#recompute" }))
869
+ h2 { reactive_text(:title_preview, @post.title) } # a text-node output
870
+ small { reactive_text(:char_count) } # another text-node output
871
+ end
872
+ ```
873
+
874
+ ```js
875
+ // Register the reducer once at boot. Its signature is (values, { changed }).
876
+ import { setComputeReducer } from "phlex/reactive/compute"
877
+ setComputeReducer("preview", ({ title }) => ({
878
+ title_preview: title, char_count: `${title.length}/80`,
879
+ }))
880
+ ```
881
+
882
+ - **Typed inputs.** `inputs:` takes a **hash** to type each input: a `:number` is
883
+ coerced through `Number` (blank/NaN → 0 — the array-form default), a `:string`
884
+ reaches the reducer **raw** so a live text preview reads real text. The **array
885
+ form** (`inputs: %i[a b]`) stays all-numeric and byte-identical on the wire.
886
+ - **`reactive_text(:name, initial)`** mirrors a value into a **text node** via
887
+ `textContent` (XSS-safe by construction). An output whose name matches an owned
888
+ form field writes that field's `.value`; an output with **no** matching field
889
+ writes every owned `[data-reactive-text="<name>"]` node. It carries **no
890
+ `name`**, so it's never collected or POSTed as a param.
891
+ - **Reducer-less mirrors.** A declared **input** also mirrors into its own
892
+ `reactive_text(:same_name)` node on every keystroke with **no reducer at all** —
893
+ so `reactive_text(:title)` is a live field echo out of the box.
894
+ - **Seed the server render.** Your `view_template` must seed each mirror with the
895
+ same derived value the reducer would (`reactive_text(:char_count, "5/80")`), or
896
+ a later morph repaints stale text — the same reconcile contract the whole
897
+ new-vs-persisted split relies on.
898
+
485
899
  ### Combobox keyboard navigation (`listnav:`)
486
900
 
487
901
  A searchable list needs Arrow keys to move a highlight, Enter to pick, Escape to
@@ -647,14 +1061,15 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
647
1061
 
648
1062
  | Builder | Reply |
649
1063
  |---|---|
650
- | `reply.replace` / `reply.update` | re-render in place (default; `replace` is an outerHTML swap, `update` morphs inner HTML) |
651
- | `reply.morph` / `reply.replace(morph: true)` | re-render in place via Idiomorph (`method="morph"`) — preserves the focused `<input>` + caret; for per-field reactive editing (issue #28) |
1064
+ | `reply.replace` / `reply.update(morph: false)` | re-render in place (default; `replace` swaps the whole element via outerHTML, `update` swaps only the inner HTML) |
1065
+ | `reply.morph` / `reply.replace(morph: true)` / `reply.update(morph: true)` | re-render in place via Idiomorph (`method="morph"`) — preserves the focused `<input>` + caret; for per-field reactive editing (`replace` #28; `update` #113) |
652
1066
  | `.also_update(target, html:)` | also re-render a companion element by DOM id; `html` is a plain string (escaped) or a Phlex component |
653
1067
  | `.also_replace(component, morph: false)` | also re-render another Streamable component, targeting its own `#id`; `morph: true` morphs it in place |
654
- | `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped) or a Phlex component (off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
1068
+ | `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped, wrapped in a level-carrying `<div>` — see [Flash levels](#flash-levels)) or a Phlex component (rendered verbatim; off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
655
1069
  | `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |
656
1070
  | `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
657
1071
  | `reply.streams(*streams)` | **partial update** — emit exactly these streams (no full-self replace) + a tiny token-only refresh, so live inputs survive; for per-field grid editing (issue #30) |
1072
+ | `.js(ops, target: …)` | also push **client DOM ops** (focus, dispatch, class/attr toggles) over a `reactive:js` stream, applied AFTER the render — `reply.morph.js(js.focus("[name=next]"))` focuses the morphed field (issue #97) |
658
1073
  | `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |
659
1074
 
660
1075
  `.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's
@@ -664,6 +1079,79 @@ update only the targets you name) and refreshes the token via a tiny inert
664
1079
  `reactive:token` stream instead — the token rolls forward without re-rendering
665
1080
  (and clobbering) the component's live inputs.
666
1081
 
1082
+ #### Flash levels
1083
+
1084
+ The level reaches the wire (issue #77). **String** content is wrapped in a
1085
+ level-carrying `<div>`, so `:error` and `:notice` are styleable:
1086
+
1087
+ ```html
1088
+ <div class="reactive-flash reactive-flash--error" data-reactive-flash-level="error">
1089
+ Save failed
1090
+ </div>
1091
+ ```
1092
+
1093
+ Style against `.reactive-flash--{level}` (the class) and hook scripts/tests on
1094
+ `data-reactive-flash-level` (the data attribute). The string keeps the same
1095
+ injection contract as before, applied inside the wrapper: a plain string is
1096
+ HTML-escaped (a model value can't inject markup); an `html_safe` string passes
1097
+ verbatim.
1098
+
1099
+ Prefer your own markup? Two escape hatches:
1100
+
1101
+ ```ruby
1102
+ # 1. Pass a Phlex component as the content — rendered VERBATIM, no wrapper
1103
+ # (you own the markup entirely, including the level styling):
1104
+ reply.replace.flash(:error, Alert.new(level: :error, message: msg))
1105
+
1106
+ # 2. Or configure a flash component ONCE — string flashes render through it
1107
+ # (instantiated new(level:, content:)); component content still bypasses it:
1108
+ Phlex::Reactive.flash_component = MyFlash # default nil → the built-in wrapper
1109
+ ```
1110
+
1111
+ #### Server-pushed client ops (`reply.js` + `broadcast_js_to`, issue #97)
1112
+
1113
+ Sometimes the server needs the client to do something other than swap HTML —
1114
+ focus the next field after a save, dispatch an app event to a toast host, add an
1115
+ unread badge — WITHOUT re-rendering to make it happen. `reply.<verb>.js(ops)`
1116
+ chains a `reactive:js` stream carrying declared client DOM ops (the same `js`
1117
+ builder as [`on_client`](#client-only-ops-on_client--js--zero-round-trips))
1118
+ onto any reply. The op stream rides **after** the render streams, so a focus op
1119
+ sees the freshly rendered/morphed DOM:
1120
+
1121
+ ```ruby
1122
+ def save(title:)
1123
+ @todo.update!(title:)
1124
+ # Morph in place, THEN focus the next field + tell a toast host we saved:
1125
+ reply.morph.js(js.focus("[name=next_field]").dispatch("app:saved", detail: { id: @todo.id }))
1126
+ end
1127
+ ```
1128
+
1129
+ `target:` scopes op resolution on the client; it defaults to the bound
1130
+ component's id for `replace`/`morph`/`update` (so `@root` and component-relative
1131
+ selectors just work), and to document-scope for a subject-free `reply.with`.
1132
+
1133
+ The same ops broadcast to **every** subscriber of a stream over the usual
1134
+ transport (Action Cable **or** pgbus) — a background nudge to all viewers:
1135
+
1136
+ ```ruby
1137
+ # In a model/job: light up the bell in every viewer's tab, minus the actor's own.
1138
+ Notifications::Badge.broadcast_js_to(user, :alerts,
1139
+ js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
1140
+ ```
1141
+
1142
+ `broadcast_js_to` **refuses focus-class ops** (`focus`/`focus_first` raise
1143
+ `ArgumentError`): broadcasting focus would steal it in every subscriber's tab, so
1144
+ focus is an actor-reply concern only. Everything else is a fair broadcast (class
1145
+ and attribute toggles, `dispatch`). As with `on_client`, the ops are
1146
+ whitelist-interpreted client-side — an unknown op warns and is skipped — and the
1147
+ ops attribute is HTML-escaped, so a value can't break out of it. `reactive:js`
1148
+ is not a self-render: it never counts toward the token refresh, so the reply's
1149
+ signed token still rolls forward exactly as it would without the ops.
1150
+
1151
+ > **Ephemeral by design.** Like `on_client`, server-pushed ops are transient UI:
1152
+ > the next server re-render of the component resets whatever they toggled. State
1153
+ > that must survive a re-render belongs in a signed `action`, not an op.
1154
+
667
1155
  #### Record-authorized, transient-state actions (issue #64)
668
1156
 
669
1157
  A `reactive_record` component isn't obligated to persist or broadcast — the
@@ -677,7 +1165,6 @@ the params, stream a partial update, touch neither the DB nor peer tabs" action:
677
1165
 
678
1166
  ```ruby
679
1167
  class Invoice::PaymentFields < ApplicationComponent
680
- include Phlex::Reactive::Streamable
681
1168
  include Phlex::Reactive::Component
682
1169
 
683
1170
  reactive_record :invoice # identity + authorization ONLY — not persisted here
@@ -708,6 +1195,229 @@ aren't clobbered. Authorize the record as always — identity is never permissio
708
1195
  > rendered and auto-escaped through the renderer — or an `html_safe` string for
709
1196
  > raw HTML you control.
710
1197
 
1198
+ ### Failure UX & lifecycle events
1199
+
1200
+ The generic controller dispatches three bubbling, composed `CustomEvent`s
1201
+ around every action round trip, so an app can toast an error, instrument
1202
+ latency, veto a dispatch, or build retry UI **without forking the controller**:
1203
+
1204
+ | Event | When | `event.detail` |
1205
+ |-------|------|----------------|
1206
+ | `reactive:before-dispatch` | after the trigger's `preventDefault`/`confirm:`, **before** debounce/enqueue | `{ action, params, element }` — cancelable: `event.preventDefault()` skips the round trip entirely (nothing is scheduled) |
1207
+ | `reactive:applied` | after the response's token was captured and the streams were handed to `Turbo.renderStreamMessage` | `{ action, params, html }` |
1208
+ | `reactive:error` | in every failure branch of the round trip | `{ action, params, kind, status?, body?, retry }` |
1209
+
1210
+ `reactive:error`'s `kind` tells you **what** failed:
1211
+
1212
+ | `kind` | Meaning | Extra detail |
1213
+ |--------|---------|--------------|
1214
+ | `redirected` | the POST was redirected (an auth `before_action` / CSRF guard bounced it) | `status`, `retry` |
1215
+ | `http` | non-2xx response (403 default-deny/authorization, 400 bad token, 404 record gone, 500 …) | `status`, `body`, `retry` |
1216
+ | `content-type` | 200, but not a turbo-stream (an HTML error page, a misconfigured route) | `status`, `retry` |
1217
+ | `timeout` | the request took longer than the configured window (default 30s) and was aborted — the server may or may not have finished | `retry` |
1218
+ | `offline` | the browser was offline (`navigator.onLine === false`) when the action fired — the fetch was never sent | `retry` |
1219
+ | `network` | `fetch` itself rejected (DNS, connection reset, an interface drop mid-flight) — the server never saw the request | `retry` |
1220
+ | `apply` | the server processed the action successfully, but something AFTER the fetch threw (a malformed response, a Turbo render error) | no `retry` |
1221
+
1222
+ `apply` covers a throw in the controller's own post-fetch code — not a
1223
+ throwing listener on `reactive:applied` itself. Per the DOM spec,
1224
+ `EventTarget#dispatchEvent` never propagates a listener's exception back to
1225
+ its caller (it's reported to the console instead), so a listener that throws
1226
+ can't surface as `reactive:error` at all — it just logs and the round trip is
1227
+ otherwise unaffected.
1228
+
1229
+ `detail.retry()` re-enters the controller's request queue: it re-reads the
1230
+ **freshest** signed token and re-collects the component's fields at send time,
1231
+ so nothing stale is replayed. It fires no second `reactive:before-dispatch`
1232
+ (one veto per user gesture), and it no-ops with a `console.warn` once the
1233
+ component has left the DOM. The existing `console.error` logging is unchanged —
1234
+ the events add hooks, they don't replace the log.
1235
+
1236
+ **`kind: "apply"` carries no `retry()` at all** — by the time this fires the
1237
+ server has already completed the mutation, so retrying would re-POST an
1238
+ action that already succeeded (potentially a non-idempotent one). Every kind
1239
+ EXCEPT `apply` is retriable.
1240
+
1241
+ #### Request timeout (`kind: "timeout"`)
1242
+
1243
+ A server that never responds used to wedge a component's request queue forever
1244
+ (each action chains on the previous one) — the spinner never cleared and every
1245
+ later action froze. Now the fetch is bounded by `AbortSignal.timeout`: after the
1246
+ window (default **30 s**) it aborts, fires `reactive:error` `kind: "timeout"`,
1247
+ and the queue advances so the component keeps working. Configure the window with
1248
+ a page-stable meta (app-authored, following the same pattern as the action path):
1249
+
1250
+ ```erb
1251
+ <meta name="phlex-reactive-timeout" content="15000"> <%# 15s #%>
1252
+ ```
1253
+
1254
+ > **Non-goal — no automatic replay.** A timed-out POST **may have succeeded
1255
+ > server-side** (the server just answered too late). phlex-reactive never
1256
+ > auto-replays a request, and even a manual `retry()` can double-apply a
1257
+ > non-idempotent action. Make retryable actions idempotent, or gate your retry
1258
+ > UI accordingly.
1259
+
1260
+ #### Offline (`kind: "offline"`)
1261
+
1262
+ When the browser is offline (`navigator.onLine === false`) at send time, the
1263
+ action short-circuits **before the fetch** — the edit is never half-sent — and
1264
+ fires `reactive:error` `kind: "offline"` with a `retry()`. The check lives at the
1265
+ network boundary, so a request that enqueued while online but reaches the wire
1266
+ after a connection drop is reported as `offline`, not `network`.
1267
+
1268
+ `phlex-reactive` also mirrors `data-reactive-offline` onto `<html>` whenever the
1269
+ browser goes offline (kept in sync by the `online`/`offline` events) — a **pure
1270
+ CSS hook**, zero app JS:
1271
+
1272
+ ```css
1273
+ [data-reactive-offline] .save-button { opacity: .5; pointer-events: none }
1274
+ [data-reactive-offline] .offline-banner { display: block }
1275
+ ```
1276
+
1277
+ ```js
1278
+ // Auto-retry a specific action when the connection returns:
1279
+ document.addEventListener("reactive:error", (e) => {
1280
+ if (e.detail.kind === "offline") {
1281
+ addEventListener("online", () => e.detail.retry(), { once: true })
1282
+ }
1283
+ })
1284
+ ```
1285
+
1286
+ #### Latency simulator (development aid)
1287
+
1288
+ On localhost the click→morph round trip is **~5 ms**, so the pending affordances
1289
+ you just wired — `aria-busy`, `disable_with:`, `busy_on`, optimistic hints —
1290
+ flash by too fast to actually *see* while developing or demoing them. That's the
1291
+ same problem LiveView solves with `liveSocket.enableLatencySim(ms)`.
1292
+
1293
+ `phlex-reactive` ships the equivalent. Because importmap module exports aren't
1294
+ reachable from the DevTools console, the two functions are exposed on a
1295
+ `window.PhlexReactive` handle — but **only** when your layout opts in with a
1296
+ development-gated meta:
1297
+
1298
+ ```erb
1299
+ <%# app/views/layouts/application.html.erb, inside <head> — DEVELOPMENT ONLY %>
1300
+ <%= tag.meta(name: "phlex-reactive-env", content: "development") if Rails.env.development? %>
1301
+ ```
1302
+
1303
+ Then, from the browser console:
1304
+
1305
+ ```js
1306
+ PhlexReactive.enableLatencySim(400) // delay EVERY action by 400ms
1307
+ // …click around; aria-busy, spinners, disable_with, optimistic hints are now visible…
1308
+ PhlexReactive.disableLatencySim() // back to full speed
1309
+ ```
1310
+
1311
+ The delay is read live before each fetch (so toggling takes effect on the very
1312
+ next action, no reload) and persists to `sessionStorage` — it clears when the tab
1313
+ closes, so you can't accidentally leave it on across sessions. A one-time console
1314
+ banner reminds you while it's active.
1315
+
1316
+ Without the meta there is **no global handle at all** and the per-request read
1317
+ short-circuits on a `null` — **zero production surface**. It's purely a
1318
+ development convenience, gated by markup you author.
1319
+
1320
+ The events bubble from the component's root element (or from `document` when
1321
+ the root was detached by the failing round trip), so they compose with plain
1322
+ Stimulus listening — a global toaster is one attribute on an ancestor:
1323
+
1324
+ ```html
1325
+ <body data-controller="toast" data-action="reactive:error->toast#show">
1326
+ ```
1327
+
1328
+ ```js
1329
+ // toast_controller.js
1330
+ show(event) {
1331
+ const { kind, status, retry } = event.detail
1332
+ this.flash(`Action failed (${kind}${status ? ` ${status}` : ""})`, { onRetry: retry })
1333
+ }
1334
+ ```
1335
+
1336
+ Or veto/instrument at the document level:
1337
+
1338
+ ```js
1339
+ document.addEventListener("reactive:before-dispatch", (event) => {
1340
+ if (offline) event.preventDefault() // cancel: nothing is enqueued
1341
+ })
1342
+ document.addEventListener("reactive:applied", ({ detail }) => {
1343
+ metrics.count(`reactive.${detail.action}.ok`)
1344
+ })
1345
+ ```
1346
+
1347
+ One honest caveat on timing: `reactive:applied` means the turbo-streams were
1348
+ **handed to Turbo** — `renderStreamMessage` applies them asynchronously, so the
1349
+ DOM mutation may complete a tick later. If you need post-morph timing, listen
1350
+ to Turbo's own events (`turbo:before-stream-render` and friends).
1351
+
1352
+ #### Showing the user a failure (not just the console)
1353
+
1354
+ The events above are the *hook* — but a user who just wants to see "that
1355
+ didn't work" shouldn't have to write a toast controller. There are three
1356
+ built-in ways to surface a failure, cheapest first:
1357
+
1358
+ **1. In-action validation replies (already works).** For a failure your action
1359
+ *knows about* — a validation error, a business rule — return a flash directly.
1360
+ It renders at **200** (a normal reply, not an error):
1361
+
1362
+ ```ruby
1363
+ def rename(title:)
1364
+ return reply.replace.flash(:error, "Title can't be blank") if title.blank?
1365
+ @todo.update!(title:)
1366
+ end
1367
+ ```
1368
+
1369
+ **2. `Phlex::Reactive.error_flash` — server-rendered flashes on endpoint
1370
+ failures.** For the failures the *endpoint* catches (bad token, default-deny,
1371
+ authorization, missing record — the 400/403/404 rescue paths), set a lambda and
1372
+ every one renders a turbo-stream flash the user sees, at the **same status** it
1373
+ already returns (statuses never change):
1374
+
1375
+ ```ruby
1376
+ # config/initializers/phlex_reactive.rb
1377
+ Phlex::Reactive.error_flash = ->(kind) { "Something went wrong (#{kind})." }
1378
+ ```
1379
+
1380
+ The client now **renders non-OK turbo-stream bodies** (previously it read the
1381
+ body only for the console and discarded it), so an `error_flash` — or a plain
1382
+ controller replying `status: :unprocessable_entity` with a turbo-stream flash —
1383
+ lands in your flash region. The failing component's root also gets
1384
+ `data-reactive-error="<kind>"`, so you can style it in **pure CSS** with zero JS,
1385
+ and the next successful action clears it:
1386
+
1387
+ ```css
1388
+ [data-reactive-error] { outline: 2px solid var(--danger); }
1389
+ ```
1390
+
1391
+ > **Note.** A 400 (invalid token) reply never refreshes the client's held token
1392
+ > — the identity token is not a nonce, it stays retry-valid. The client only
1393
+ > adopts a fresh token from a body that re-renders *this* element's id, so a
1394
+ > foreign/error body can't swap it out.
1395
+
1396
+ **3. Offline fallback (no server to render anything).** A `network` failure
1397
+ reached no server, so there's nothing to render. Opt in with a server-rendered
1398
+ `<template>` in your layout — on a network failure the client clones it into the
1399
+ flash region (it's your trusted markup, cloned verbatim — no client templating):
1400
+
1401
+ ```erb
1402
+ <template data-reactive-error-flash>
1403
+ <div class="reactive-flash reactive-flash--error">You appear to be offline.</div>
1404
+ </template>
1405
+ ```
1406
+
1407
+ #### Self-dismissing flashes (`dismiss_after:`)
1408
+
1409
+ A flash that never cleans itself up piles up. Pass `dismiss_after:` (ms) and the
1410
+ flash removes itself after the timeout — driven by a **document-level** handler,
1411
+ so it self-cleans both reply-delivered and **broadcast-delivered** flashes (the
1412
+ flash container is a plain host-app div with no controller attached):
1413
+
1414
+ ```ruby
1415
+ reply.replace.flash(:error, "Couldn't save — try again", dismiss_after: 4000)
1416
+ ```
1417
+
1418
+ It wraps string content with `data-reactive-dismiss-after="4000"`; a verbatim
1419
+ Phlex component owns its own lifecycle and is left untouched.
1420
+
711
1421
  ### Reactive collections (add/remove rows + count + empty-state)
712
1422
 
713
1423
  An add/remove-row list — line items, attachments, tags, comments, a
@@ -722,7 +1432,6 @@ Declare the collection on the container component, then `reply.append` /
722
1432
 
723
1433
  ```ruby
724
1434
  class NotificationsList < ApplicationComponent
725
- include Phlex::Reactive::Streamable
726
1435
  include Phlex::Reactive::Component
727
1436
 
728
1437
  reactive_collection :notifications,
@@ -794,14 +1503,77 @@ Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(ENV["REACTIVE_KEY"
794
1503
 
795
1504
  # Change the endpoint path (default "/reactive/actions"):
796
1505
  Phlex::Reactive.action_path = "/_r/actions"
1506
+
1507
+ # Diagnostic error bodies + dropped-param logging (default: Rails.env.local? —
1508
+ # on in development AND test, off in production):
1509
+ Phlex::Reactive.verbose_errors = true
1510
+
1511
+ # User-visible flash on endpoint failures (default nil = off). When set, every
1512
+ # rescue path (400/403/404) ALSO renders a turbo-stream flash the user sees — at
1513
+ # the SAME status it returns today (statuses never change). The lambda receives
1514
+ # the failure kind (:tampered/:unknown_class/:not_reactive_class/:forbidden/
1515
+ # :not_found), so you can map it to a friendly message:
1516
+ Phlex::Reactive.error_flash = ->(kind) do
1517
+ case kind
1518
+ when :not_found then "That item is no longer available."
1519
+ when :forbidden then "You don't have permission to do that."
1520
+ else "Something went wrong — please try again."
1521
+ end
1522
+ end
1523
+
1524
+ # Component-aware wrapper around every action (audit / rate-limit / assert).
1525
+ # Sees the resolved component, action name, and COERCED params; runs inside
1526
+ # the connection-id scope but OUTSIDE the transaction. See "Two seams" below.
1527
+ Phlex::Reactive.around_action do |ctx, &action|
1528
+ RateLimiter.check!(ctx.request.remote_ip, ctx.action_name) # raise -> 403
1529
+ result = action.call
1530
+ AuditLog.record!(actor: Current.user, action: ctx.action_name)
1531
+ result # <- REQUIRED: return the continuation's value
1532
+ end
797
1533
  ```
798
1534
 
1535
+ #### Two seams: HTTP-layer (`base_controller_name`) vs component-layer (`around_action`)
1536
+
1537
+ There are two places to wrap a reactive action, and they see different things:
1538
+
1539
+ | | Base controller (`base_controller_name`) | `Phlex::Reactive.around_action` |
1540
+ |---|---|---|
1541
+ | **Layer** | HTTP request | the resolved component action |
1542
+ | **Sees** | headers, session, `request` | the component instance, action name, **coerced** params, `request` |
1543
+ | **Runs** | full Rails filter chain | inside `with_connection_id`, **outside** the transaction |
1544
+ | **Use for** | auth, CSRF, coarse per-IP rate limiting | audit logging, component-aware rate limiting, assertions |
1545
+
1546
+ Plain Rails `around_action` / `rate_limit` on a dedicated base controller already
1547
+ covers attributes, authentication, and coarse per-IP throttling — but that layer
1548
+ never sees the resolved component, the declared action name, or the coerced
1549
+ params, and can't sit *inside* the connection-id scope yet *outside* the action's
1550
+ transaction. `Phlex::Reactive.around_action` is that component-aware seam. `ctx` is
1551
+ a frozen `Phlex::Reactive::ActionContext` (`component`, `action_name`, `params`,
1552
+ `request`); the fold runs *after* token verify, default-deny, and param coercion,
1553
+ so a wrapper can never widen what's invokable.
1554
+
1555
+ **Contract — each wrapper MUST return `action.call`'s value.** The endpoint
1556
+ type-checks the action's return for a `Phlex::Reactive::Response`; a wrapper that
1557
+ ends on its logger's return value instead silently downgrades every reply to the
1558
+ implicit self-replace. A wrapper raising a registered `authorization_errors` error
1559
+ renders as 403; an unregistered raise is a 500. Multiple wrappers nest in
1560
+ registration order (last-registered outermost). Tests reset the stack with
1561
+ `Phlex::Reactive.reset_around_actions!`.
1562
+
799
1563
  If you set a custom `action_path`, expose it to the client:
800
1564
 
801
1565
  ```erb
802
1566
  <meta name="phlex-reactive-action-path" content="<%= Phlex::Reactive.action_path %>">
803
1567
  ```
804
1568
 
1569
+ The client request timeout (default 30 s) is likewise an app-authored meta —
1570
+ there is no server-side setting, so drop it in your layout head if 30 s is wrong
1571
+ for your slowest action:
1572
+
1573
+ ```erb
1574
+ <meta name="phlex-reactive-timeout" content="15000"> <%# 15s, in ms #%>
1575
+ ```
1576
+
805
1577
  ---
806
1578
 
807
1579
  ## Security
@@ -827,6 +1599,60 @@ real, so read this once.
827
1599
  but if you have *public* reactive components, ensure the action path isn't
828
1600
  force-redirected to a login page for logged-out users.
829
1601
 
1602
+ ### Token payload versioning
1603
+
1604
+ The signed identity payload carries a version (`"v"`,
1605
+ `Phlex::Reactive::TOKEN_VERSION`) so a future change to the token *shape* can
1606
+ **upgrade tokens already in flight** instead of breaking every open page at
1607
+ deploy. When you change the shape, bump `TOKEN_VERSION` and register an upgrader:
1608
+
1609
+ ```ruby
1610
+ # config/initializers/phlex_reactive.rb
1611
+ Phlex::Reactive.register_token_upgrader(0) do |payload|
1612
+ payload.merge("gid" => rewrite_old_gid(payload["gid"]))
1613
+ end
1614
+ ```
1615
+
1616
+ On verify, the payload runs through the upgrader chain (oldest → current) before
1617
+ your component rebuilds from it. A pre-versioning token (no `"v"`) is read as-is
1618
+ — introducing versioning invalidated nothing. It **fails closed**: a token signed
1619
+ by a *newer* deploy than the running code (a rollback) verifies its signature but
1620
+ carries an unknown version, so `Phlex::Reactive.verify` returns `nil` and the
1621
+ endpoint answers 400 — never guessing a shape it doesn't understand. See
1622
+ [the security guide](https://phlex-reactive.zoolutions.llc/docs/security#token-lifetime-rotation)
1623
+ for depth.
1624
+
1625
+ ### Debugging endpoint failures (`verbose_errors`)
1626
+
1627
+ Every endpoint failure is warn-logged as `[phlex-reactive] …` in **every**
1628
+ environment. With `Phlex::Reactive.verbose_errors` on (the default in
1629
+ development and test via `Rails.env.local?`; off in production), the failure
1630
+ response ALSO carries a plain-text diagnostic body — the client already prints
1631
+ it via `console.error` — and param coercion warn-logs every dropped key with
1632
+ its full bracketed path and reason (`undeclared` / `uncoercible`), including a
1633
+ hint when a flat name looks like the bracketed twin of a declared nested key
1634
+ (or vice versa). What each status means:
1635
+
1636
+ - **400** — token signature invalid (stale token from before a deploy?
1637
+ `secret_key_base` mismatch?), a token class that no longer resolves, or a
1638
+ class that resolved but doesn't include `Phlex::Reactive::Component`
1639
+ - **403** — an undeclared action (the body lists the declared actions) or a
1640
+ registered authorization error raised inside the action
1641
+ - **404** — the signed GlobalID no longer resolves (record deleted)
1642
+
1643
+ The flag never changes a status — only the body and the coercion log.
1644
+
1645
+ The same flag also makes `on(:typo)` fail loudly at **render** time: when
1646
+ `verbose_errors` is on, `on(:name)` raises `Phlex::Reactive::Error` (listing the
1647
+ declared actions) if `:name` isn't declared on that component — so a misspelled
1648
+ or forgotten `action` surfaces the moment you load the page in dev/test, instead
1649
+ of as an unexplained 403 on click. Production (flag off) keeps the permissive
1650
+ emit, so a stale page after a deploy that removed an action never 500s on render.
1651
+ A component that declares no actions of its own (a cross-component dispatch
1652
+ helper — a child row rendering a trigger for its container's action) is skipped;
1653
+ `on_client` triggers are never checked (they aren't declared actions). The
1654
+ server's default-deny stays the security boundary — this is a dev-time courtesy.
1655
+
830
1656
  See [docs/security.md](https://phlex-reactive.zoolutions.llc/docs/security) for the threat model and a checklist.
831
1657
 
832
1658
  ---
@@ -881,15 +1707,13 @@ end
881
1707
 
882
1708
  ```ruby
883
1709
  class Counter < ApplicationComponent
884
- include Phlex::Reactive::Streamable
885
1710
  include Phlex::Reactive::Component
886
1711
 
887
- reactive_record :counter
1712
+ reactive_record :counter # also defaults #id to dom_id(@counter)
888
1713
  action :increment
889
1714
  action :decrement
890
1715
 
891
1716
  def initialize(counter:) = @counter = counter
892
- def id = dom_id(@counter)
893
1717
 
894
1718
  def increment = @counter.increment!(:value)
895
1719
  def decrement = @counter.decrement!(:value)
@@ -936,19 +1760,149 @@ See [docs/broadcasting.md](https://phlex-reactive.zoolutions.llc/docs/broadcasti
936
1760
 
937
1761
  ---
938
1762
 
1763
+ ## Observability
1764
+
1765
+ The hot paths emit `ActiveSupport::Notifications` events, so an APM (AppSignal,
1766
+ Datadog, Skylight) sees reactive traffic at the **component level** — which
1767
+ component/action a slow request was, render time, and broadcast fan-out. Three
1768
+ events, all under the `phlex_reactive` namespace:
1769
+
1770
+ | Event | Fires | Payload |
1771
+ |-------|-------|---------|
1772
+ | `action.phlex_reactive` | once per request | `component`, `action`, `outcome` (`ok`/`denied_undeclared`/`invalid_token`/`not_found`/`unauthorized`) |
1773
+ | `render.phlex_reactive` | per component render | `component`, `bytesize` |
1774
+ | `broadcast.phlex_reactive` | per `broadcast_*_to` (Action Cable **and** pgbus) | `component`, `stream_action`, `streamables` |
1775
+
1776
+ Payloads carry **names, the outcome, and sizes only** — never the token, params,
1777
+ or state, so an event can't leak a secret. Subscribe from an initializer:
1778
+
1779
+ ```ruby
1780
+ ActiveSupport::Notifications.subscribe("action.phlex_reactive") do |*args|
1781
+ event = ActiveSupport::Notifications::Event.new(*args)
1782
+ MyAPM.record("reactive.#{event.payload[:outcome]}", event.duration,
1783
+ component: event.payload[:component], action: event.payload[:action])
1784
+ end
1785
+ ```
1786
+
1787
+ To watch reactive traffic in your own log without an APM, flip on the bundled
1788
+ `LogSubscriber` (default off) — one compact line per event at DEBUG:
1789
+
1790
+ ```ruby
1791
+ # config/initializers/phlex_reactive.rb
1792
+ Phlex::Reactive.log_events = true
1793
+ # [reactive] Counter#increment ok (3.1ms)
1794
+ # [reactive] render Counter 512B (0.9ms)
1795
+ # [reactive] broadcast replace Counter →2 (1.4ms)
1796
+ ```
1797
+
1798
+ The events fire whether or not you enable the LogSubscriber; the flag only
1799
+ controls the gem's own log lines. See
1800
+ [docs/performance.md](https://phlex-reactive.zoolutions.llc/docs/performance).
1801
+
1802
+ ### Client debug mode (devtools-lite)
1803
+
1804
+ The `LogSubscriber` above is the **server** lens. The client lens is
1805
+ `console.error` on a failure plus the [lifecycle events](#failure-ux--lifecycle-events)
1806
+ — but on the *successful-but-wrong* path (which streams arrived? did a token
1807
+ refresh come?) there was nothing to see. `Phlex::Reactive.debug` fills that gap:
1808
+
1809
+ ```ruby
1810
+ # config/initializers/phlex_reactive.rb
1811
+ Phlex::Reactive.debug = Rails.env.development?
1812
+ ```
1813
+
1814
+ With it on, every reactive root carries `data-reactive-debug="true"` and the
1815
+ generic controller `console.group`s **every dispatch** in the browser:
1816
+
1817
+ ```text
1818
+ ▼ reactive #todo_42 rename → 200 (48ms)
1819
+ params: [title] + collected: [title]
1820
+ encoding: json
1821
+ streams: replace → #todo_42
1822
+ token: refreshed ✓
1823
+ ```
1824
+
1825
+ The trace carries **names and outcomes only** — the explicit param names and the
1826
+ collected sibling-field names (never their **values**, which may be sensitive),
1827
+ the request encoding (`json`/`multipart`), the HTTP status, the response's stream
1828
+ actions + targets, whether a token refresh arrived (**never the token value**),
1829
+ and the round-trip time. Off (the default) it does nothing — a single attribute
1830
+ check per dispatch, no string building — so it is safe to leave gated on
1831
+ `Rails.env.development?`.
1832
+
1833
+ ---
1834
+
1835
+ ## Testing
1836
+
1837
+ `Phlex::Reactive::TestHelpers` is the public test surface — mix it in once and
1838
+ never reach for a private method or a hand-rolled POST:
1839
+
1840
+ ```ruby
1841
+ # spec/rails_helper.rb
1842
+ RSpec.configure do |c|
1843
+ c.include Phlex::Reactive::TestHelpers # run_reactive + matchers
1844
+ c.include Phlex::Reactive::TestHelpers, type: :request # + the HTTP helpers
1845
+ end
1846
+ ```
1847
+
1848
+ **`run_reactive` — the no-HTTP action driver.** It runs the action through the
1849
+ SAME contract the endpoint enforces — default-deny, the signed identity
1850
+ round-trip (a record-backed component's row is re-found), schema coercion, the
1851
+ transaction wrapper — with no HTTP, and returns a `Result`. So a unit test can't
1852
+ pass on a component that would fail a real click:
1853
+
1854
+ ```ruby
1855
+ result = run_reactive(Counter.new(count: 0), :set, count: "42") # client sends strings
1856
+ expect(result).to have_reactive_replace("counter")
1857
+ expect(result.component.instance_variable_get(:@count)).to eq(42) # :integer, cast
1858
+
1859
+ # default-deny, deleted-record, and authorization all surface as the real failures:
1860
+ expect { run_reactive(Counter.new(count: 0), :drop_table) }
1861
+ .to raise_error(Phlex::Reactive::TestHelpers::UndeclaredReactiveAction)
1862
+ ```
1863
+
1864
+ `Result` answers `replace?` / `remove?` / `redirect?` / `redirect_url` /
1865
+ `streams` / `response`, plus `component` (the instance rebuilt from identity, the
1866
+ one the action ran against). A registered authorization error **raises** (the
1867
+ endpoint maps it to 403). Matchers: `have_reactive_replace`,
1868
+ `have_reactive_remove`, `have_reactive_token_for` — the last pins the token
1869
+ refresh so a reply that would silently break the next click fails your test.
1870
+
1871
+ **HTTP helpers** — `post_reactive_action(component_or_class, act, params:, payload:)`
1872
+ and `post_reactive_multipart(...)` POST a signed token to
1873
+ `Phlex::Reactive.action_path` exactly as the client does. **Token minting** —
1874
+ `reactive_token_for(component_or_class, payload = {})`.
1875
+
1876
+ > `verbose_errors` defaults ON in test (it changes only an error BODY, never a
1877
+ > status). Asserting an empty failure body? Set
1878
+ > `Phlex::Reactive.verbose_errors = false` in your setup.
1879
+
1880
+ See [the testing guide](https://phlex-reactive.zoolutions.llc/docs/testing) for
1881
+ the full layer-by-layer walkthrough.
1882
+
1883
+ ---
1884
+
939
1885
  ## Documentation
940
1886
 
941
1887
  - [Installation & bundler setups](https://phlex-reactive.zoolutions.llc/docs/installation)
942
1888
  - [Mental model & architecture](https://phlex-reactive.zoolutions.llc/docs/architecture)
1889
+ - [Actions & events (the `on(...)` API)](https://phlex-reactive.zoolutions.llc/docs/actions-events)
943
1890
  - [Security & threat model](https://phlex-reactive.zoolutions.llc/docs/security)
944
1891
  - [Broadcasting & live updates](https://phlex-reactive.zoolutions.llc/docs/broadcasting)
945
1892
  - [Transport: pgbus vs Action Cable](https://phlex-reactive.zoolutions.llc/docs/transport-pgbus)
946
1893
  - [Testing reactive components](https://phlex-reactive.zoolutions.llc/docs/testing)
947
1894
  - [Performance & benchmarking](https://phlex-reactive.zoolutions.llc/docs/performance)
948
1895
  - Examples: [counter](https://phlex-reactive.zoolutions.llc/docs/example-counter) ·
1896
+ [payment split](https://phlex-reactive.zoolutions.llc/docs/example-payment-split) ·
949
1897
  [chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) · [todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) ·
950
1898
  [inline edit](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) ·
951
- [notifications](https://phlex-reactive.zoolutions.llc/docs/example-notifications)
1899
+ [notifications](https://phlex-reactive.zoolutions.llc/docs/example-notifications) ·
1900
+ [collections](https://phlex-reactive.zoolutions.llc/docs/example-collections) ·
1901
+ [file uploads & custom types](https://phlex-reactive.zoolutions.llc/docs/example-uploads) ·
1902
+ [loading states](https://phlex-reactive.zoolutions.llc/docs/example-loading-states) ·
1903
+ [client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops) ·
1904
+ [failure surface](https://phlex-reactive.zoolutions.llc/docs/example-failure) ·
1905
+ [team inbox](https://phlex-reactive.zoolutions.llc/docs/example-team-inbox)
952
1906
 
953
1907
  ## Credits & prior art
954
1908