phlex-reactive 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 913a7629041138c8daf5a4538e694a557f77aeffd137fe7320ce1654efff6aae
4
+ data.tar.gz: ea6dc93c905242537e00b05d651b98cbea3933a9188e0ae0e10594c87b997504
5
+ SHA512:
6
+ metadata.gz: 49f1fbd3b2caa40817f91968e4c39658e8fb02a6ab11850ef8a5a5ba7c5f901b4910e7196642b670c61b74060242f550740937b60c71a8fee3af31c5a080f270
7
+ data.tar.gz: e1fcfbbc3f8c2cb3e22b0929830f7191f59d440b3b1b023a14222547c74fef509face1fdfed4d3a19b3ef13df33f7d4584a1545cc67970cce9ad8de637daf6bf
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - `Phlex::Reactive::Streamable` — class methods (`replace`, `update`, `append`,
12
+ `prepend`, `remove`) and broadcast methods (`broadcast_replace_to`, ...) that
13
+ render a Phlex component as an auto-targeted Turbo Stream by its stable `id`.
14
+ - `Phlex::Reactive::Component` — declare client-invokable `action`s with a param
15
+ schema; `reactive_record` (record-backed, GlobalID identity) and
16
+ `reactive_state` (record-less, signed state); `reactive_attrs` and `on(...)`
17
+ view helpers.
18
+ - `Phlex::Reactive::ActionsController` — one signed-identity endpoint behind all
19
+ reactive components; default-deny actions, schema-coerced params,
20
+ transactional action execution.
21
+ - Generic `reactive` Stimulus controller — per-component request serialization,
22
+ synchronous token threading, auto field collection; no per-feature controllers.
23
+ - Rails engine — mounts the action endpoint, registers and auto-pins the client
24
+ runtime for importmap apps.
25
+
26
+ [Unreleased]: https://github.com/mhenrixon/phlex-reactive/commits/main
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 mhenrixon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,421 @@
1
+ # phlex-reactive
2
+
3
+ **Reactive [Phlex](https://www.phlex.fun) components for Rails — Livewire-style
4
+ actions and live cross-tab updates, without writing Stimulus controllers or
5
+ hand-picking Turbo Stream targets.**
6
+
7
+ ```ruby
8
+ class Counter < ApplicationComponent
9
+ include Phlex::Reactive::Streamable
10
+ include Phlex::Reactive::Component
11
+
12
+ reactive_state :count
13
+ action :increment
14
+ action :decrement
15
+
16
+ def initialize(count: 0) = @count = count
17
+ def id = "counter"
18
+
19
+ def increment = @count += 1
20
+ def decrement = @count -= 1
21
+
22
+ def view_template
23
+ div(id:, **reactive_attrs) do
24
+ button(**on(:decrement)) { "−" }
25
+ span { @count }
26
+ button(**on(:increment)) { "+" }
27
+ end
28
+ end
29
+ end
30
+ ```
31
+
32
+ That's the whole counter. **No Stimulus controller. No `.turbo_stream.erb`. No
33
+ route. No hand-picked target.** Click `+` and the count updates in place.
34
+
35
+ ---
36
+
37
+ ## Why
38
+
39
+ Stimulus + Turbo are powerful but tedious. A single interactive widget means a
40
+ Stimulus controller, a `data-*` soup, a `.turbo_stream.erb` view, a controller
41
+ action, and a hand-picked `dom_id` target — repeated for every feature. The
42
+ mental model is "wire everything by hand."
43
+
44
+ phlex-reactive borrows the **mental model** that makes Livewire and Phoenix
45
+ LiveView pleasant — *a component has state and actions; change state and the UI
46
+ follows* — and implements it the Rails way:
47
+
48
+ - **Actions are Ruby methods.** Declare `action :increment`; the client calls it.
49
+ - **Re-render is auto-targeted.** A component owns a stable `id`; the response is
50
+ a `<turbo-stream>` that replaces it. You never pick a target.
51
+ - **The same unit re-renders for clicks AND broadcasts.** A click and a
52
+ background broadcast both produce "replace the component by its id," so live
53
+ cross-tab updates are the same mechanism as local interactivity.
54
+ - **State lives in your database, not the browser.** The DOM carries only a
55
+ *signed identity* (a record's GlobalID), not a snapshot of state — so there's
56
+ no mass-assignment surface and no re-signing protocol.
57
+ - **One tiny client runtime.** A single generic Stimulus controller, registered
58
+ once, handles every reactive component. You don't write per-feature JS.
59
+
60
+ Pair it with [**pgbus**](https://github.com/mhenrixon/pgbus) and your live
61
+ updates become *transactional* (no broadcast for a rolled-back change) and
62
+ *reconnect-safe* (missed messages replay) over Postgres SSE — **no Action Cable,
63
+ no Redis.**
64
+
65
+ ---
66
+
67
+ ## Installation
68
+
69
+ ```ruby
70
+ # Gemfile
71
+ gem "phlex-reactive"
72
+ ```
73
+
74
+ ```bash
75
+ bundle install
76
+ ```
77
+
78
+ That's it for **importmap** apps — the engine mounts the action endpoint at
79
+ `/reactive/actions` and auto-pins (and preloads) the client runtime. Register
80
+ the controller eagerly so a click immediately after load is never missed:
81
+
82
+ ```js
83
+ // app/javascript/controllers/index.js
84
+ import { application } from "controllers/application"
85
+ import ReactiveController from "phlex/reactive/reactive_controller"
86
+ application.register("reactive", ReactiveController)
87
+ ```
88
+
89
+ <details>
90
+ <summary>esbuild / webpack / bun</summary>
91
+
92
+ Import and register it from your controllers entrypoint:
93
+
94
+ ```js
95
+ import { application } from "./application"
96
+ import ReactiveController from "phlex-reactive/reactive_controller"
97
+ application.register("reactive", ReactiveController)
98
+ ```
99
+
100
+ The JS ships at `app/javascript/phlex/reactive/reactive_controller.js` in the
101
+ gem; point your bundler at the gem path or copy it in. See
102
+ [docs/installation.md](docs/installation.md).
103
+ </details>
104
+
105
+ **Requirements:** Rails 7.1+, Phlex 2 (`phlex-rails`), Turbo 8+ (for morphing),
106
+ and a Phlex `ApplicationComponent` base class. pgbus is optional but recommended
107
+ for broadcasting.
108
+
109
+ ---
110
+
111
+ ## The mental model in one picture
112
+
113
+ ```
114
+ ┌── click / input ──────────────────────────────────────────┐
115
+ │ ▼
116
+ [ button(**on(:increment)) ] POST /reactive/actions { token, act, params }
117
+ ▲ │
118
+ │ verify signed token (no state trusted)
119
+ │ rebuild component (record from DB)
120
+ │ run the whitelisted action
121
+ │ re-render → <turbo-stream replace id>
122
+ └──────── Turbo morphs it in ◀───────────────────────────────┘
123
+
124
+ ...and for OTHER tabs/users:
125
+ model change → Component.broadcast_replace_to(stream) → pgbus SSE → same morph
126
+ ```
127
+
128
+ Client actions and server broadcasts **converge on one re-render unit**: the
129
+ component, targeted by its `id`.
130
+
131
+ ---
132
+
133
+ ## Quickstart: a live, cross-tab counter
134
+
135
+ ```ruby
136
+ # app/components/counter.rb — see the top of this README for the full class
137
+ render Counter.new(count: 0)
138
+ ```
139
+
140
+ Open the page in two tabs, click `+` — done. To make it update across tabs when
141
+ the underlying record changes, use a record-backed component (below).
142
+
143
+ ---
144
+
145
+ ## Two kinds of reactive component
146
+
147
+ ### 1. Record-backed (the common case)
148
+
149
+ State lives in an ActiveRecord row. The signed identity is the record's
150
+ GlobalID; the server re-finds it on each action. **Always prefer this.**
151
+
152
+ ```ruby
153
+ class Todos::Item < ApplicationComponent
154
+ include Phlex::Reactive::Streamable
155
+ include Phlex::Reactive::Component
156
+
157
+ reactive_record :todo
158
+ action :toggle
159
+ action :rename, params: { title: :string }
160
+
161
+ def initialize(todo:) = @todo = todo
162
+ def id = dom_id(@todo) # stable per-record DOM id == Turbo target
163
+
164
+ def toggle
165
+ authorize! @todo, :update? # YOU authorize — the token only proves identity
166
+ @todo.toggle!(:done)
167
+ end
168
+
169
+ def rename(title:)
170
+ authorize! @todo, :update?
171
+ @todo.update!(title:)
172
+ end
173
+
174
+ def view_template
175
+ li(id:, **reactive_attrs, class: ("done" if @todo.done?)) do
176
+ button(**on(:toggle)) { @todo.done? ? "✓" : "○" }
177
+ span { @todo.title }
178
+ end
179
+ end
180
+ end
181
+ ```
182
+
183
+ ### 2. State-backed (record-less widgets)
184
+
185
+ No database row — e.g. a counter or a wizard step. The listed instance vars are
186
+ signed into the token. Keep state small and JSON-serializable.
187
+
188
+ ```ruby
189
+ reactive_state :count, :step # signed; rebuilt on each action
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Concrete examples
195
+
196
+ | Example | What it shows |
197
+ |---|---|
198
+ | [Counter](docs/examples/counter.md) | State-backed, the smallest reactive component |
199
+ | [Cross-tab chat](docs/examples/chat.md) | Record-backed action **+ pgbus broadcast** → live sync across tabs/browsers |
200
+ | [Live todo list](docs/examples/todo_list.md) | Per-row components, add/toggle/rename/delete, broadcast on change |
201
+ | [Inline edit](docs/examples/inline_edit.md) | Show ↔ edit mode toggle, replacing a Stimulus controller + 3 routes |
202
+ | [Notifications / badges](docs/examples/notifications.md) | Pure broadcast (no client action) — a job pushes a re-render |
203
+
204
+ The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
205
+ [docs/examples/chat.md](docs/examples/chat.md).
206
+
207
+ ---
208
+
209
+ ## API reference
210
+
211
+ ### `Phlex::Reactive::Streamable`
212
+
213
+ | Method | Use |
214
+ |---|---|
215
+ | `#id` (you implement) | Stable DOM id == Turbo Stream target. Must match the root element's `id`. |
216
+ | `.replace(model = nil, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component |
217
+ | `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
218
+ | `.broadcast_replace_to(*streamables, model:)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable) |
219
+ | `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |
220
+ | `#to_stream_replace` / `#to_stream_update` | Stream the *already-built* instance (used internally after an action) |
221
+
222
+ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
223
+
224
+ ### `Phlex::Reactive::Component`
225
+
226
+ | Macro / helper | Use |
227
+ |---|---|
228
+ | `reactive_record :name` | Record-backed identity (GlobalID). State = the DB. |
229
+ | `reactive_state :a, :b` | State-backed identity (signed instance vars). Record-less only. |
230
+ | `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
231
+ | `reactive_attrs` | Spread onto the root element: marks it reactive + carries the signed token. |
232
+ | `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
233
+
234
+ Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
235
+ in the schema is dropped before reaching your method.
236
+
237
+ ### Configuration (`config/initializers/phlex_reactive.rb`)
238
+
239
+ ```ruby
240
+ Phlex::Reactive.configure do |c| end if false # (plain accessors below)
241
+
242
+ # Inherit auth/CSRF/Current from your app on the action endpoint:
243
+ Phlex::Reactive.base_controller_name = "ApplicationController"
244
+
245
+ # Render your authorization library's error as 403:
246
+ Phlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]
247
+ # or: [ActionPolicy::Unauthorized]
248
+
249
+ # Use your ApplicationController to render components (app helpers / Current):
250
+ Phlex::Reactive.renderer = ApplicationController
251
+
252
+ # Sign tokens with a dedicated key instead of secret_key_base:
253
+ Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(ENV["REACTIVE_KEY"])
254
+
255
+ # Change the endpoint path (default "/reactive/actions"):
256
+ Phlex::Reactive.action_path = "/_r/actions"
257
+ ```
258
+
259
+ If you set a custom `action_path`, expose it to the client:
260
+
261
+ ```erb
262
+ <meta name="phlex-reactive-action-path" content="<%= Phlex::Reactive.action_path %>">
263
+ ```
264
+
265
+ ---
266
+
267
+ ## Security
268
+
269
+ phlex-reactive is built so the easy path is the safe path — but the boundary is
270
+ real, so read this once.
271
+
272
+ - **State is never trusted from the client.** The DOM holds a `MessageVerifier`-
273
+ signed identity (`{component, gid}` or `{component, state}`), not raw state. A
274
+ tampered class, record, or state value fails signature verification → 400.
275
+ - **Actions are default-deny.** Only methods declared with `action :name` are
276
+ invokable. A public method without `action` is unreachable.
277
+ - **You must authorize.** The signature proves the *token is yours*, not that
278
+ *this user may act on this record*. Call your authorizer inside the action
279
+ (`authorize! @todo, :update?`) and register its error in
280
+ `Phlex::Reactive.authorization_errors`.
281
+ - **Params are schema-coerced.** Only declared params reach your method, each
282
+ cast to its declared type. No raw mass assignment.
283
+ - **CSRF + auth are the host app's.** The endpoint inherits from your configured
284
+ `base_controller_name`. Inherit `ApplicationController` to get CSRF and auth —
285
+ but if you have *public* reactive components, ensure the action path isn't
286
+ force-redirected to a login page for logged-out users.
287
+
288
+ See [docs/security.md](docs/security.md) for the threat model and a checklist.
289
+
290
+ ---
291
+
292
+ ## How it beats Stimulus + Turbo (same feature, less code)
293
+
294
+ A counter, today vs. with phlex-reactive:
295
+
296
+ <table>
297
+ <tr><th>Stimulus + Turbo</th><th>phlex-reactive</th></tr>
298
+ <tr><td>
299
+
300
+ ```js
301
+ // counter_controller.js
302
+ import { Controller } from "@hotwired/stimulus"
303
+ export default class extends Controller {
304
+ static values = { url: String }
305
+ increment() { this.#post("increment") }
306
+ decrement() { this.#post("decrement") }
307
+ #post(op) {
308
+ fetch(`${this.urlValue}/${op}`, {
309
+ method: "POST",
310
+ headers: { "X-CSRF-Token": token() },
311
+ })
312
+ }
313
+ }
314
+ ```
315
+ ```erb
316
+ <%# _counter.html.erb %>
317
+ <div id="<%= dom_id(@counter) %>"
318
+ data-controller="counter"
319
+ data-counter-url-value="<%= counter_path(@counter) %>">
320
+ <button data-action="counter#decrement">−</button>
321
+ <span><%= @counter.value %></span>
322
+ <button data-action="counter#increment">+</button>
323
+ </div>
324
+ ```
325
+ ```ruby
326
+ # routes + controller
327
+ resources :counters do
328
+ member { post :increment; post :decrement }
329
+ end
330
+ def increment
331
+ @counter.increment!(:value)
332
+ render turbo_stream: turbo_stream.replace(
333
+ dom_id(@counter), partial: "counter",
334
+ locals: { counter: @counter })
335
+ end
336
+ ```
337
+
338
+ </td><td>
339
+
340
+ ```ruby
341
+ class Counter < ApplicationComponent
342
+ include Phlex::Reactive::Streamable
343
+ include Phlex::Reactive::Component
344
+
345
+ reactive_record :counter
346
+ action :increment
347
+ action :decrement
348
+
349
+ def initialize(counter:) = @counter = counter
350
+ def id = dom_id(@counter)
351
+
352
+ def increment = @counter.increment!(:value)
353
+ def decrement = @counter.decrement!(:value)
354
+
355
+ def view_template
356
+ div(id:, **reactive_attrs) do
357
+ button(**on(:decrement)) { "−" }
358
+ span { @counter.value }
359
+ button(**on(:increment)) { "+" }
360
+ end
361
+ end
362
+ end
363
+ ```
364
+
365
+ *One file. No JS. No routes. No partial. No hand-picked target.*
366
+
367
+ </td></tr>
368
+ </table>
369
+
370
+ ---
371
+
372
+ ## Live updates with pgbus (recommended)
373
+
374
+ [pgbus](https://github.com/mhenrixon/pgbus) replaces Action Cable's transport
375
+ with Postgres SSE and fixes its reliability gaps. With it installed,
376
+ `broadcast_*_to` and `turbo_stream_from` route over pgbus automatically:
377
+
378
+ ```ruby
379
+ class Message < ApplicationRecord
380
+ broadcasts_to ->(m) { [m.room, :messages] }, durable: true
381
+ end
382
+ ```
383
+
384
+ - **Transactional**: a broadcast inside a transaction that rolls back never
385
+ fires — *and* the DB change is undone. No "ghost" UI updates.
386
+ - **Reconnect-safe**: a tab that dropped replays missed messages on reconnect
387
+ (`Last-Event-ID` + PGMQ archive).
388
+ - **No race on subscribe**: messages broadcast between render and subscribe are
389
+ replayed, not lost.
390
+ - **No Redis, no Action Cable.**
391
+
392
+ See [docs/broadcasting.md](docs/broadcasting.md) and
393
+ [docs/transport-pgbus.md](docs/transport-pgbus.md).
394
+
395
+ ---
396
+
397
+ ## Documentation
398
+
399
+ - [Installation & bundler setups](docs/installation.md)
400
+ - [Mental model & architecture](docs/architecture.md)
401
+ - [Security & threat model](docs/security.md)
402
+ - [Broadcasting & live updates](docs/broadcasting.md)
403
+ - [Transport: pgbus vs Action Cable](docs/transport-pgbus.md)
404
+ - [Testing reactive components](docs/testing.md)
405
+ - Examples: [counter](docs/examples/counter.md) ·
406
+ [chat](docs/examples/chat.md) · [todo list](docs/examples/todo_list.md) ·
407
+ [inline edit](docs/examples/inline_edit.md) ·
408
+ [notifications](docs/examples/notifications.md)
409
+
410
+ ## Credits & prior art
411
+
412
+ The mental model is stolen, gratefully, from
413
+ [Laravel Livewire](https://livewire.laravel.com) (public method = action) and
414
+ [Phoenix LiveView](https://www.phoenixframework.org) (a component is a re-render
415
+ unit). The transport and reliability come from
416
+ [pgbus](https://github.com/mhenrixon/pgbus). The rendering is all
417
+ [Phlex](https://www.phlex.fun).
418
+
419
+ ## License
420
+
421
+ [MIT](LICENSE.txt).
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The single endpoint behind every reactive component. The generic
6
+ # `reactive` Stimulus controller POSTs here with a signed identity token,
7
+ # an action name, and params. We verify the token, rebuild the component
8
+ # (re-finding the record from the DB for record-backed components), run the
9
+ # whitelisted action, and return an auto-targeted Turbo Stream the client
10
+ # morphs in.
11
+ #
12
+ # Customizing in your app:
13
+ # * Authentication — by default this inherits from
14
+ # Phlex::Reactive.base_controller (ActionController::Base). Set it to
15
+ # your ApplicationController to get current_user/Current/CSRF, but make
16
+ # sure the action path isn't force-redirected for logged-out users if
17
+ # you have public reactive components.
18
+ # * Authorization — DO IT IN THE COMPONENT ACTION. The token proves the
19
+ # identity is ours, not that this user may act. Raise from the action
20
+ # (e.g. authorize!), and configure Phlex::Reactive.authorization_errors
21
+ # so it's rendered as 403 here.
22
+ class ActionsController < Phlex::Reactive.base_controller
23
+ # Our JSON body uses keys that collide with Rails' reserved routing
24
+ # params (action/controller) and would be wrapped by wrap_parameters.
25
+ # Disable wrapping so the body lands flat; the action name travels as
26
+ # `act` (not `action`, which is reserved and resolves to "create").
27
+ wrap_parameters false if respond_to?(:wrap_parameters)
28
+
29
+ def create
30
+ payload = verified_payload
31
+ component_class = resolve_component(payload["c"])
32
+ action_def = component_class.reactive_action(reactive_action_name)
33
+
34
+ return head(:forbidden) unless action_def # default-deny
35
+
36
+ component = component_class.from_identity(payload)
37
+ coerced = coerce_params(action_def.params)
38
+
39
+ run_action(component, action_def, coerced)
40
+
41
+ render turbo_stream: component.to_stream_replace
42
+ rescue Phlex::Reactive::InvalidToken
43
+ head :bad_request
44
+ rescue ActiveRecord::RecordNotFound
45
+ head :not_found
46
+ rescue *authorization_errors
47
+ head :forbidden
48
+ end
49
+
50
+ private
51
+
52
+ # Run the action inside a transaction so transactional broadcasts (pgbus
53
+ # broadcasts_to ... durable:) defer to after_commit and never fire for a
54
+ # rolled-back change. Override to add per-request instrumentation.
55
+ def run_action(component, action_def, coerced)
56
+ transaction_wrapper do
57
+ if coerced.any?
58
+ component.public_send(action_def.name, **coerced)
59
+ else
60
+ component.public_send(action_def.name)
61
+ end
62
+ end
63
+ end
64
+
65
+ def transaction_wrapper(&block)
66
+ if defined?(::ActiveRecord::Base)
67
+ ::ActiveRecord::Base.transaction(&block)
68
+ else
69
+ yield
70
+ end
71
+ end
72
+
73
+ def verified_payload
74
+ token = params.require(:token)
75
+ Phlex::Reactive.verify(token) || raise(Phlex::Reactive::InvalidToken)
76
+ end
77
+
78
+ # NB: must NOT be named `action_name` — that's reserved by
79
+ # ActionController dispatch and overriding it recurses fatally.
80
+ def reactive_action_name
81
+ params.require(:act).to_sym
82
+ end
83
+
84
+ # Coerce client params against the action's declared schema. Anything not
85
+ # in the schema is dropped — no raw mass assignment reaches the component.
86
+ def coerce_params(schema)
87
+ return {} if schema.blank?
88
+
89
+ raw = params.fetch(:params, {})
90
+ schema.each_with_object({}) do |(key, type), out|
91
+ next unless raw.key?(key.to_s)
92
+
93
+ out[key.to_sym] = coerce(raw[key.to_s], type)
94
+ end
95
+ end
96
+
97
+ def coerce(value, type)
98
+ case type
99
+ when :integer then value.to_i
100
+ when :float then value.to_f
101
+ when :boolean then ActiveModel::Type::Boolean.new.cast(value)
102
+ else value.to_s
103
+ end
104
+ end
105
+
106
+ # Only components that opt into Reactive may be resolved. The signature
107
+ # already gates this; defense in depth against constant injection.
108
+ def resolve_component(name)
109
+ klass = name.to_s.safe_constantize
110
+ unless klass && klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
111
+ raise Phlex::Reactive::InvalidToken
112
+ end
113
+
114
+ klass
115
+ end
116
+
117
+ def authorization_errors
118
+ Phlex::Reactive.authorization_errors
119
+ end
120
+ end
121
+ end
122
+ end