phlex-reactive 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +197 -0
  3. data/README.md +282 -2
  4. data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
  5. data/app/javascript/phlex/reactive/inspect.js +225 -0
  6. data/app/javascript/phlex/reactive/inspect.min.js +4 -0
  7. data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/reactive_controller.js +534 -6
  9. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  10. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
  11. data/lib/generators/phlex/reactive/claude/USAGE +15 -0
  12. data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
  13. data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
  14. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
  15. data/lib/phlex/reactive/authorization.rb +118 -0
  16. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
  17. data/lib/phlex/reactive/component/dsl.rb +70 -0
  18. data/lib/phlex/reactive/component/helpers.rb +193 -8
  19. data/lib/phlex/reactive/component/identity.rb +10 -1
  20. data/lib/phlex/reactive/component/lazy.rb +79 -0
  21. data/lib/phlex/reactive/component/registry.rb +7 -1
  22. data/lib/phlex/reactive/component.rb +1 -0
  23. data/lib/phlex/reactive/defer.rb +363 -0
  24. data/lib/phlex/reactive/deferred_render_job.rb +116 -0
  25. data/lib/phlex/reactive/doctor.rb +79 -11
  26. data/lib/phlex/reactive/engine.rb +16 -2
  27. data/lib/phlex/reactive/inspector/report.rb +140 -0
  28. data/lib/phlex/reactive/inspector.rb +253 -0
  29. data/lib/phlex/reactive/log_subscriber.rb +10 -0
  30. data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
  31. data/lib/phlex/reactive/mcp/runner.rb +24 -0
  32. data/lib/phlex/reactive/mcp/server.rb +47 -0
  33. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
  34. data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
  35. data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
  36. data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
  37. data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
  38. data/lib/phlex/reactive/mcp.rb +58 -0
  39. data/lib/phlex/reactive/reply.rb +18 -0
  40. data/lib/phlex/reactive/response.rb +47 -2
  41. data/lib/phlex/reactive/streamable.rb +5 -1
  42. data/lib/phlex/reactive/version.rb +1 -1
  43. data/lib/phlex/reactive.rb +253 -3
  44. data/lib/tasks/phlex_reactive.rake +28 -0
  45. metadata +22 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86eeeabfa81b914dae1f3598e3c879892478c712afc5631b500b96cbd9b83757
4
- data.tar.gz: d72882a3b92db0f2d542b5ef457a13f9e74fda538cb3ee5db7d2ac5916833515
3
+ metadata.gz: 2d95213b7c5a3711a00f1992f0a1992363c203aa3bd253875f0ceb67db88986f
4
+ data.tar.gz: 93574b53d1e24f5c61d4dca65b65521a32782d5945d99c911484c76ec10ac8ce
5
5
  SHA512:
6
- metadata.gz: 884a4c34c7d26930bea2873bae0cf3a1ec0ff82d796179b01fe493187f8001d36283ea1e22daeaa347ab72ca8e74be31773cbba77bf0db41bb38781eb578c340
7
- data.tar.gz: 4b75f6edec65f1d84954260305d384ad6674e21ee3fc5f51270e7055e7679a7d1bb209783d6678b2c506259bf6d1688c6a67c991cc013b22a6f66961bfec84f8
6
+ metadata.gz: 037cfc4cb78a82f4d98b197b59f1dfe1d9ed7bb9222751207bf0b583d1e8ba89bdb30d1cb79ad5543ad5b7e4e5a84a08cc59e0b463bef0e5e5f36f1c31a60850
7
+ data.tar.gz: d0d5eb3a48674bb0dc93417b3e61911e43c976457b3203a1e45bf59f0e0e5c33ceebc50e458240b8f8f15158ad81bdb98fb53addc47a36865c4b94e490e7fd67
data/CHANGELOG.md CHANGED
@@ -6,8 +6,193 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Changed
10
+
11
+ - **BREAKING: `verify_authorized` is ON by default (#168).** A reactive action
12
+ that completes **without any authorization call now raises**
13
+ `Phlex::Reactive::AuthorizationNotVerified` — **rolling back the transaction**
14
+ (fail-closed, stronger than Pundit's after-the-fact check). This is the
15
+ presence-side complement to `authorization_errors`: a forgotten `authorize!`
16
+ becomes a loud 500 your error tracker sees, not a silent hole. The guard
17
+ detects a call to any `Phlex::Reactive.authorization_methods` name
18
+ (default `%i[authorize! authorize allowed_to?]` — Pundit/CanCanCan/ActionPolicy)
19
+ **or** `mark_authorized!`, made anywhere during the action (a helper the action
20
+ calls counts too). **Three remedies** for each action:
21
+ 1. call your authorization method (`authorize! @record, :update?`);
22
+ 2. call `mark_authorized!` after a bespoke check the interceptor can't see;
23
+ 3. declare `skip_verify_authorized` (whole component) or
24
+ `skip_verify_authorized :action_name` (specific actions) for an
25
+ intentionally public action.
26
+ Turn it off globally with `Phlex::Reactive.verify_authorized = false` (the
27
+ install-generator initializer documents the knob). The `action.phlex_reactive`
28
+ instrumentation event gains a new `:unverified` outcome. A new **advisory**
29
+ doctor check flags mutating actions with no detected authorization call
30
+ (heuristic — a helper may authorize indirectly; never a hard fail).
31
+
9
32
  ### Added
10
33
 
34
+ - **Installable Claude debugging skill + `rails g phlex:reactive:claude` (#168).**
35
+ The gem ships a `phlex-reactive-debugging` skill (the doctor → inventory → find
36
+ → browser `report()` → MCP workflow + a failure table) under `lib/`, and the
37
+ new generator copies it into a host app's `.claude/skills/` and writes the MCP
38
+ server entry to `.mcp.json` — only when absent (it never rewrites an existing
39
+ `.mcp.json`; it prints the snippet instead). A new **Debugging & tooling** docs
40
+ page ties the four surfaces together; the security page documents
41
+ `verify_authorized`; the instrumentation table gains the `:unverified` outcome;
42
+ the README gains a Debugging & tooling section + the config rows.
43
+
44
+ - **On-demand client inspector — `phlex/reactive/inspect` (#168).** A standalone
45
+ JS module (the `confirm.js`/`compute.js` precedent — **zero hot-path cost**, no
46
+ edit to `reactive_controller.js`, loaded only when imported) that scans the live
47
+ DOM and maps every reactive root + bound trigger back to the server
48
+ `Component#action` names. From the browser console:
49
+ `(await import("phlex/reactive/inspect")).report()` prints a `console.table` of
50
+ every reactive root — its `id`, decoded token payload (component class, gid,
51
+ state keys, token version — try/catch base64+JSON decode, degrading to
52
+ `{ opaque: true }` on a Marshal-serialized payload), status attrs, triggers
53
+ (action + event + params + debounce/throttle/confirm), client-only ops,
54
+ computes, the `name`d fields the dispatch would collect, and the
55
+ show/filter/text binding families. Triggers are scoped to the **nearest** root,
56
+ so nested roots aren't double-attributed. The server↔client mapping is
57
+ by-name: `scan()`'s `component` + trigger `action` strings are exactly the
58
+ identifiers `phlex_reactive:actions` and the MCP tools list. Pinned by the
59
+ engine (not preloaded); pure read, never mutates the page.
60
+
61
+ - **Read-only diagnostic MCP server (#168).** `bin/rails phlex_reactive:mcp`
62
+ starts a stdio [MCP](https://modelcontextprotocol.io) server exposing five
63
+ read-only tools — `phlex_reactive_doctor`, `phlex_reactive_components`,
64
+ `phlex_reactive_actions` (optional `component:` filter), `phlex_reactive_find`
65
+ (fuzzy search + Prism method source), `phlex_reactive_config` (redacted) — so
66
+ Claude Code inside a host app can introspect the live reactive registry when
67
+ debugging. The `mcp` gem is **optional and lazy**: it is NOT a gemspec runtime
68
+ dependency; `Phlex::Reactive::MCP.load!` requires it on demand with a helpful
69
+ message when missing (the pgbus pattern), and the gem-dependent tool tree stays
70
+ out of the Zeitwerk autoloader — a host app without `mcp` boots and eager-loads
71
+ unaffected. Every tool is read-only and non-destructive (no arbitrary-query or
72
+ mutation tool) and reports names/paths/schemas only — never a token, secret, or
73
+ runtime state; `phlex_reactive_config` never emits the verifier or
74
+ `secret_key_base`. Consumer `.mcp.json`:
75
+ `{ "mcpServers": { "phlex-reactive": { "command": "bin/rails", "args": ["phlex_reactive:mcp"] } } }`.
76
+ Known constraint: stdio MCP needs a clean stdout — an initializer that `puts`
77
+ breaks the transport (same caveat as pgbus).
78
+
79
+ - **verify_authorized runtime guard (#168).** New
80
+ `Phlex::Reactive::Authorization` (fiber-local tracking window, method
81
+ interception, the enforcement decision), `mark_authorized!` instance helper,
82
+ the `skip_verify_authorized` DSL (registry #6, inherits like the other five),
83
+ and `Phlex::Reactive.verify_authorized` / `authorization_methods` config
84
+ (`defined?`-guarded so an explicit override sticks). See the breaking note
85
+ above for the behavior and remedies.
86
+
87
+ - **Action inventory — `Phlex::Reactive::Inspector` + rake tasks (#168).** A new
88
+ read-only introspection layer that answers "what reactive actions exist in this
89
+ app, where are they defined, and is each authorized?" without grepping.
90
+ `Phlex::Reactive::Inspector.components` discovers every constant-backed reactive
91
+ component from the loaded `Streamable` registry; `.find(query)` fuzzy-matches
92
+ one (exact > prefix > substring > subsequence, on both the demodulized and the
93
+ full name). Each action reports its declared param schema, `file:line`, the full
94
+ `def … end` source (extracted with **Prism**, degrading to `nil` on an
95
+ unreadable/unparseable file — never raising), and a **heuristic** authorization
96
+ status (a Prism scan for a configured authorization method or
97
+ `mark_authorized!` in the body — advisory only, since a helper may authorize
98
+ indirectly). Two shipped rake tasks surface it: `bin/rails
99
+ phlex_reactive:actions` (plain-text table, `FORMAT=json` for tooling) and
100
+ `bin/rails "phlex_reactive:find[query]"` (ranked matches; top match in detail
101
+ with each action's method source). Output is names/paths/schemas only — never
102
+ tokens, secrets, or runtime state (the instrumentation privacy contract
103
+ extended to tooling). `Doctor` now delegates its `constant_backed_component?`
104
+ filter to the Inspector so the endpoint-rebuild predicate lives in one place.
105
+
106
+ - **Deferred reply segments — `reply.defer` (#165).** An expensive part of a
107
+ reply (a cross-aggregate rollup, a report) no longer stalls the actor's
108
+ interaction: `reply.streams(cheap).defer(SessionTotals.new(workout:))`
109
+ returns the cheap streams immediately and streams the real render to the
110
+ SAME actor when it finishes. Keep-content default (the stale value stays
111
+ visible, marked `data-reactive-defer-pending` + `aria-busy` for CSS
112
+ shimmer); `placeholder: true` / a component swaps a skeleton in;
113
+ `morph: true` morphs the arrival (the mode rides INSIDE the signed token).
114
+ Transactional (the directive rides the post-commit reply — a rollback or a
115
+ denied action leaks nothing), actor-scoped (peers keep `broadcast_*_to`),
116
+ superseding (a newer action for the same target aborts the in-flight
117
+ deferred render — no stale paint), and interactive on arrival (fresh action
118
+ token). Delivery is transport-adaptive (`Phlex::Reactive.defer_transport`,
119
+ default `:auto`): a parallel fetch to the new `POST /reactive/defer`
120
+ endpoint everywhere (purpose-scoped, short-TTL defer token —
121
+ `defer_token_ttl`, default 120s; `reply.defer` tokens are **actor-bound**
122
+ to the requesting session so a leaked one can't be redeemed elsewhere,
123
+ `reactive_lazy` shell tokens are unbound by necessity — they render
124
+ before a session exists — with the TTL + `authorize!` as their bound;
125
+ never interchangeable with action tokens),
126
+ or a **durable pgbus one-shot stream + `DeferredRenderJob`** when pgbus's
127
+ reactive Streams and ActiveJob are present (`defer_job_queue` config; the
128
+ durable since-id replay closes the broadcast-before-subscribe race, and the
129
+ broadcast tears down its own subscription). The push lane's one-shot queue is
130
+ reclaimed by pgbus's age-based orphan-stream sweep (**pgbus ≥ 0.9.10**; run
131
+ the Dispatcher with `streams_orphan_threshold` set); we never eager-drop it
132
+ (that would reopen the delivery race). The one-shot key is sized to the live
133
+ pgbus `queue_prefix` budget, so a non-default prefix can't overflow it; the
134
+ render job broadcasts a cleanup on ANY failure so the actor's pending state
135
+ always resolves. Every capability gap degrades to the fetch lane — the
136
+ Action-Cable-or-pgbus invariant holds. **Profile first:** an app-side N+1
137
+ looks exactly like framework lag; defer is for segments that are genuinely
138
+ expensive after the synchronous path is cheap.
139
+
140
+ - **Lazy initial mount — `reactive_lazy` (#165).** The same machinery for the
141
+ FIRST render (Livewire `#[Lazy]`): the page ships the component's
142
+ placeholder shell (`deferred_placeholder`, or a built-in pending shell) with
143
+ the defer token on the root; the client fetches the real content on connect
144
+ AND after a Turbo page-refresh morph (so a lazy component survives a
145
+ `turbo:reload`). `reactive_lazy tag: :tr` (etc.) ships a shell element that
146
+ matches a `<tr>`/`<li>` root instead of an invalid `<div>`.
147
+ Reactive-machinery renders (an action's self-replace, broadcasts, the defer
148
+ endpoint/job) stay REAL, so actions never pay two round trips.
149
+
150
+ - **pgbus capability gates.** `Phlex::Reactive.pgbus?` and `.pgbus_streams?`
151
+ (the documented broadcast-accepts-`:exclude` probe, now actually
152
+ implemented) plus `.defer_push_capable?` for the defer push lane.
153
+
154
+ - **Client-side option filtering — `reactive_filter` (#163).** The other half
155
+ of #72's combobox: **preload the options, type to narrow — zero round
156
+ trips.** Spread `reactive_filter(input:, option:, group:, empty:)` onto the
157
+ root and the generic controller shows/hides each option on every keystroke by
158
+ a case-folded substring match against its `data-reactive-filter-text`
159
+ haystack (falling back to the option's own text) — no POST, no token, no
160
+ bespoke per-feature Stimulus controller. Optional `group:` collapses a header
161
+ whose every contained option is hidden; optional `empty:` reveals a
162
+ no-matches node at 0 visible. Selectors resolve within the root only (nested
163
+ reactive roots untouched), state seeds at connect and re-applies after a
164
+ morph, and blank selectors raise at render (a dead binding must fail loudly).
165
+ Composes with keyboard nav and per-row selection: a filtered-out option also
166
+ drops out of the Arrow-key path and loses its highlight, so Enter can never
167
+ pick an invisible row — selection itself stays a signed `on(:select)` action.
168
+ - **Standalone combobox keyboard nav — `reactive_listnav` (#163).** The same
169
+ Arrow/Enter/Escape wiring `on(…, listnav:)` appends, without the dispatch
170
+ descriptor — for the preload-and-filter input that fires **no** action (an
171
+ `on()` trigger would POST per keystroke). Spread
172
+ `reactive_listnav("[role=option]")` onto the input; Enter still picks by
173
+ clicking the highlighted option's own signed trigger.
174
+ - **Cross-root `reactive_show` targets — `reactive_show_targets` (#164).** A
175
+ field can now drive the visibility of declared elements **outside** its
176
+ reactive root — the nav tab, the panel in another tab pane, the sidebar note
177
+ a mode selector governs — the visibility parallel to the #159 cross-root
178
+ text mirror. The component that **owns** the field declares which outside
179
+ ids it governs, spread on the root:
180
+ `mix(reactive_root, reactive_show_targets(:mode, "#advanced-tab" =>
181
+ { equals: "advanced" }, "#basic-note" => { not: "advanced" }))`. Same
182
+ posture as `mirror:`: opt-in and declared, never implicit (a plain
183
+ `reactive_show` stays root-isolated, #15 untouched); targets are **single id
184
+ selectors only** — a class/compound selector raises at declare time AND is
185
+ warn-and-skipped by the client (two-sided default-deny); the predicate is
186
+ the same literal-only `reactive_show` vocabulary; the toggle is `hidden`
187
+ only. The field read stays **owned** — you can only drive outside visibility
188
+ from a field the declaring root owns. A target id not on the page is
189
+ silently skipped (an unrendered tab pane is normal); the cross-root pass
190
+ shares the owned-binding pass's field-read memo (one read per field per
191
+ sync). No map declared → one `getAttribute` and out. **One call per root**:
192
+ Phlex `mix` space-joins duplicate string data values, so a second call's
193
+ JSON would corrupt the attr (the client warns and ignores it) — several
194
+ fields go in one call via the hash form
195
+ (`reactive_show_targets(mode: { … }, kind: { … })`).
11
196
  - **Value-conditional visibility — `reactive_show` (#161).** The `x-show` /
12
197
  `data-show` / `wire:show` case — show/hide an element from a form field's
13
198
  **current value** — no longer needs a hand-written `change`-listener Stimulus
@@ -45,6 +230,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
45
230
  can opt out of the reply's target-root scope to document-wide resolution
46
231
  (previously it was silently ignored when a `target` was set).
47
232
 
233
+ ### Performance
234
+
235
+ - The defer machinery adds nothing measurable to the existing hot paths
236
+ (same-machine, same-checkout before/after vs `main`): `to_stream_replace`
237
+ 14.4k → 14.2k i/s (within ±3.7% noise), identity token 199.9k → 196.7k i/s
238
+ (state-backed) / 93.7k → 93.5k (record-backed), allocations byte-identical.
239
+ New `benchmark/micro/defer_token.rb`: `sign_defer` ~120k i/s, `verify_defer`
240
+ ~99k i/s, full fetch-lane directive build ~11 µs/segment, 0 retained. Defer
241
+ is a **latency-shape** change, not a throughput one — the A/B spec
242
+ (`spec/requests/deferred_latency_spec.rb`) pins that a 120 ms segment cost
243
+ moves OFF the actor's reply and onto the deferred leg; it never disappears.
244
+
48
245
  ## [0.9.0] - 2026-07-03
49
246
 
50
247
  Consolidates everything since 0.2.6; tags 0.2.7–0.4.8 shipped without changelog
data/README.md CHANGED
@@ -189,6 +189,33 @@ If reactive elements are on the page but the controller never connected, the
189
189
  runtime logs a console warning (`[phlex-reactive] found N element(s) with
190
190
  data-controller="reactive" but the reactive controller never connected …`).
191
191
 
192
+ ### Debugging & tooling
193
+
194
+ Four read-only introspection surfaces answer "what's reactive, where is it
195
+ defined, is it authorized, and what does this page POST?" — plus an installable
196
+ Claude Code toolkit. See the [Debugging & tooling
197
+ guide](https://phlex-reactive.zoolutions.llc/docs/tooling) for the full workflow.
198
+
199
+ ```bash
200
+ bin/rails phlex_reactive:doctor # validate the whole install (✓/✗/? + a fix each)
201
+ bin/rails phlex_reactive:actions # every component × action: params, file:line, auth
202
+ bin/rails "phlex_reactive:find[counter]" # fuzzy-find one; prints each action's method source
203
+ bin/rails phlex_reactive:mcp # a read-only MCP server (needs `gem "mcp"`)
204
+ ```
205
+
206
+ In the browser console, map every reactive root + trigger on the page back to its
207
+ server `Component#action` names (a standalone module — zero cost until imported):
208
+
209
+ ```js
210
+ (await import("phlex/reactive/inspect")).report()
211
+ ```
212
+
213
+ Install the debugging skill + MCP config for Claude Code in one command:
214
+
215
+ ```bash
216
+ rails g phlex:reactive:claude
217
+ ```
218
+
192
219
  ---
193
220
 
194
221
  ## The mental model in one picture
@@ -339,6 +366,8 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
339
366
  | `reactive_record :name` | Record-backed identity (GlobalID). State = the DB. Also defaults `#id` to `dom_id(record)`. |
340
367
  | `reactive_state :a, :b` | Signed instance-var identity. Standalone, or combined with `reactive_record` to sign transient UI state alongside the row. |
341
368
  | `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
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
+ | `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). |
342
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. |
343
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. |
344
373
  | `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
@@ -359,6 +388,9 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
359
388
  | `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
360
389
  | `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
390
  | `reactive_show(:field, equals:/not:/in:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the named field's **current value**, client-only, zero round trip. One literal predicate: `equals:`, `not:`, or `in: [...]`; `equals: true` reads a checkbox's checked state. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
391
+ | `reactive_show_targets(:field, "#id" => { equals: … })` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form (`reactive_show_targets(mode: { … }, kind: { … })`). Id selectors only (raise at render + client warn-skip); same literal predicates; toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
392
+ | `reactive_filter(input:, option:, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root — typing in the named input shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. Optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
393
+ | `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. |
362
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. |
363
395
  | `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). |
364
396
  | `reactive_root(track_dirty: true, warn_unsaved: true)` / `reactive_field(:param, dirty: true)` | **Dirty tracking** against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-dirty--track_dirty--warn_unsaved). |
@@ -905,7 +937,45 @@ end
905
937
  real `input` event, so a *derived* value can drive visibility too.
906
938
  - Presentational only, strictly weaker than the js ops: it reads an owned
907
939
  field and toggles `hidden` on an owned element — no `innerHTML`, no
908
- attribute freedom, no cross-root writes.
940
+ attribute freedom. Cross-root writes take the **declared** escape below.
941
+
942
+ **Cross-root targets (`reactive_show_targets`).** A plain `reactive_show` is
943
+ root-scoped by design — but "a mode selector reveals dependent sections
944
+ elsewhere on the page" (a nav tab, a panel in a *different* tab pane, a sticky
945
+ sidebar note) routinely puts the dependents **outside** the control's root.
946
+ `reactive_show_targets` is the declared escape, the visibility parallel of the
947
+ [cross-root text mirror](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root):
948
+ the component that **owns** the field declares which outside ids it governs,
949
+ spread on the **root**:
950
+
951
+ ```ruby
952
+ div(**mix(reactive_root, reactive_show_targets(:mode,
953
+ "#advanced-tab" => { equals: "advanced" },
954
+ "#advanced-panel" => { equals: "advanced" },
955
+ "#basic-note" => { not: "advanced" }))) do
956
+ select(name: "mode") { mode_options }
957
+ # …
958
+ end
959
+ ```
960
+
961
+ Same posture as `mirror:`: **opt-in and declared, never implicit** — a plain
962
+ `reactive_show` stays root-isolated; targets are **single id selectors only**
963
+ (a class/compound selector raises at render AND is warn-and-skipped by the
964
+ client — two-sided default-deny); the predicate is the same literal-only
965
+ vocabulary; and the toggle is `hidden` only. The field read stays **owned** —
966
+ you can only drive outside visibility from a field the declaring root owns. A
967
+ target id not on the page is silently skipped, so a target inside an
968
+ unrendered tab pane is fine.
969
+
970
+ **One call per root.** Phlex `mix` space-joins duplicate string `data:`
971
+ values, so a *second* `reactive_show_targets` call on the same root would
972
+ concatenate two JSON payloads into an unparseable attribute (the client warns
973
+ and ignores it). Several fields go in **one call** via the hash form:
974
+
975
+ ```ruby
976
+ reactive_show_targets(mode: { "#advanced-tab" => { equals: "advanced" } },
977
+ kind: { "#premium-note" => { not: "basic" } })
978
+ ```
909
979
 
910
980
  ### Client-side computes (`reactive_compute` + `reactive_text`)
911
981
 
@@ -1023,6 +1093,58 @@ Enter **clicks the highlighted option** — so selection runs through its normal
1023
1093
  Escape clears the highlight. Only the highlight is client-side — the selection
1024
1094
  stays a real signed action, and the highlight is never shipped as trusted state.
1025
1095
 
1096
+ ### Client-side option filtering (`reactive_filter`)
1097
+
1098
+ `listnav:` is the keyboard half of a combobox; `reactive_filter` is the other
1099
+ half: **preload the options, type to narrow — zero round trips.** For a small,
1100
+ static catalog a server search per keystroke is pure latency (the data was
1101
+ already known at first paint), and a bespoke hide/show Stimulus controller is
1102
+ exactly the per-feature JS this gem exists to remove. Declare the filter on the
1103
+ root instead:
1104
+
1105
+ ```ruby
1106
+ div(**mix(reactive_root, reactive_filter(
1107
+ input: "#exercise-search", # the input whose value drives the filter
1108
+ option: "[role=option]", # the elements to show/hide
1109
+ group: "[data-filter-group]", # optional: collapse a header when all its options hide
1110
+ empty: "#no-matches" # optional: reveal when 0 options match
1111
+ ))) do
1112
+ # STANDALONE keyboard nav — no action on the input, so typing never POSTs.
1113
+ input(id: "exercise-search", type: "search", **reactive_listnav("[role=option]"))
1114
+
1115
+ categories.each do |category, exercises|
1116
+ div(data: { filter_group: "" }) do
1117
+ h3 { category }
1118
+ exercises.each do |exercise|
1119
+ # The haystack is server-rendered — pack in synonyms/categories.
1120
+ button(**mix(
1121
+ on(:select, id: exercise.id),
1122
+ role: "option",
1123
+ data: { reactive_filter_text: exercise.search_text }
1124
+ )) { exercise.name }
1125
+ end
1126
+ end
1127
+ end
1128
+
1129
+ div(id: "no-matches", hidden: true) { "No matches" }
1130
+ end
1131
+ ```
1132
+
1133
+ On every keystroke the generic controller lowercases the input's value and
1134
+ toggles `hidden` on each option by a **substring match** against its
1135
+ `data-reactive-filter-text` haystack (falling back to the option's own text) —
1136
+ a declared literal match, never an expression, so there is no eval surface. A
1137
+ group whose every contained option is hidden collapses with them; the empty
1138
+ node reveals at 0 visible. Everything resolves **within this root only** and
1139
+ re-applies after a morph.
1140
+
1141
+ It composes: `reactive_listnav` gives the input Arrow/Enter/Escape **without an
1142
+ action** (Arrow keys skip filtered-out options; `on(…, listnav:)` requires a
1143
+ dispatching trigger — wrong for an input that must never POST), and each option
1144
+ stays its own signed `on(:select)` trigger. Only *filtering* is client-side —
1145
+ selection still round-trips as a real signed action. Blank selectors raise at
1146
+ render: a dead binding must fail loudly, not no-op in the browser.
1147
+
1026
1148
  **Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
1027
1149
  a hash that includes a `data:` key. Spreading them *and* passing another `data:`
1028
1150
  (or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge. For the
@@ -1169,6 +1291,7 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
1169
1291
  | `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
1170
1292
  | `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) |
1171
1293
  | `.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) |
1294
+ | `.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) |
1172
1295
  | `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |
1173
1296
 
1174
1297
  `.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's
@@ -1178,6 +1301,155 @@ update only the targets you name) and refreshes the token via a tiny inert
1178
1301
  `reactive:token` stream instead — the token rolls forward without re-rendering
1179
1302
  (and clobbering) the component's live inputs.
1180
1303
 
1304
+ #### Deferred segments (`reply.defer` + `reactive_lazy`)
1305
+
1306
+ > **Profile first.** An app-side N+1 or a missing eager-load *looks exactly
1307
+ > like framework lag* — a scoreboard re-rendering on every debounced keystroke
1308
+ > once "felt slow" here, and the real cause was `2 + N` queries per keystroke,
1309
+ > fixed with one eager load and no gem change. Make the synchronous path cheap
1310
+ > before you make it async; reach for `defer` only when a reply segment is
1311
+ > **genuinely** expensive (a cross-aggregate rollup, a report, an external call).
1312
+
1313
+ Everything in a `reply` renders synchronously on the request thread, so one
1314
+ expensive segment stalls the actor's whole interaction. `reply.defer` takes it
1315
+ off the critical path — the actor's reply returns immediately and the real
1316
+ HTML streams to **that same actor** the moment the render finishes:
1317
+
1318
+ ```ruby
1319
+ action :update, params: { weight_kg: :float, reps: :integer, rpe: :float }
1320
+ def update(weight_kg:, reps:, rpe:)
1321
+ authorize! @set, :update?
1322
+ @set.update!(weight_kg:, reps:, rpe:)
1323
+
1324
+ reply
1325
+ .streams(volume_cell_stream) # instant, cheap — synchronous
1326
+ .defer(SessionTotals.new(workout: @workout)) # expensive — deferred
1327
+ end
1328
+ ```
1329
+
1330
+ Be honest about the trade: **defer improves the actor's reply latency and makes
1331
+ time-to-full-content slightly worse** (one extra hop). It moves cost off the
1332
+ critical path; it never removes it.
1333
+
1334
+ While the deferred render is pending, the target keeps its current (stale)
1335
+ content and carries `data-reactive-defer-pending` + `aria-busy` — style the
1336
+ window in pure CSS:
1337
+
1338
+ ```css
1339
+ [data-reactive-defer-pending] { opacity: .5; }
1340
+ ```
1341
+
1342
+ Options:
1343
+
1344
+ ```ruby
1345
+ reply.defer(comp) # keep-content default (above)
1346
+ reply.defer(comp, placeholder: true) # comp's deferred_placeholder, or a built-in shell
1347
+ reply.defer(comp, placeholder: Skeleton.new) # explicit skeleton (component, or an html_safe string)
1348
+ reply.defer(comp, morph: true) # arrival morphs in place (mode rides INSIDE the signed token)
1349
+ ```
1350
+
1351
+ `deferred_placeholder` (optional, on the deferred component) returns a Phlex
1352
+ component instance, an `html_safe` string, or a plain string (escaped — data,
1353
+ not markup).
1354
+
1355
+ Semantics you can rely on:
1356
+
1357
+ - **Transactional** — the directive rides the reply, which only renders after
1358
+ the action's transaction committed; a rollback or a denied action leaks no
1359
+ deferred render (and enqueues no job).
1360
+ - **Actor-scoped** — the deferred render reaches only the actor; peers keep
1361
+ getting updates via `broadcast_*_to` (use both when both need the value).
1362
+ - **Superseding** — a newer action for the same target aborts the in-flight
1363
+ deferred render; a fast typist never gets stale totals painted over fresh ones.
1364
+ - **Interactive on arrival** — the streamed root carries a fresh action token.
1365
+ - **Failure-visible** — a failed deferred fetch clears the pending state, sets
1366
+ `data-reactive-error="defer"`, and emits `reactive:error` with a `retry()`;
1367
+ `render?` false resolves to a 204 (pending cleared, content kept).
1368
+
1369
+ **Delivery** is transport-adaptive (`Phlex::Reactive.defer_transport`, default
1370
+ `:auto`): a parallel authenticated fetch to `POST /reactive/defer` everywhere
1371
+ (carrying a purpose-scoped, short-TTL signed identity token —
1372
+ `defer_token_ttl`, default 120s; an action token is rejected at the defer
1373
+ endpoint and vice versa), or — when pgbus's reactive Streams **and** ActiveJob
1374
+ are present — a **durable one-shot pgbus stream** rendered by
1375
+ `Phlex::Reactive::DeferredRenderJob` off the request thread
1376
+ (`defer_job_queue` config; the durable replay closes the
1377
+ broadcast-before-subscribe race). `:fetch` forces the fetch lane; `:stream`
1378
+ requests push and degrades to fetch with a warning when the capability is
1379
+ absent. Both lanes are invisible to your action code.
1380
+
1381
+ > **The push lane's queue lifecycle.** Each deferred segment on the push lane
1382
+ > mints a durable one-shot pgbus stream. Its queue is reclaimed by pgbus's
1383
+ > **age-based orphan-stream sweep** (pgbus **≥ 0.9.10**) — ensure the pgbus
1384
+ > **Dispatcher is running** with `streams_orphan_threshold` set (its default).
1385
+ > We do **not** drop the queue from the render job: an eager drop would destroy
1386
+ > a not-yet-consumed message and reopen the very broadcast-before-subscribe race
1387
+ > the durable lane exists to close. On pgbus ≤ 0.9.9 the sweep only reaped
1388
+ > *empty* queues (which a durable stream never becomes), so a one-shot queue
1389
+ > leaked — upgrade to ≥ 0.9.10, or stay on the pull lane
1390
+ > (`defer_transport = :fetch`, the universal default, which needs no cleanup).
1391
+
1392
+ **Security of the defer token.** The defer endpoint re-renders the real
1393
+ component, whose fresh root carries a normal (non-expiring) action token — so a
1394
+ defer token is, within its TTL, a render of that identity and a path to that
1395
+ identity's action token. What bounds the damage in every case: **the signature
1396
+ proves identity, not permission** — the harvested action token is useless
1397
+ against `authorize!` in the action, which re-checks the *current* actor.
1398
+ Authorize every mutating action; the token, defer or otherwise, is never the
1399
+ authority.
1400
+
1401
+ The two defer-token channels are bound differently, by their leak surface:
1402
+
1403
+ - **`reply.defer` tokens** ride an **action's HTTP response** (which can transit
1404
+ a logging proxy, a shared HAR, an APM that captures bodies) — the real
1405
+ cross-infrastructure leak vector. They are **actor-bound**: signed under the
1406
+ requesting session (`Phlex::Reactive.defer_binding_for(request)`, the
1407
+ persisted session id by default — override to bind to your own actor identity,
1408
+ e.g. a user id), so a leaked one can't be redeemed in another session.
1409
+ - **`reactive_lazy` shell tokens** live in the **page HTML the actor already
1410
+ fetched** over their own session (a small leak surface) — and *can't* be
1411
+ actor-bound anyway, because the shell renders on a fresh visit before the
1412
+ session exists (Rails establishes it during that response). They are minted
1413
+ **unbound**; the TTL + `authorize!` are their bound.
1414
+
1415
+ Apps with no persisted session (the `ActionController::Base` default, a
1416
+ token-auth API) mint every defer token unbound — there too the TTL +
1417
+ `authorize!` are the whole bound. Set `Phlex::Reactive.base_controller_name` to
1418
+ a session-bearing controller, or override `defer_binding_for`, to bind
1419
+ `reply.defer` tokens to your actor identity.
1420
+
1421
+ **Lazy initial mount** — the same machinery for the *first* render
1422
+ (Livewire's `#[Lazy]`): declare `reactive_lazy` and the page ships the
1423
+ placeholder shell; the client fetches the real content on connect. Every
1424
+ reactive-machinery render (an action's self-replace, broadcasts, the defer
1425
+ endpoint) stays REAL, so actions never pay two round trips:
1426
+
1427
+ ```ruby
1428
+ class SessionTotals < ApplicationComponent
1429
+ include Phlex::Reactive::Component
1430
+
1431
+ reactive_record :workout
1432
+ reactive_lazy # first render = placeholder shell
1433
+ # reactive_lazy tag: :tr # for a <tr>/<li> root the shell must match
1434
+
1435
+ def deferred_placeholder = TotalsSkeleton.new # optional
1436
+ end
1437
+ ```
1438
+
1439
+ The lazy shell's `<div>` root would be invalid inside `<tbody>`/`<ul>`, so a
1440
+ component whose real root is a `<tr>`/`<li>` sets `reactive_lazy tag: :tr` (etc.)
1441
+ to ship a matching shell element. The client re-fetches the real content both on
1442
+ connect AND after a Turbo page-refresh **morph** (which re-shows the shell while
1443
+ keeping the element connected), so a lazy component survives a `turbo:reload`.
1444
+
1445
+ > **One edge case:** a `reply.defer(placeholder:)` shell (the action-driven,
1446
+ > not page-mount, form) carries no token of its own — the transient directive
1447
+ > owns its delivery. If a page is snapshotted by Turbo mid-defer and later
1448
+ > restored from cache, that placeholder can appear stuck (the directive that
1449
+ > would have filled it is long gone). It self-corrects on the next action;
1450
+ > deferred **content** is never lost server-side. Lazy mounts (which carry the
1451
+ > token on the shell) don't have this — they re-fetch on restore.
1452
+
1181
1453
  #### Flash levels
1182
1454
 
1183
1455
  The level reaches the wire (issue #77). **String** content is wrapped in a
@@ -1597,6 +1869,14 @@ Phlex::Reactive.base_controller_name = "ApplicationController"
1597
1869
  Phlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]
1598
1870
  # or: [ActionPolicy::Unauthorized]
1599
1871
 
1872
+ # verify_authorized (ON by default): an action that authorizes NOTHING raises
1873
+ # AuthorizationNotVerified inside the transaction (the mutation rolls back —
1874
+ # fail-closed). Satisfy it by calling one of authorization_methods, calling
1875
+ # mark_authorized! after a bespoke check, or declaring skip_verify_authorized on
1876
+ # a genuinely public component/action. Set the method names to match your library:
1877
+ Phlex::Reactive.authorization_methods = %i[authorize! authorize allowed_to?]
1878
+ # Phlex::Reactive.verify_authorized = false # turn the guard off (not recommended)
1879
+
1600
1880
  # Use your ApplicationController to render components (app helpers / Current):
1601
1881
  Phlex::Reactive.renderer = ApplicationController
1602
1882
 
@@ -1871,7 +2151,7 @@ events, all under the `phlex_reactive` namespace:
1871
2151
 
1872
2152
  | Event | Fires | Payload |
1873
2153
  |-------|-------|---------|
1874
- | `action.phlex_reactive` | once per request | `component`, `action`, `outcome` (`ok`/`denied_undeclared`/`invalid_token`/`not_found`/`unauthorized`) |
2154
+ | `action.phlex_reactive` | once per request | `component`, `action`, `outcome` (`ok`/`denied_undeclared`/`invalid_token`/`not_found`/`unauthorized`/`unverified`) |
1875
2155
  | `render.phlex_reactive` | per component render | `component`, `bytesize` |
1876
2156
  | `broadcast.phlex_reactive` | per `broadcast_*_to` (Action Cable **and** pgbus) | `component`, `stream_action`, `streamables` |
1877
2157