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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +197 -0
- data/README.md +282 -2
- data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
- data/app/javascript/phlex/reactive/inspect.js +225 -0
- data/app/javascript/phlex/reactive/inspect.min.js +4 -0
- data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +534 -6
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/generators/phlex/reactive/claude/USAGE +15 -0
- data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
- data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
- data/lib/phlex/reactive/authorization.rb +118 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
- data/lib/phlex/reactive/component/dsl.rb +70 -0
- data/lib/phlex/reactive/component/helpers.rb +193 -8
- data/lib/phlex/reactive/component/identity.rb +10 -1
- data/lib/phlex/reactive/component/lazy.rb +79 -0
- data/lib/phlex/reactive/component/registry.rb +7 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +363 -0
- data/lib/phlex/reactive/deferred_render_job.rb +116 -0
- data/lib/phlex/reactive/doctor.rb +79 -11
- data/lib/phlex/reactive/engine.rb +16 -2
- data/lib/phlex/reactive/inspector/report.rb +140 -0
- data/lib/phlex/reactive/inspector.rb +253 -0
- data/lib/phlex/reactive/log_subscriber.rb +10 -0
- data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
- data/lib/phlex/reactive/mcp/runner.rb +24 -0
- data/lib/phlex/reactive/mcp/server.rb +47 -0
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
- data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
- data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
- data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
- data/lib/phlex/reactive/mcp.rb +58 -0
- data/lib/phlex/reactive/reply.rb +18 -0
- data/lib/phlex/reactive/response.rb +47 -2
- data/lib/phlex/reactive/streamable.rb +5 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +253 -3
- data/lib/tasks/phlex_reactive.rake +28 -0
- metadata +22 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2d95213b7c5a3711a00f1992f0a1992363c203aa3bd253875f0ceb67db88986f
|
|
4
|
+
data.tar.gz: 93574b53d1e24f5c61d4dca65b65521a32782d5945d99c911484c76ec10ac8ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|