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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +315 -0
- data/README.md +176 -122
- data/app/controllers/phlex/reactive/actions_controller.rb +41 -8
- data/app/javascript/phlex/reactive/confirm_predicate.js +52 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +475 -218
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/client_bindings.rb +57 -0
- data/lib/phlex/reactive/component/dsl.rb +158 -33
- data/lib/phlex/reactive/component/helpers.rb +478 -381
- data/lib/phlex/reactive/component/registry.rb +8 -1
- data/lib/phlex/reactive/component.rb +26 -12
- data/lib/phlex/reactive/defer.rb +1 -1
- data/lib/phlex/reactive/deferred_render_job.rb +2 -2
- data/lib/phlex/reactive/engine.rb +14 -0
- data/lib/phlex/reactive/js.rb +29 -15
- data/lib/phlex/reactive/reply.rb +105 -44
- data/lib/phlex/reactive/response.rb +139 -48
- data/lib/phlex/reactive/show_conditions.rb +249 -0
- data/lib/phlex/reactive/streamable.rb +244 -202
- data/lib/phlex/reactive/test_helpers.rb +10 -2
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +137 -12
- metadata +6 -1
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.
|
|
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 `
|
|
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
|
-
| `.
|
|
354
|
-
| `.
|
|
355
|
-
| `.
|
|
356
|
-
| `.
|
|
357
|
-
| `#to_stream_replace
|
|
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
|
-
| `
|
|
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(
|
|
391
|
-
| `reactive_show_targets(:field, "#id" =>
|
|
392
|
-
| `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
|
-
| `
|
|
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 (`
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
- **`
|
|
708
|
-
re-scan on change. **`
|
|
709
|
-
|
|
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
|
|
743
|
-
count: `beforeunload` (a real browser unload) and
|
|
744
|
-
in-app navigation). A clean form never blocks.
|
|
745
|
-
own generic `beforeunload` copy (your message
|
|
746
|
-
`turbo:before-visit` **does not fire on
|
|
747
|
-
documented Turbo gap, not a
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
896
|
-
|
|
897
|
-
`
|
|
898
|
-
|
|
899
|
-
|
|
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
|
|
937
|
+
div(**reactive_show(unless: { mode: "off" })) { shipping_details }
|
|
908
938
|
|
|
909
939
|
input(type: "checkbox", name: "gift")
|
|
910
|
-
div(**reactive_show(:gift
|
|
940
|
+
div(**reactive_show(if: { gift: true })) { gift_message_field }
|
|
911
941
|
|
|
912
942
|
select(name: "size") { size_options }
|
|
913
|
-
div(**reactive_show(:size
|
|
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
|
-
- **
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
server
|
|
932
|
-
|
|
933
|
-
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
|
939
|
-
|
|
940
|
-
attribute freedom. Cross-root writes take the
|
|
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" =>
|
|
954
|
-
"#advanced-panel" =>
|
|
955
|
-
"#
|
|
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);
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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" =>
|
|
977
|
-
kind: { "#premium-note" =>
|
|
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(**
|
|
995
|
-
input(**
|
|
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).
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
`
|
|
1019
|
-
|
|
1020
|
-
`
|
|
1021
|
-
|
|
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
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
-
|
|
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. `
|
|
1177
|
-
stays on the button, so focusing the
|
|
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
|
-
|
|
1186
|
-
|
|
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
|
-
`
|
|
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.
|
|
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
|
-
| `.
|
|
1288
|
-
| `.
|
|
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`/`.
|
|
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 `
|
|
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
|
-
#
|
|
1479
|
-
|
|
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` + `
|
|
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.
|
|
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
|
-
`
|
|
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!` / `
|
|
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.
|
|
1564
|
-
> (`Phlex::Reactive::Response.replace(self)`
|
|
1565
|
-
>
|
|
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
|
|
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)
|
|
1828
|
-
|
|
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(
|
|
1839
|
-
| `reply.prepend(
|
|
1840
|
-
| `reply.remove(
|
|
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
|
|
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.
|
|
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
|
-
`
|
|
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 `
|
|
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:
|