phlex-reactive 0.9.3 → 0.9.5

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 +212 -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 +659 -10
  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 +348 -21
  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: 9e6f3431087aff47f32d503174b5242c51af5fce1bbdd7832d40a466a5c3a548
4
+ data.tar.gz: 7bed02791a8b41163bd7b237aa1bb2730ff99496450783eae712bbcb1ae46052
5
5
  SHA512:
6
- metadata.gz: 884a4c34c7d26930bea2873bae0cf3a1ec0ff82d796179b01fe493187f8001d36283ea1e22daeaa347ab72ca8e74be31773cbba77bf0db41bb38781eb578c340
7
- data.tar.gz: 4b75f6edec65f1d84954260305d384ad6674e21ee3fc5f51270e7055e7679a7d1bb209783d6678b2c506259bf6d1688c6a67c991cc013b22a6f66961bfec84f8
6
+ metadata.gz: f66d90ab48f9210f0bfddf08f14b2a86593118af903574432592b408920656f3d025cd7a8a067b27faf2c42b8456e8e802caf93980cfa967b3fd69eed0c80646
7
+ data.tar.gz: 6e449424886a2375fc5b64788077086eb7274536eb72d843c9dc5ee0d107e688f573f1a723a283d806dac2897bf55b9e9f432f472bb157360c7d1931aff6aab3
data/CHANGELOG.md CHANGED
@@ -6,8 +6,208 @@ 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
+ - **Compound & numeric `reactive_show` predicates — `all:`/`any:` and
35
+ `gte:`/`gt:`/`lte:`/`lt:` (#176).** Value-conditional visibility now spans
36
+ **more than one field** and **numeric thresholds**, staying inside the
37
+ eval-free "declared literal predicate" contract. `all:` / `any:` fold a list of
38
+ per-field terms (`{ field:, equals:/not:/in:/gte:/… }`) with one fixed
39
+ connective — AND vs OR over the same literal vocabulary, no expression surface;
40
+ one flat binding replaces wrapper-div nesting and is the only way to express OR.
41
+ `gte:`/`gt:`/`lte:`/`lt:` compare `Number(value)` against a literal number baked
42
+ into the binding (the RHS must be a real `Numeric` — a typo fails at render); a
43
+ non-numeric field value is `NaN` → hidden, the safe reveal-on-threshold default.
44
+ Numeric predicates work standalone, as a compound term, and inside a
45
+ `reactive_show_targets` map. Malformed terms fold **false** (fail-closed:
46
+ default-deny). The single-field `reactive_show(:field, equals:)` form is
47
+ unchanged; the additions are backwards compatible.
48
+
49
+ - **Installable Claude debugging skill + `rails g phlex:reactive:claude` (#168).**
50
+ The gem ships a `phlex-reactive-debugging` skill (the doctor → inventory → find
51
+ → browser `report()` → MCP workflow + a failure table) under `lib/`, and the
52
+ new generator copies it into a host app's `.claude/skills/` and writes the MCP
53
+ server entry to `.mcp.json` — only when absent (it never rewrites an existing
54
+ `.mcp.json`; it prints the snippet instead). A new **Debugging & tooling** docs
55
+ page ties the four surfaces together; the security page documents
56
+ `verify_authorized`; the instrumentation table gains the `:unverified` outcome;
57
+ the README gains a Debugging & tooling section + the config rows.
58
+
59
+ - **On-demand client inspector — `phlex/reactive/inspect` (#168).** A standalone
60
+ JS module (the `confirm.js`/`compute.js` precedent — **zero hot-path cost**, no
61
+ edit to `reactive_controller.js`, loaded only when imported) that scans the live
62
+ DOM and maps every reactive root + bound trigger back to the server
63
+ `Component#action` names. From the browser console:
64
+ `(await import("phlex/reactive/inspect")).report()` prints a `console.table` of
65
+ every reactive root — its `id`, decoded token payload (component class, gid,
66
+ state keys, token version — try/catch base64+JSON decode, degrading to
67
+ `{ opaque: true }` on a Marshal-serialized payload), status attrs, triggers
68
+ (action + event + params + debounce/throttle/confirm), client-only ops,
69
+ computes, the `name`d fields the dispatch would collect, and the
70
+ show/filter/text binding families. Triggers are scoped to the **nearest** root,
71
+ so nested roots aren't double-attributed. The server↔client mapping is
72
+ by-name: `scan()`'s `component` + trigger `action` strings are exactly the
73
+ identifiers `phlex_reactive:actions` and the MCP tools list. Pinned by the
74
+ engine (not preloaded); pure read, never mutates the page.
75
+
76
+ - **Read-only diagnostic MCP server (#168).** `bin/rails phlex_reactive:mcp`
77
+ starts a stdio [MCP](https://modelcontextprotocol.io) server exposing five
78
+ read-only tools — `phlex_reactive_doctor`, `phlex_reactive_components`,
79
+ `phlex_reactive_actions` (optional `component:` filter), `phlex_reactive_find`
80
+ (fuzzy search + Prism method source), `phlex_reactive_config` (redacted) — so
81
+ Claude Code inside a host app can introspect the live reactive registry when
82
+ debugging. The `mcp` gem is **optional and lazy**: it is NOT a gemspec runtime
83
+ dependency; `Phlex::Reactive::MCP.load!` requires it on demand with a helpful
84
+ message when missing (the pgbus pattern), and the gem-dependent tool tree stays
85
+ out of the Zeitwerk autoloader — a host app without `mcp` boots and eager-loads
86
+ unaffected. Every tool is read-only and non-destructive (no arbitrary-query or
87
+ mutation tool) and reports names/paths/schemas only — never a token, secret, or
88
+ runtime state; `phlex_reactive_config` never emits the verifier or
89
+ `secret_key_base`. Consumer `.mcp.json`:
90
+ `{ "mcpServers": { "phlex-reactive": { "command": "bin/rails", "args": ["phlex_reactive:mcp"] } } }`.
91
+ Known constraint: stdio MCP needs a clean stdout — an initializer that `puts`
92
+ breaks the transport (same caveat as pgbus).
93
+
94
+ - **verify_authorized runtime guard (#168).** New
95
+ `Phlex::Reactive::Authorization` (fiber-local tracking window, method
96
+ interception, the enforcement decision), `mark_authorized!` instance helper,
97
+ the `skip_verify_authorized` DSL (registry #6, inherits like the other five),
98
+ and `Phlex::Reactive.verify_authorized` / `authorization_methods` config
99
+ (`defined?`-guarded so an explicit override sticks). See the breaking note
100
+ above for the behavior and remedies.
101
+
102
+ - **Action inventory — `Phlex::Reactive::Inspector` + rake tasks (#168).** A new
103
+ read-only introspection layer that answers "what reactive actions exist in this
104
+ app, where are they defined, and is each authorized?" without grepping.
105
+ `Phlex::Reactive::Inspector.components` discovers every constant-backed reactive
106
+ component from the loaded `Streamable` registry; `.find(query)` fuzzy-matches
107
+ one (exact > prefix > substring > subsequence, on both the demodulized and the
108
+ full name). Each action reports its declared param schema, `file:line`, the full
109
+ `def … end` source (extracted with **Prism**, degrading to `nil` on an
110
+ unreadable/unparseable file — never raising), and a **heuristic** authorization
111
+ status (a Prism scan for a configured authorization method or
112
+ `mark_authorized!` in the body — advisory only, since a helper may authorize
113
+ indirectly). Two shipped rake tasks surface it: `bin/rails
114
+ phlex_reactive:actions` (plain-text table, `FORMAT=json` for tooling) and
115
+ `bin/rails "phlex_reactive:find[query]"` (ranked matches; top match in detail
116
+ with each action's method source). Output is names/paths/schemas only — never
117
+ tokens, secrets, or runtime state (the instrumentation privacy contract
118
+ extended to tooling). `Doctor` now delegates its `constant_backed_component?`
119
+ filter to the Inspector so the endpoint-rebuild predicate lives in one place.
120
+
121
+ - **Deferred reply segments — `reply.defer` (#165).** An expensive part of a
122
+ reply (a cross-aggregate rollup, a report) no longer stalls the actor's
123
+ interaction: `reply.streams(cheap).defer(SessionTotals.new(workout:))`
124
+ returns the cheap streams immediately and streams the real render to the
125
+ SAME actor when it finishes. Keep-content default (the stale value stays
126
+ visible, marked `data-reactive-defer-pending` + `aria-busy` for CSS
127
+ shimmer); `placeholder: true` / a component swaps a skeleton in;
128
+ `morph: true` morphs the arrival (the mode rides INSIDE the signed token).
129
+ Transactional (the directive rides the post-commit reply — a rollback or a
130
+ denied action leaks nothing), actor-scoped (peers keep `broadcast_*_to`),
131
+ superseding (a newer action for the same target aborts the in-flight
132
+ deferred render — no stale paint), and interactive on arrival (fresh action
133
+ token). Delivery is transport-adaptive (`Phlex::Reactive.defer_transport`,
134
+ default `:auto`): a parallel fetch to the new `POST /reactive/defer`
135
+ endpoint everywhere (purpose-scoped, short-TTL defer token —
136
+ `defer_token_ttl`, default 120s; `reply.defer` tokens are **actor-bound**
137
+ to the requesting session so a leaked one can't be redeemed elsewhere,
138
+ `reactive_lazy` shell tokens are unbound by necessity — they render
139
+ before a session exists — with the TTL + `authorize!` as their bound;
140
+ never interchangeable with action tokens),
141
+ or a **durable pgbus one-shot stream + `DeferredRenderJob`** when pgbus's
142
+ reactive Streams and ActiveJob are present (`defer_job_queue` config; the
143
+ durable since-id replay closes the broadcast-before-subscribe race, and the
144
+ broadcast tears down its own subscription). The push lane's one-shot queue is
145
+ reclaimed by pgbus's age-based orphan-stream sweep (**pgbus ≥ 0.9.10**; run
146
+ the Dispatcher with `streams_orphan_threshold` set); we never eager-drop it
147
+ (that would reopen the delivery race). The one-shot key is sized to the live
148
+ pgbus `queue_prefix` budget, so a non-default prefix can't overflow it; the
149
+ render job broadcasts a cleanup on ANY failure so the actor's pending state
150
+ always resolves. Every capability gap degrades to the fetch lane — the
151
+ Action-Cable-or-pgbus invariant holds. **Profile first:** an app-side N+1
152
+ looks exactly like framework lag; defer is for segments that are genuinely
153
+ expensive after the synchronous path is cheap.
154
+
155
+ - **Lazy initial mount — `reactive_lazy` (#165).** The same machinery for the
156
+ FIRST render (Livewire `#[Lazy]`): the page ships the component's
157
+ placeholder shell (`deferred_placeholder`, or a built-in pending shell) with
158
+ the defer token on the root; the client fetches the real content on connect
159
+ AND after a Turbo page-refresh morph (so a lazy component survives a
160
+ `turbo:reload`). `reactive_lazy tag: :tr` (etc.) ships a shell element that
161
+ matches a `<tr>`/`<li>` root instead of an invalid `<div>`.
162
+ Reactive-machinery renders (an action's self-replace, broadcasts, the defer
163
+ endpoint/job) stay REAL, so actions never pay two round trips.
164
+
165
+ - **pgbus capability gates.** `Phlex::Reactive.pgbus?` and `.pgbus_streams?`
166
+ (the documented broadcast-accepts-`:exclude` probe, now actually
167
+ implemented) plus `.defer_push_capable?` for the defer push lane.
168
+
169
+ - **Client-side option filtering — `reactive_filter` (#163).** The other half
170
+ of #72's combobox: **preload the options, type to narrow — zero round
171
+ trips.** Spread `reactive_filter(input:, option:, group:, empty:)` onto the
172
+ root and the generic controller shows/hides each option on every keystroke by
173
+ a case-folded substring match against its `data-reactive-filter-text`
174
+ haystack (falling back to the option's own text) — no POST, no token, no
175
+ bespoke per-feature Stimulus controller. Optional `group:` collapses a header
176
+ whose every contained option is hidden; optional `empty:` reveals a
177
+ no-matches node at 0 visible. Selectors resolve within the root only (nested
178
+ reactive roots untouched), state seeds at connect and re-applies after a
179
+ morph, and blank selectors raise at render (a dead binding must fail loudly).
180
+ Composes with keyboard nav and per-row selection: a filtered-out option also
181
+ drops out of the Arrow-key path and loses its highlight, so Enter can never
182
+ pick an invisible row — selection itself stays a signed `on(:select)` action.
183
+ - **Standalone combobox keyboard nav — `reactive_listnav` (#163).** The same
184
+ Arrow/Enter/Escape wiring `on(…, listnav:)` appends, without the dispatch
185
+ descriptor — for the preload-and-filter input that fires **no** action (an
186
+ `on()` trigger would POST per keystroke). Spread
187
+ `reactive_listnav("[role=option]")` onto the input; Enter still picks by
188
+ clicking the highlighted option's own signed trigger.
189
+ - **Cross-root `reactive_show` targets — `reactive_show_targets` (#164).** A
190
+ field can now drive the visibility of declared elements **outside** its
191
+ reactive root — the nav tab, the panel in another tab pane, the sidebar note
192
+ a mode selector governs — the visibility parallel to the #159 cross-root
193
+ text mirror. The component that **owns** the field declares which outside
194
+ ids it governs, spread on the root:
195
+ `mix(reactive_root, reactive_show_targets(:mode, "#advanced-tab" =>
196
+ { equals: "advanced" }, "#basic-note" => { not: "advanced" }))`. Same
197
+ posture as `mirror:`: opt-in and declared, never implicit (a plain
198
+ `reactive_show` stays root-isolated, #15 untouched); targets are **single id
199
+ selectors only** — a class/compound selector raises at declare time AND is
200
+ warn-and-skipped by the client (two-sided default-deny); the predicate is
201
+ the same literal-only `reactive_show` vocabulary; the toggle is `hidden`
202
+ only. The field read stays **owned** — you can only drive outside visibility
203
+ from a field the declaring root owns. A target id not on the page is
204
+ silently skipped (an unrendered tab pane is normal); the cross-root pass
205
+ shares the owned-binding pass's field-read memo (one read per field per
206
+ sync). No map declared → one `getAttribute` and out. **One call per root**:
207
+ Phlex `mix` space-joins duplicate string data values, so a second call's
208
+ JSON would corrupt the attr (the client warns and ignores it) — several
209
+ fields go in one call via the hash form
210
+ (`reactive_show_targets(mode: { … }, kind: { … })`).
11
211
  - **Value-conditional visibility — `reactive_show` (#161).** The `x-show` /
12
212
  `data-show` / `wire:show` case — show/hide an element from a form field's
13
213
  **current value** — no longer needs a hand-written `change`-listener Stimulus
@@ -45,6 +245,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
45
245
  can opt out of the reply's target-root scope to document-wide resolution
46
246
  (previously it was silently ignored when a `target` was set).
47
247
 
248
+ ### Performance
249
+
250
+ - The defer machinery adds nothing measurable to the existing hot paths
251
+ (same-machine, same-checkout before/after vs `main`): `to_stream_replace`
252
+ 14.4k → 14.2k i/s (within ±3.7% noise), identity token 199.9k → 196.7k i/s
253
+ (state-backed) / 93.7k → 93.5k (record-backed), allocations byte-identical.
254
+ New `benchmark/micro/defer_token.rb`: `sign_defer` ~120k i/s, `verify_defer`
255
+ ~99k i/s, full fetch-lane directive build ~11 µs/segment, 0 retained. Defer
256
+ is a **latency-shape** change, not a throughput one — the A/B spec
257
+ (`spec/requests/deferred_latency_spec.rb`) pins that a 120 ms segment cost
258
+ moves OFF the actor's reply and onto the deferred leg; it never disappears.
259
+
48
260
  ## [0.9.0] - 2026-07-03
49
261
 
50
262
  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