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