phlex-reactive 0.9.5 → 0.11.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.
data/README.md CHANGED
@@ -233,7 +233,7 @@ rails g phlex:reactive:claude
233
233
  └──────── Turbo applies it in ◀──────────────────────────────┘
234
234
 
235
235
  ...and for OTHER tabs/users:
236
- model change → Component.broadcast_replace_to(stream) → pgbus SSE → same morph
236
+ model change → Component.broadcast_to(stream, replace: model) → pgbus SSE → same morph
237
237
  ```
238
238
 
239
239
  Client actions and server broadcasts **converge on one re-render unit**: the
@@ -326,7 +326,7 @@ The [inline edit example](https://phlex-reactive.zoolutions.llc/docs/example-inl
326
326
  | [Cross-tab chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) | Record-backed action **+ pgbus broadcast** → live sync across tabs/browsers |
327
327
  | [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 |
328
328
  | [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 |
329
- | [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 |
329
+ | [Notifications / badges](https://phlex-reactive.zoolutions.llc/docs/example-notifications) | Pure broadcast — a background event pushes a re-render, plus a `broadcast_to(js:)` cross-tab pulse |
330
330
  | [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 |
331
331
  | [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 |
332
332
  | [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 |
@@ -350,11 +350,11 @@ flagship — every feature composed into one believable UI.
350
350
  | `.replace(model = nil, morph: false, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component; `morph: true` adds `method="morph"` |
351
351
  | `.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) |
352
352
  | `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
353
- | `.broadcast_replace_to(*streamables, model:, morph: false)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable); `morph: true` morphs in place |
354
- | `.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) |
355
- | `.broadcast_append_to(*streamables, target:, model:)` / `_prepend_` / `_remove_` | The other broadcast variants |
356
- | `.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). |
357
- | `#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 |
353
+ | `.broadcast_to(*streamables, replace: model, morph: false)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable); `morph: true` morphs in place |
354
+ | `.broadcast_to(*streamables, update: 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) |
355
+ | `.broadcast_to(*streamables, append: model, target:)` / `prepend:` / `remove:` | The other broadcast verbs — the verb is a kwarg (exactly one of `replace:`/`update:`/`append:`/`prepend:`/`remove:`/`js:`) |
356
+ | `.broadcast_to(each: stream_keys, replace: model, morph: false, exclude:, visible_to:)` | **Render once, fan out** the *same* payload to K different stream keys — a per-tenant loop. Pass `each:` instead of `*streamables`. 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). |
357
+ | `#to_stream_replace(morph: false)` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `reply`); `morph: true` morphs in place |
358
358
  | `#to_stream_update(morph: false)` | Inner-HTML update of the *already-built* instance; `morph: true` morphs in place (issue #113) |
359
359
 
360
360
  Use in controllers: `render turbo_stream: Counter.replace(counter)`.
@@ -368,7 +368,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
368
368
  | `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
369
369
  | `mark_authorized!` | Inside an action: satisfy the `verify_authorized` guard after a bespoke check the interceptor can't see (a hand-rolled policy). Call it only after your check passes. |
370
370
  | `skip_verify_authorized [ :a, :b ]` | Opt a component (bare) or specific actions out of the default-ON `verify_authorized` guard — for a genuinely public component (a counter, a client-only filter). |
371
- | `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. |
371
+ | `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. `compute: :name` binds a client-side compute **at the root** — descriptors plus the `input->reactive#recompute` delegation, so no field needs its own wiring; `nil` collapses to no binding. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
372
372
  | `reactive_attrs` | Marks an element reactive + carries the signed token (no `id`). Spread alongside `id:` on the **same** element: `div(id:, **reactive_attrs)`. Prefer `reactive_root`, which can't split them. |
373
373
  | `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
374
374
  | `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
@@ -384,16 +384,15 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
384
384
  | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
385
385
  | `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). |
386
386
  | `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`, `text` (set `textContent` — XSS-safe), and `dispatch` — chainable. |
387
- | `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
388
- | `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
387
+ | `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. |
389
388
  | `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). |
390
- | `reactive_show(:field, equals:/not:/in:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the named field's **current value**, client-only, zero round trip. One literal predicate: `equals:`, `not:`, or `in: [...]`; `equals: true` reads a checkbox's checked state. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
391
- | `reactive_show_targets(:field, "#id" => { equals: … })` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form (`reactive_show_targets(mode: { }, kind: { })`). Id selectors only (raise at render + client warn-skip); same literal predicates; toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
392
- | `reactive_filter(input:, option:, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root — typing in the named input shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. Optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
389
+ | `reactive_show(if:/if_any:/unless:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the fields' **current values**, client-only, zero round trip. One conditions language: a **Hash is an AND**, an **Array is membership**, a **Range is a threshold**, `if_any:` is OR-of-AND, `unless:` negates. `reactive_values` computes first paint; `disable:` disables a hidden section's controls. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
390
+ | `reactive_show_targets(:field, "#id" => value)` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form. The value uses the same `where`-style vocabulary (`"advanced"`, `%w[a b]`, `10..`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
391
+ | `reactive_filter(:field, option: nil, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root and name the **field** that drives it `reactive_filter(:q)` compiles `:q` to `[name="q"]` (scope-aware) and typing shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. `option:` defaults to `[role=option]`; optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
393
392
  | `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. |
394
- | `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. |
393
+ | `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; the **permit-style** form (`inputs: [:qty, title: :string]`) mixes both — bare symbols default `:number`, a trailing hash types the exceptions. `outputs:` is the field allowlist; a reducer-result key also paints any owned `reactive_text` node by presence and any `mirror:` id, so an `outputs:` entry that exists only to reach a text node is redundant (harmless — a widening). |
395
394
  | `reactive_compute :name, ..., mirror: { sum: "#summary-sum" }` | **Cross-root text mirrors**: paint a compute value into declared, id-allowlisted nodes **outside** the reactive root (a recap in another tab pane) via `textContent` — no bespoke listener. See [Cross-root mirrors](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root). |
396
- | `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). |
395
+ | `reactive_dirty` / `reactive_dirty warn_unsaved: true` / `reactive_dirty only: %i[...]` | **Dirty tracking**, declared once at the class level, 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; `only:` scopes tracking to named fields. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-reactive_dirty). |
397
396
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
398
397
  | `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). |
399
398
  | `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). |
@@ -428,6 +427,23 @@ Register during boot only: the registry is **frozen after initialization**, so a
428
427
  runtime `param_type` call raises. A schema referencing a registered type is
429
428
  validated at declaration like any built-in.
430
429
 
430
+ **Named param schemas (`Phlex::Reactive.param_schema`).** Register a reusable
431
+ schema once so sibling components stop duplicating a verbatim params Hash that
432
+ drifts — follows the same boot-only contract as `param_type`:
433
+
434
+ ```ruby
435
+ # config/initializers/phlex_reactive.rb
436
+ Phlex::Reactive.param_schema :todo, title: :string, done: :boolean
437
+
438
+ # then, in any component:
439
+ action :save, params: :todo
440
+
441
+ # or compose it into a bigger schema:
442
+ action :bulk, params: { **Phlex::Reactive.param_schema(:todo), note: :string }
443
+ ```
444
+
445
+ Reading an unregistered name raises, listing the ones that are registered.
446
+
431
447
  **File uploads (`:file`).** Declare `:file` (or `[:file]` for multiple) to accept
432
448
  an uploaded file in a reactive action — attach a document/receipt/image to the
433
449
  record without dropping out to a bespoke controller. When the reactive root holds
@@ -686,7 +702,7 @@ is not disabled mid-typing. On settle the text is restored only if it still
686
702
  matches what was swapped in; a morph that rendered a **new** server label is left
687
703
  alone.
688
704
 
689
- ### Dirty-field tracking (`dirty:` / `track_dirty:` + `warn_unsaved:`)
705
+ ### Dirty-field tracking (`reactive_dirty`)
690
706
 
691
707
  Show "unsaved changes", enable **Save** only when something changed, or warn
692
708
  before navigating away — Livewire's `wire:dirty` — **without shipping any client
@@ -696,17 +712,32 @@ zero extra bytes. `input.defaultValue` / `defaultChecked` / `option.defaultSelec
696
712
  phlex-reactive reads that baseline straight from the DOM — nothing to snapshot,
697
713
  nothing to sign.
698
714
 
715
+ `reactive_dirty` is declared **once, at the class level**, alongside your other
716
+ macros (`reactive_record`/`reactive_state`/`action`) — not as a `reactive_root` or
717
+ `reactive_field` keyword:
718
+
699
719
  ```ruby
700
- div(**reactive_root(track_dirty: true, warn_unsaved: true)) do
701
- input(**reactive_field(:title, value: @todo.title, dirty: true))
702
- span(class: "unsaved-badge") { "Unsaved" } # shown via [data-reactive-dirty] CSS
703
- button(**on(:save, disable_with: "Saving…")) { "Save" }
720
+ class TodoForm < ApplicationComponent
721
+ include Phlex::Reactive::Component
722
+
723
+ reactive_record :todo
724
+ reactive_dirty warn_unsaved: true
725
+ action :save, params: { title: :string }
726
+
727
+ def view_template
728
+ div(**reactive_root) do
729
+ input(**reactive_field(:title, value: @todo.title))
730
+ span(class: "unsaved-badge") { "Unsaved" } # shown via [data-reactive-dirty] CSS
731
+ button(**on(:save, disable_with: "Saving…")) { "Save" }
732
+ end
733
+ end
704
734
  end
705
735
  ```
706
736
 
707
- - **`track_dirty: true`** on the root wires every input under it to a full
708
- re-scan on change. **`dirty: true`** on a single `reactive_field` opts that one
709
- field in (use it when only some fields should count).
737
+ - **`reactive_dirty`** (no args) wires every input under the root to a full
738
+ re-scan on change. **`reactive_dirty only: %i[title]`** scopes tracking to the
739
+ named fields only each carries its own descriptor instead of the root
740
+ delegating for the whole subtree (use it when only some fields should count).
710
741
  - On each change the client re-scans **every field the root owns** in one pass and
711
742
  marks:
712
743
  - each changed field with **`data-reactive-dirty="true"`**, and
@@ -739,15 +770,16 @@ and the badge clears with no reload. (Turbo 8 morph preserves a focused field's
739
770
  in-progress value while writing the fresh defaults — the post-morph re-scan is
740
771
  what keeps the root count honest in that state.)
741
772
 
742
- **`warn_unsaved: true`** arms a navigate-away guard gated on the **live** dirty
743
- count: `beforeunload` (a real browser unload) and `turbo:before-visit` (a Turbo
744
- in-app navigation). A clean form never blocks. **Caveats:** browsers show their
745
- own generic `beforeunload` copy (your message string is legacy and ignored), and
746
- `turbo:before-visit` **does not fire on restoration visits** (Back/Forward) — a
747
- documented Turbo gap, not a phlex-reactive one.
773
+ **`reactive_dirty warn_unsaved: true`** arms a navigate-away guard gated on the
774
+ **live** dirty count: `beforeunload` (a real browser unload) and
775
+ `turbo:before-visit` (a Turbo in-app navigation). A clean form never blocks.
776
+ **Caveats:** browsers show their own generic `beforeunload` copy (your message
777
+ string is legacy and ignored), and `turbo:before-visit` **does not fire on
778
+ restoration visits** (Back/Forward) — a documented Turbo gap, not a
779
+ phlex-reactive one.
748
780
 
749
781
  **Out of scope (v1):** rich-text / `contenteditable` editors have no `default*`
750
- baseline and are **not** tracked. Combining `reactive_field(dirty:)` with your own
782
+ baseline and are **not** tracked. Combining `reactive_field` with your own
751
783
  `data:`/`on(...)` still needs `mix(...)` (a bare merge clobbers the `data-action`
752
784
  the descriptor rides on — the same rule as everywhere else).
753
785
 
@@ -788,7 +820,7 @@ the same chain covers the rest of the client-only vocabulary:
788
820
 
789
821
  ```ruby
790
822
  button(**on_client(:click, js
791
- .toggle("#menu", transition: %w[transition-opacity opacity-0 opacity-100])
823
+ .toggle("#menu", transition: { during: "transition-opacity", from: "opacity-0", to: "opacity-100" })
792
824
  .toggle_attr(:root, "aria-expanded") # accessible disclosure state
793
825
  .focus_first("#menu") # move focus into the opened menu
794
826
  .dispatch("app:menu-toggled", detail: { open: true }))) { "Menu" }
@@ -814,7 +846,7 @@ button(**on_client(:click, js
814
846
  another component or a plain Stimulus controller can react to a client-only
815
847
  interaction — `to:` picks the element (default: the component root), `detail:`
816
848
  is the payload.
817
- - **`transition: [during, from, to]`** on `show`/`hide`/`toggle` animates the
849
+ - **`transition: { during:, from:, to: }`** on `show`/`hide`/`toggle` animates the
818
850
  visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next
819
851
  frame, and the helper classes are cleaned up on `animationend` (with a timeout
820
852
  fallback, so an element with no animation never leaves them stuck). The op chain
@@ -892,52 +924,58 @@ free as an ordinary action-param name (`on(:switch, key: "pgbus")` still passes
892
924
 
893
925
  `on_client` covers the *unconditional* client-only interactions; the last gap
894
926
  was **show/hide from a form field's current value** — the Alpine `x-show` /
895
- Datastar `data-show` / Livewire `wire:show` case ("reveal the details panel
896
- *while* this select isn't `none`"). That used to force a hand-written
897
- `change`-listener Stimulus controller back into an otherwise declarative form.
898
- `reactive_show` closes it: spread it onto the element to show/hide, name the
899
- controlling field, declare **one literal predicate** the generic controller
900
- toggles the `hidden` attribute from the field's current value on every
901
- `input`/`change`. Client-only, **no token, no POST, ever**:
927
+ Datastar `data-show` / Livewire `wire:show` case. `reactive_show` closes it with
928
+ ONE Ruby-native conditions language: spread it onto the element to show/hide and
929
+ declare an `if:` / `if_any:` / `unless:` condition with `where`-style values —
930
+ the generic controller toggles the `hidden` attribute from the fields' current
931
+ values on every `input`/`change`. Client-only, **no token, no POST, ever**:
902
932
 
903
933
  ```ruby
904
934
  def view_template
905
935
  div(**reactive_root) do
906
936
  select(name: "mode") { shipping_options }
907
- div(**reactive_show(:mode, not: "off", hidden: @order.mode == "off")) { shipping_details }
937
+ div(**reactive_show(unless: { mode: "off" })) { shipping_details }
908
938
 
909
939
  input(type: "checkbox", name: "gift")
910
- div(**reactive_show(:gift, equals: true, hidden: true)) { gift_message_field }
940
+ div(**reactive_show(if: { gift: true })) { gift_message_field }
911
941
 
912
942
  select(name: "size") { size_options }
913
- div(**reactive_show(:size, in: %w[l xl], hidden: true)) { surcharge_note }
943
+ div(**reactive_show(if: { size: %w[l xl] })) { surcharge_note } # membership
944
+
945
+ input(type: "number", name: "quantity")
946
+ div(**reactive_show(if: { quantity: 10.. })) { bulk_note } # threshold
914
947
  end
915
948
  end
916
949
  ```
917
950
 
918
- - **One predicate per binding** `equals:`, `not:`, or `in:` (a list); zero or
919
- two raise at render (a dead binding must not silently no-op). Values are
920
- **stringified literals** matched against the field's value never an
921
- expression, so there is no eval surface.
922
- - **Field reads follow the collection rules**: a checkbox compares its
923
- *checked* state as `"true"`/`"false"` (so `equals: true` is the checkbox
924
- formits `.value` is the constant `"on"`, and it wins over the hidden
925
- input Rails pairs with it); a radio group reads the **checked** radio's
926
- value (`""` when none is); everything else reads `.value`. Ownership is the
927
- usual rule a nested reactive component's fields and bindings belong to the
928
- nested component.
929
- - **Initial state**: the client seeds visibility at connect and reconciles
930
- after a morph, but render the initial `hidden:` yourself (from the same
931
- server state that renders the field, as above) so the first paint doesn't
932
- flash.
933
- - **Extra attrs ride through the helper** (`reactive_show(:mode, not: "off",
934
- class: "panel", data: { testid: "details" })`) and deep-merge — a bare
935
- `data:` spread *beside* it would clobber the binding, same as `on(...)`.
951
+ - **The value language**: a **Hash is an AND** (multiple keys ANDed), an
952
+ **Array is membership**, a **Range is a threshold** (`10..` 10, `..10` 10,
953
+ `...10` < 10, `10..20` between), `true`/`false` compare a checkbox's checked
954
+ state, `nil` matches blank. `unless:` **negates** and composes with `if:`.
955
+ Never an expression every term is a declared literal, so there is no eval
956
+ surface. A blank/non-numeric value fails a numeric term **closed** (hidden).
957
+ - **OR-of-AND** — `if_any:` takes an array of AND-hashes (`if_any: [{ director:
958
+ true }, { shareholder: true, role: "individual" }]`), so
959
+ `director || (shareholder && individual)` is **one flat binding**, no nested
960
+ wrapper divs, no hand-applied distributive law.
961
+ - **First paint is computed for you** — declare `reactive_values` once
962
+ (`def reactive_values = { mode: @order.mode, gift: @order.gift? }`) and every
963
+ binding whose fields are all provided renders the correct initial `hidden:`
964
+ server-side. No per-section mirror method, no flash. An explicit `hidden:`
965
+ always wins; a per-call `values:` override merges over `reactive_values`.
966
+ - **`reactive_scope :form`** lets bindings and `reactive_values` use bare
967
+ symbols while the client resolves `[name="form[field]"]`.
968
+ - **`disable: true`** disables a hidden section's own controls so a
969
+ switched-away value never submits (the stale-value fix).
970
+ - **Field reads follow the collection rules**: a checkbox compares its *checked*
971
+ state; a radio group reads the **checked** radio's value; everything else
972
+ reads `.value`. Ownership is the usual rule — a nested reactive component's
973
+ fields and bindings belong to the nested component.
936
974
  - **Composes with computes**: a `reactive_compute` output write dispatches a
937
975
  real `input` event, so a *derived* value can drive visibility too.
938
- - Presentational only, strictly weaker than the js ops: it reads an owned
939
- field and toggles `hidden` on an owned element — no `innerHTML`, no
940
- attribute freedom. Cross-root writes take the **declared** escape below.
976
+ - Presentational only, strictly weaker than the js ops: it reads owned fields
977
+ and toggles `hidden` (+ optionally `disabled`) on owned elements — no
978
+ `innerHTML`, no attribute freedom. Cross-root writes take the escape below.
941
979
 
942
980
  **Cross-root targets (`reactive_show_targets`).** A plain `reactive_show` is
943
981
  root-scoped by design — but "a mode selector reveals dependent sections
@@ -946,13 +984,13 @@ sidebar note) routinely puts the dependents **outside** the control's root.
946
984
  `reactive_show_targets` is the declared escape, the visibility parallel of the
947
985
  [cross-root text mirror](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root):
948
986
  the component that **owns** the field declares which outside ids it governs,
949
- spread on the **root**:
987
+ spread on the **root**, using the same `where`-style values:
950
988
 
951
989
  ```ruby
952
990
  div(**mix(reactive_root, reactive_show_targets(:mode,
953
- "#advanced-tab" => { equals: "advanced" },
954
- "#advanced-panel" => { equals: "advanced" },
955
- "#basic-note" => { not: "advanced" }))) do
991
+ "#advanced-tab" => "advanced", # equals
992
+ "#advanced-panel" => "advanced",
993
+ "#premium-note" => %w[gold platinum]))) do # membership
956
994
  select(name: "mode") { mode_options }
957
995
  # …
958
996
  end
@@ -961,11 +999,10 @@ end
961
999
  Same posture as `mirror:`: **opt-in and declared, never implicit** — a plain
962
1000
  `reactive_show` stays root-isolated; targets are **single id selectors only**
963
1001
  (a class/compound selector raises at render AND is warn-and-skipped by the
964
- client — two-sided default-deny); the predicate is the same literal-only
965
- vocabulary; and the toggle is `hidden` only. The field read stays **owned** —
966
- you can only drive outside visibility from a field the declaring root owns. A
967
- target id not on the page is silently skipped, so a target inside an
968
- unrendered tab pane is fine.
1002
+ client — two-sided default-deny); values use the same vocabulary; and the
1003
+ toggle is `hidden` only. The field read stays **owned** — you can only drive
1004
+ outside visibility from a field the declaring root owns. A target id not on the
1005
+ page is silently skipped, so a target inside an unrendered tab pane is fine.
969
1006
 
970
1007
  **One call per root.** Phlex `mix` space-joins duplicate string `data:`
971
1008
  values, so a *second* `reactive_show_targets` call on the same root would
@@ -973,8 +1010,8 @@ concatenate two JSON payloads into an unparseable attribute (the client warns
973
1010
  and ignores it). Several fields go in **one call** via the hash form:
974
1011
 
975
1012
  ```ruby
976
- reactive_show_targets(mode: { "#advanced-tab" => { equals: "advanced" } },
977
- kind: { "#premium-note" => { not: "basic" } })
1013
+ reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
1014
+ kind: { "#premium-note" => %w[gold platinum] })
978
1015
  ```
979
1016
 
980
1017
  ### Client-side computes (`reactive_compute` + `reactive_text`)
@@ -991,9 +1028,8 @@ reactive_compute :preview,
991
1028
  inputs: { title: :string, qty: :number }, # typed: :string raw, :number → Number
992
1029
  outputs: %i[title_preview char_count] # written with no round trip
993
1030
 
994
- div(**mix(reactive_root, reactive_compute_attrs(:preview))) do
995
- input(**mix(reactive_field(:title, value: @post.title),
996
- data: { action: "input->reactive#recompute" }))
1031
+ div(**reactive_root(compute: :preview)) do
1032
+ input(**reactive_field(:title, value: @post.title))
997
1033
  h2 { reactive_text(:title_preview, @post.title) } # a text-node output
998
1034
  small { reactive_text(:char_count) } # another text-node output
999
1035
  end
@@ -1007,18 +1043,31 @@ setComputeReducer("preview", ({ title }) => ({
1007
1043
  }))
1008
1044
  ```
1009
1045
 
1046
+ - **`reactive_root(compute: :name)` binds AND listens at the root.** It emits the
1047
+ compute descriptors plus the `input->reactive#recompute` delegation on the root
1048
+ element, so no field needs any per-field compute wiring. `nil` (e.g.
1049
+ `reactive_root(compute: (:preview unless @post.persisted?))`) collapses to no
1050
+ binding at all — one expression for conditional compute.
1010
1051
  - **Typed inputs.** `inputs:` takes a **hash** to type each input: a `:number` is
1011
1052
  coerced through `Number` (blank/NaN → 0 — the array-form default), a `:string`
1012
1053
  reaches the reducer **raw** so a live text preview reads real text. The **array
1013
- form** (`inputs: %i[a b]`) stays all-numeric and byte-identical on the wire.
1054
+ form** (`inputs: %i[a b]`) stays all-numeric and byte-identical on the wire. The
1055
+ **permit-style form** (`inputs: [:qty, title: :string]`) combines both in one
1056
+ declaration — bare symbols default to `:number`, a trailing hash types the
1057
+ exceptions.
1014
1058
  - **`reactive_text(:name, initial)`** mirrors a value into a **text node** via
1015
- `textContent` (XSS-safe by construction). An output whose name matches an owned
1016
- form field writes that field's `.value`; an output with **no** matching field
1017
- writes every owned `[data-reactive-text="<name>"]` node. It carries **no
1018
- `name`**, so it's never collected or POSTed as a param.
1019
- - **Reducer-less mirrors.** A declared **input** also mirrors into its own
1020
- `reactive_text(:same_name)` node on every keystroke with **no reducer at all** —
1021
- so `reactive_text(:title)` is a live field echo out of the box.
1059
+ `textContent` (XSS-safe by construction). Every reducer-result key paints any
1060
+ matching sink: an owned **field** if declared in `outputs:`, any owned
1061
+ **`reactive_text`** node by presence, or a declared **`mirror:`** id — so an
1062
+ `outputs:` entry that exists only to reach a text node is redundant (existing
1063
+ declarations keep working; it's a widening, not a breaking change). It carries
1064
+ **no `name`**, so it's never collected or POSTed as a param.
1065
+ - **Reducer-less mirrors, and reactive_values seeding.** A declared **input** also
1066
+ mirrors into its own `reactive_text(:same_name)` node on every keystroke with
1067
+ **no reducer at all** — so `reactive_text(:title)` is a live field echo out of
1068
+ the box. And `reactive_text(:name)` called with **no explicit `initial`** seeds
1069
+ its first paint from `reactive_values` when the component declares one and
1070
+ covers that name — the same no-flash first-paint contract `reactive_show` uses.
1022
1071
  - **Seed the server render.** Your `view_template` must seed each mirror with the
1023
1072
  same derived value the reducer would (`reactive_text(:char_count, "5/80")`), or
1024
1073
  a later morph repaints stale text — the same reconcile contract the whole
@@ -1104,13 +1153,13 @@ root instead:
1104
1153
 
1105
1154
  ```ruby
1106
1155
  div(**mix(reactive_root, reactive_filter(
1107
- input: "#exercise-search", # the input whose value drives the filter
1108
- option: "[role=option]", # the elements to show/hide
1109
- group: "[data-filter-group]", # optional: collapse a header when all its options hide
1110
- empty: "#no-matches" # optional: reveal when 0 options match
1156
+ :q, # the FIELD that drives the filter → [name="q"] (scope-aware)
1157
+ group: "[data-filter-group]", # optional: collapse a header when all its options hide
1158
+ empty: "#no-matches" # optional: reveal when 0 options match
1111
1159
  ))) do
1112
1160
  # STANDALONE keyboard nav — no action on the input, so typing never POSTs.
1113
- input(id: "exercise-search", type: "search", **reactive_listnav("[role=option]"))
1161
+ # name: "q" is the field reactive_filter(:q) binds to; option: defaults to [role=option].
1162
+ input(name: "q", type: "search", **reactive_listnav("[role=option]"))
1114
1163
 
1115
1164
  categories.each do |category, exercises|
1116
1165
  div(data: { filter_group: "" }) do
@@ -1173,17 +1222,17 @@ button(**on(:increment), data: { testid: "inc" }) { "+" }
1173
1222
  **Binding inputs to action params (drop the magic `name:`).** A field's value
1174
1223
  travels with an action only if its `name` equals the param. Hand-writing
1175
1224
  `name: "value"` on every input is easy to forget — the action then silently gets
1176
- nothing. `reactive_input`/`reactive_select` emit the binding for you (the trigger
1177
- stays on the button, so focusing the field doesn't dispatch and collapse edit
1178
- mode):
1225
+ nothing. `reactive_field` returns the attribute hash that carries the binding —
1226
+ spread it onto any control (the trigger stays on the button, so focusing the
1227
+ field doesn't dispatch and collapse edit mode):
1179
1228
 
1180
1229
  ```ruby
1181
1230
  action :save, params: { value: :string, status: :string }
1182
1231
 
1183
1232
  def view_template
1184
1233
  span(**reactive_root) do
1185
- reactive_input(:value, value: @record.name) # <input name="value" …>
1186
- reactive_select(:status) do # <select name="status">…</select>
1234
+ input(**reactive_field(:value, value: @record.name)) # <input name="value" …>
1235
+ select(**reactive_field(:status)) do # <select name="status">…</select>
1187
1236
  %w[open closed].each { |s| option(value: s, selected: s == @record.status) { s } }
1188
1237
  end
1189
1238
  button(**mix(on(:save), data: { testid: "save" })) { "Save" }
@@ -1247,7 +1296,7 @@ gains a seam.
1247
1296
  By default an action re-renders its component in place. To do more, **return**
1248
1297
  `reply.<verb>` — a subject-bound builder available in every component. It governs
1249
1298
  only the actor's HTTP reply (cross-tab updates still use
1250
- `broadcast_*_to(..., exclude: reactive_connection_id)`). Returning anything else
1299
+ `broadcast_to(..., exclude: reactive_connection_id)`). Returning anything else
1251
1300
  keeps the default, so existing actions are unaffected.
1252
1301
 
1253
1302
  `reply` reads cleanly: the component is the implicit subject (no `self` to
@@ -1271,7 +1320,7 @@ def add(item:) = reply.replace.stream(Totals.update(@order)) # mul
1271
1320
  def update(name:) = (@row.update!(name:); reply.morph)
1272
1321
 
1273
1322
  # Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
1274
- def rename(value:) = (@account.update!(name: value); reply.replace.also_update("page_heading", html: @account.name))
1323
+ def rename(value:) = (@account.update!(name: value); reply.replace.also(page_heading: @account.name))
1275
1324
 
1276
1325
  # Update ONLY part of the component (issue #30): re-stream just the total cell,
1277
1326
  # NOT the whole row. reply.streams emits exactly your streams plus a tiny
@@ -1284,8 +1333,8 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
1284
1333
  |---|---|
1285
1334
  | `reply.replace` / `reply.update(morph: false)` | re-render in place (default; `replace` swaps the whole element via outerHTML, `update` swaps only the inner HTML) |
1286
1335
  | `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) |
1287
- | `.also_update(target, html:)` | also re-render a companion element by DOM id; `html` is a plain string (escaped) or a Phlex component |
1288
- | `.also_replace(component, morph: false)` | also re-render another Streamable component, targeting its own `#id`; `morph: true` morphs it in place |
1336
+ | `.also(target => content, )` | **UPDATE** (inner HTML) companion elements by DOM id; `content` is a plain string (escaped) or a Phlex component. The argument *type* picks the action — pairs mean `update` |
1337
+ | `.also(component, morph: false)` | **REPLACE** another Streamable component at its own `#id` (`morph: true` morphs in place). A component argument means `replace` |
1289
1338
  | `.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"`) |
1290
1339
  | `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |
1291
1340
  | `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
@@ -1294,7 +1343,7 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
1294
1343
  | `.defer(component, placeholder:, morph:)` | take an **expensive segment off the actor's critical path** (issue #165) — the reply returns immediately and the real render streams to the SAME actor when ready; see [Deferred segments](#deferred-segments-replydefer--reactive_lazy) |
1295
1344
  | `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |
1296
1345
 
1297
- `.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's
1346
+ `.flash`/`.stream`/`.also` are additive on a self-replace, so the component's
1298
1347
  signed token always refreshes. **`reply.streams`** is the exception that proves
1299
1348
  the rule: it deliberately skips the full-self replace (so your hand-built streams
1300
1349
  update only the targets you name) and refreshes the token via a tiny inert
@@ -1358,7 +1407,7 @@ Semantics you can rely on:
1358
1407
  the action's transaction committed; a rollback or a denied action leaks no
1359
1408
  deferred render (and enqueues no job).
1360
1409
  - **Actor-scoped** — the deferred render reaches only the actor; peers keep
1361
- getting updates via `broadcast_*_to` (use both when both need the value).
1410
+ getting updates via `broadcast_to` (use both when both need the value).
1362
1411
  - **Superseding** — a newer action for the same target aborts the in-flight
1363
1412
  deferred render; a fast typist never gets stale totals painted over fresh ones.
1364
1413
  - **Interactive on arrival** — the streamed root carries a fresh action token.
@@ -1474,12 +1523,14 @@ Prefer your own markup? Two escape hatches:
1474
1523
  # (you own the markup entirely, including the level styling):
1475
1524
  reply.replace.flash(:error, Alert.new(level: :error, message: msg))
1476
1525
 
1477
- # 2. Or configure a flash component ONCE — string flashes render through it
1478
- # (instantiated new(level:, content:)); component content still bypasses it:
1479
- Phlex::Reactive.flash_component = MyFlash # default nil the built-in wrapper
1526
+ # 2. Or configure a flash component ONCE — string flashes render through it;
1527
+ # component content still bypasses it. flash_component is an app-owned
1528
+ # CALLABLE (issue #182): it maps (level, content) to your component —
1529
+ # the gem never guesses kwargs:
1530
+ Phlex::Reactive.flash_component = ->(level, content) { MyFlash.new(level:, content:) } # default nil → the built-in wrapper
1480
1531
  ```
1481
1532
 
1482
- #### Server-pushed client ops (`reply.js` + `broadcast_js_to`, issue #97)
1533
+ #### Server-pushed client ops (`reply.js` + `broadcast_to(js:)`, issue #97)
1483
1534
 
1484
1535
  Sometimes the server needs the client to do something other than swap HTML —
1485
1536
  focus the next field after a save, dispatch an app event to a toast host, add an
@@ -1509,11 +1560,11 @@ transport (Action Cable **or** pgbus) — a background nudge to all viewers:
1509
1560
 
1510
1561
  ```ruby
1511
1562
  # In a model/job: light up the bell in every viewer's tab, minus the actor's own.
1512
- Notifications::Badge.broadcast_js_to(user, :alerts,
1513
- js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
1563
+ Notifications::Badge.broadcast_to(user, :alerts,
1564
+ js: js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
1514
1565
  ```
1515
1566
 
1516
- `broadcast_js_to` **refuses focus-class ops** (`focus`/`focus_first` raise
1567
+ `broadcast_to` with `js:` **refuses focus-class ops** (`focus`/`focus_first` raise
1517
1568
  `ArgumentError`): broadcasting focus would steal it in every subscriber's tab, so
1518
1569
  focus is an actor-reply concern only. Everything else is a fair broadcast (class
1519
1570
  and attribute toggles, `dispatch`). As with `on_client`, the ops are
@@ -1533,7 +1584,7 @@ record can be there purely for **identity + authorization** while the action's
1533
1584
  real job is to recompute **live, unsaved form values** the user is mid-edit. The
1534
1585
  record is re-located and instantiated on each action (`from_identity`), never
1535
1586
  auto-saved and never auto-broadcast; persistence and cross-tab broadcast are both
1536
- opt-in (you call `record.update!` / `broadcast_*_to` yourself). Pair that with
1587
+ opt-in (you call `record.update!` / `broadcast_to` yourself). Pair that with
1537
1588
  `reply.streams` and you get a first-class "authorize via the row, compute over
1538
1589
  the params, stream a partial update, touch neither the DB nor peer tabs" action:
1539
1590
 
@@ -1560,9 +1611,10 @@ Broadcasting is deliberately omitted so peer tabs with their own in-flight edits
1560
1611
  aren't clobbered. Authorize the record as always — identity is never permission.
1561
1612
 
1562
1613
  > **Under the hood.** `reply.<verb>` returns a `Phlex::Reactive::Response` — the
1563
- > immutable value object the endpoint reads. You can build one directly
1564
- > (`Phlex::Reactive::Response.replace(self)`) and it still works, but `reply` is
1565
- > the preferred surface; treat `Response` as an internal detail.
1614
+ > immutable value object the endpoint reads. `reply` is the ONE documented door
1615
+ > (issue #182): the old `Phlex::Reactive::Response.replace(self)` class verbs
1616
+ > are removed and raise a guided `ArgumentError` naming the `reply.<verb>`
1617
+ > rewrite; treat `Response` as an internal detail.
1566
1618
  > **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
1567
1619
  > `html: @account.name` is safe even for user-supplied values. To emit intentional
1568
1620
  > markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —
@@ -1820,12 +1872,13 @@ class NotificationsList < ApplicationComponent
1820
1872
 
1821
1873
  def add(title:)
1822
1874
  todo = Todo.create!(title:)
1823
- reply.append(:notifications, todo) # append row + bump count + clear empty-state
1875
+ reply.append(todo, to: :notifications) # append row + bump count + clear empty-state
1824
1876
  end
1825
1877
 
1826
1878
  def dismiss(id:)
1827
- Todo.find(id).destroy!
1828
- reply.remove(:notifications, id) # remove row + bump count + restore empty-state at 0
1879
+ todo = Todo.find(id)
1880
+ todo.destroy!
1881
+ reply.remove(todo, from: :notifications) # remove row + bump count + restore empty-state at 0
1829
1882
  end
1830
1883
 
1831
1884
  # view_template renders the count, the container <ul>, and the empty-state on
@@ -1835,9 +1888,9 @@ end
1835
1888
 
1836
1889
  | Builder | Reply (one `Response`) |
1837
1890
  |---|---|
1838
- | `reply.append(name, model)` | append the row into the container + update the count + remove the empty-state when the list crosses 0→1 |
1839
- | `reply.prepend(name, model)` | as `append`, but the row goes to the top |
1840
- | `reply.remove(name, model)` | remove the row by its `dom_id` + update the count + append the empty-state back when the list crosses →0 |
1891
+ | `reply.append(model, to: name)` | append the row into the container + update the count + remove the empty-state when the list crosses 0→1 |
1892
+ | `reply.prepend(model, to: name)` | as `append`, but the row goes to the top |
1893
+ | `reply.remove(model, from: name)` | remove the row by its `dom_id` + update the count + append the empty-state back when the list crosses →0 |
1841
1894
 
1842
1895
  - **`size:` is the source of truth** — it's *re-counted* server-side after the
1843
1896
  mutation, so the badge and the empty-state are correct-by-construction (no
@@ -1849,11 +1902,11 @@ end
1849
1902
  (correct on the first click, silently rejected after); the helper bakes the
1850
1903
  refresh in so you never hit it.
1851
1904
  - **`remove` takes the record or its `dom_id` string** — a just-destroyed
1852
- ActiveRecord still answers `dom_id` correctly, so `reply.remove(:items, todo)`
1905
+ ActiveRecord still answers `dom_id` correctly, so `reply.remove(todo, from: :items)`
1853
1906
  works; pass the raw id only if your row `#id` matches `ActiveRecord::RecordIdentifier`.
1854
1907
  - **Reply governs the actor's HTTP response only.** For a *cross-tab* live list
1855
1908
  (other viewers see the row appear) keep broadcasting the row with
1856
- `NotificationRow.broadcast_append_to(..., exclude: reactive_connection_id)` —
1909
+ `NotificationRow.broadcast_to(..., append: model, exclude: reactive_connection_id)` —
1857
1910
  `reactive_collection` is the per-actor add/remove + count + empty-state wrapper,
1858
1911
  not a replacement for the broadcast.
1859
1912
 
@@ -2121,7 +2174,7 @@ end
2121
2174
 
2122
2175
  [pgbus](https://github.com/mhenrixon/pgbus) replaces Action Cable's transport
2123
2176
  with Postgres SSE and fixes its reliability gaps. With it installed,
2124
- `broadcast_*_to` and `turbo_stream_from` route over pgbus automatically:
2177
+ `broadcast_to` and `turbo_stream_from` route over pgbus automatically:
2125
2178
 
2126
2179
  ```ruby
2127
2180
  class Message < ApplicationRecord
@@ -2153,7 +2206,8 @@ events, all under the `phlex_reactive` namespace:
2153
2206
  |-------|-------|---------|
2154
2207
  | `action.phlex_reactive` | once per request | `component`, `action`, `outcome` (`ok`/`denied_undeclared`/`invalid_token`/`not_found`/`unauthorized`/`unverified`) |
2155
2208
  | `render.phlex_reactive` | per component render | `component`, `bytesize` |
2156
- | `broadcast.phlex_reactive` | per `broadcast_*_to` (Action Cable **and** pgbus) | `component`, `stream_action`, `streamables` |
2209
+ | `broadcast.phlex_reactive` | per `broadcast_to` call (Action Cable **and** pgbus) | `component`, `stream_action`, `streamables` |
2210
+ | `defer.phlex_reactive` | once per deferred render (the `reply.defer` pull/push endpoint) | `component`, `outcome` (`ok`/`no_content`/`invalid_token`/`not_found`/`unauthorized`) |
2157
2211
 
2158
2212
  Payloads carry **names, the outcome, and sizes only** — never the token, params,
2159
2213
  or state, so an event can't leak a secret. Subscribe from an initializer: