Skip to content

Commit 256233f

Browse files
authored
Merge pull request #1 from cardmagic/agent/reactive-erb-components
Add reactive ERB components
2 parents c70a958 + b841063 commit 256233f

64 files changed

Lines changed: 2604 additions & 129 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
# Changelog
22

3-
## Unreleased
3+
## 0.4.0 - 2026-08-06
44

5+
- Add dependency-driven live ERB components with request-time authorization,
6+
conventional partial resolution, revision fencing, refresh coalescing, and
7+
reconnect convergence without broadcasting personalized HTML.
8+
- Persist a monotonic state revision for secure component refresh ordering.
59
- Retry SQLite synchronous lock contention in Ruby so a native busy wait
610
cannot starve the thread holding the database lock.
711
- Allow maintainers to dispatch CI manually when a push webhook is dropped.

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.3.0)
4+
solid_objects (0.4.0)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -373,7 +373,7 @@ CHECKSUMS
373373
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
374374
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
375375
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
376-
solid_objects (0.3.0)
376+
solid_objects (0.4.0)
377377
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
378378
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
379379
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

README.md

Lines changed: 134 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ tested, but the project does not yet claim production readiness. See
7474
- [Cloudflare Durable Objects for Rails](#cloudflare-durable-objects-for-rails)
7575
- [Reactive ERB](#reactive-erb)
7676
- [Installation](#installation)
77+
- [Upgrading](#upgrading)
7778
- [Worker requirements](#worker-requirements)
7879
- [Defining an actor](#defining-an-actor)
7980
- [Actor identity](#actor-identity)
@@ -173,42 +174,105 @@ rolled-back state change cannot leak into the page.
173174
Define an observable:
174175

175176
```ruby
176-
class ShoppingCart < SolidObjects::Actor
177-
attribute :items, default: -> { [] }
177+
class ChatRoom < SolidObjects::Actor
178+
attribute :recent_messages, default: -> { [] }
179+
attribute :status, default: "open"
178180

179-
observable :items_count do
180-
items.sum { |item| item.fetch("quantity") }
181+
observable :message_count do
182+
recent_messages.length
181183
end
184+
185+
observable :recent_messages
186+
observable :status
182187
end
183188
```
184189

185-
Render it:
190+
Scalar observables remain stable `<span>` targets:
191+
192+
```erb
193+
<%= solid_object @room, authorization_context: current_user do |room| %>
194+
Messages: <%= room.message_count %>
195+
<% end %>
196+
```
197+
198+
Reactive components rerender a host ERB partial when one of their explicit
199+
dependencies changes:
186200

187201
```erb
188-
<%= solid_object current_cart do |cart| %>
189-
Cart items: <%= cart.items_count %>
202+
<%= solid_object @room, authorization_context: current_user do |room| %>
203+
<%= room.component :messages, observes: :recent_messages %>
204+
<%= room.component :presence, observes: %i[recent_messages status] %>
190205
<% end %>
191206
```
192207

193-
That template provides initial server rendering, a stable opaque DOM target,
194-
and live Turbo replacements after committed actor turns. One `solid_object`
195-
block makes one Action Cable subscription for all values inside it, and Action
196-
Cable multiplexes subscriptions over the browser's WebSocket.
208+
`room.component(:messages)` resolves only
209+
`actors/chat_room/_messages`. Its partial receives `actor` and
210+
`authorization_context` locals:
211+
212+
```erb
213+
<ul>
214+
<% actor.recent_messages.each do |message| %>
215+
<li><%= message.fetch("body") %></li>
216+
<% end %>
217+
</ul>
218+
```
219+
220+
Declared observables are deeply frozen ordinary Ruby values inside a
221+
component. Arrays support loops, hashes support ordinary lookup, conditionals
222+
work normally, and ERB still escapes user strings. A reactive component cannot
223+
read `actor.state`, access an undeclared observable, or choose a dynamic
224+
partial path.
225+
226+
That template provides initial server rendering, stable opaque DOM targets,
227+
and live updates after committed actor turns. One `solid_object` block makes
228+
one Action Cable subscription for all scalar values and components inside it,
229+
and Action Cable multiplexes subscriptions over the browser's WebSocket.
197230

198231
No client-side state store, custom Stimulus controller, channel class, manual
199232
broadcast, or one-WebSocket-per-value setup is required. Signed stream tokens
200-
protect integrity, an application policy authorizes every subscription,
201-
broadcasts are delivered from a durable outbox, and reconnecting clients
202-
refresh from current actor state.
233+
protect integrity, not access. Initial rendering authorizes with the
234+
`authorization_context` passed to `solid_object`; Cable authorizes with its
235+
connection; every component refresh authorizes again with a request-specific
236+
context:
203237

204-
`cart.component(:summary)` supports initial rendering of
205-
`actors/shopping_cart/_summary`. Durable live component replacement and
206-
Turbo append actions are roadmap work; observable replacement is the live path
207-
implemented today.
238+
```ruby
239+
SolidObjects.configure do |configuration|
240+
configuration.component_authorization_context = ->(controller:) { Current.user }
241+
end
242+
```
243+
244+
The durable outbox stores one row per changed observable, never personalized
245+
HTML. Cable sends invalidation metadata over the shared actor stream, then a
246+
Turbo Frame requests the component with normal cookies. Only scalar targets
247+
that the server rendered into this `solid_object` scope are signed into its
248+
stream token and receive value payloads; component-only dependencies do not
249+
send their values to the browser. The endpoint renders the latest committed
250+
snapshot, returns `private, no-store`, and reauthorizes the component name plus
251+
every declared dependency. Two viewers can therefore receive different HTML
252+
for the same actor without sharing either projection.
253+
254+
Reconnect compares the component's signed initial revision with the latest
255+
actor incarnation and state revision, then refreshes stale components. Cable
256+
coalesces several dependency changes from one actor turn into one component
257+
refresh and ignores older out-of-order invalidations. A newer invalidation
258+
replaces an in-flight frame, so its detached older response cannot overwrite
259+
newer state.
260+
261+
Reactive components add no HTML to durable rows, but each affected component
262+
causes an authorized HTTP render. One actor turn still inserts one broadcast
263+
row per changed observable; several dependencies from that turn coalesce at
264+
the subscriber. Keep components bounded, declare only necessary dependencies,
265+
and use scalar observables for inexpensive single-value replacement.
208266

209267
Reactive views require `turbo-rails` and a working Action Cable adapter in the
210-
host application. They are optional; the actor runtime itself does not depend
211-
on Turbo.
268+
host application. The Solid Objects engine must be mounted so its signed
269+
component endpoint is reachable. Reactive views are optional; the actor
270+
runtime itself does not depend on Turbo.
271+
272+
```ruby
273+
# config/routes.rb
274+
mount SolidObjects::Engine => "/solid_objects"
275+
```
212276

213277
## Installation
214278

@@ -274,6 +338,50 @@ can generate the gem RBI with:
274338
bundle exec tapioca gem solid_objects
275339
```
276340

341+
## Upgrading
342+
343+
Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
344+
notes, then update the gem:
345+
346+
```bash
347+
bundle update solid_objects
348+
```
349+
350+
If the `Gemfile` pins an exact version, update that constraint first and run
351+
`bundle install`. Commit both `Gemfile.lock` and the copied Solid Objects
352+
migrations.
353+
354+
Copy only migrations that the newer gem has added, migrate, and verify the
355+
installation:
356+
357+
```bash
358+
bin/rails solid_objects:install:migrations
359+
bin/rails db:migrate
360+
bin/rails solid_objects:doctor
361+
```
362+
363+
The migration task skips engine migrations already present in the application
364+
and gives new migrations host-specific timestamps. Inspect the resulting
365+
`db/migrate/*.solid_objects.rb` files before applying them. Do not rerun
366+
`generate solid_objects:install` during an upgrade because that also attempts
367+
to regenerate the application initializer.
368+
369+
When Solid Objects uses a separate database configuration named `actors`, copy
370+
and run migrations through that database's configured migration path:
371+
372+
```bash
373+
DATABASE=actors bin/rails solid_objects:install:migrations
374+
bin/rails db:migrate:actors
375+
bin/rails solid_objects:doctor
376+
```
377+
378+
For production, back up the actor database and run new migrations before
379+
starting application or Solid Objects worker processes that require the new
380+
schema. Restart the web and Solid Objects worker fleet after the bundle and
381+
schema are current. For releases that change actor state versions, also follow
382+
the [state migration and rolling-deployment guide](docs/state-migrations.md);
383+
Rails schema migrations and actor state migrations are separate concerns.
384+
277385
## Worker requirements
278386

279387
Synchronous actors can be adopted without adding a long-running process. Start
@@ -290,7 +398,7 @@ the runtime when the feature introduces asynchronous delivery or outboxes:
290398
| `emit` without an actor callback | Effect worker |
291399
| `emit` with success or failure callback | Effect worker and actor worker |
292400
| Actor-to-actor `async` or `send_to` | Effect worker and actor worker |
293-
| Observable Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
401+
| Scalar or component Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
294402
| Initial `solid_object` server render | No Solid Objects worker; normal Rails rendering |
295403

296404
One command starts every Solid Objects role:
@@ -929,7 +1037,7 @@ See the [development guide](docs/development.md) and
9291037

9301038
## Status
9311039

932-
Implemented and tested in 0.3:
1040+
Implemented and tested in 0.4:
9331041

9341042
- Rails engine, install generator, migrations, and `solid_objects` executable;
9351043
- actor registry, references, JSON state, and state migrations;
@@ -947,7 +1055,8 @@ Implemented and tested in 0.3:
9471055
- one-shot and recurring per-actor reminders;
9481056
- authorized actor destruction with fenced stale-write rejection and cascading
9491057
durable-work cleanup;
950-
- durable observable broadcasts and authorized Action Cable refresh;
1058+
- durable observable invalidations, scalar Turbo replacement, and authorized
1059+
request-time ERB component refresh;
9511060
- process registration, heartbeats, caller shutdown, cleanup, and bounded
9521061
message/process retention plus opt-in actor-instance expiration;
9531062
- an opt-in Minitest helper for actor-state isolation and deterministic async
@@ -961,8 +1070,8 @@ Partially implemented:
9611070
run periodic maintenance automatically;
9621071
- cross-process wake-up uses polling; PostgreSQL notifications and optional
9631072
Redis acceleration are not implemented;
964-
- live observable replacement works, while live component replacement and
965-
Turbo append actions remain future work;
1073+
- live observable and component replacement work, while Turbo append actions
1074+
remain future work;
9661075
- local admission limits exist, but distributed rate limits and global
9671076
admission control do not; and
9681077
- administration views and pruning commands exist, but scheduled maintenance
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# rbs_inline: enabled
2+
3+
require "action_controller/base"
4+
5+
module SolidObjects
6+
class ComponentsController < ActionController::Base
7+
protect_from_forgery with: :exception
8+
9+
# @rbs () -> void
10+
def show
11+
registration = ComponentRegistration.from_token(
12+
params.require(:token)
13+
)
14+
requested_revision = requested_revision_key
15+
snapshot = ActorSnapshot.new(registration.reference)
16+
return head :conflict if newer_than_snapshot?(requested_revision, snapshot)
17+
18+
authorization_context = SolidObjects
19+
.configuration
20+
.component_authorization_context
21+
.call(controller: self)
22+
rendered = ComponentRenderer.new(
23+
snapshot:,
24+
component_name: registration.component_name,
25+
dependencies: registration.dependencies,
26+
view_context: component_view_context,
27+
authorization_context:
28+
).call
29+
response.headers["Cache-Control"] = "private, no-store"
30+
render html: component_frame(registration, snapshot, rendered)
31+
rescue Unauthorized
32+
head :forbidden
33+
rescue UnknownComponent
34+
head :not_found
35+
rescue ActionController::ParameterMissing,
36+
ArgumentError,
37+
InvalidComponentToken
38+
head :bad_request
39+
end
40+
41+
private
42+
43+
# @rbs () -> Array[Integer]
44+
def requested_revision_key
45+
instance_id = Integer(params.fetch(:instance_id), 10)
46+
revision = Integer(params.fetch(:revision), 10)
47+
raise ArgumentError if instance_id.negative? || revision.negative?
48+
49+
[ instance_id, revision ]
50+
end
51+
52+
# @rbs (Array[Integer], ActorSnapshot) -> bool
53+
def newer_than_snapshot?(requested_revision, snapshot)
54+
(requested_revision <=> [ snapshot.instance_id, snapshot.revision ]) == 1
55+
end
56+
57+
# @rbs () -> untyped
58+
def component_view_context
59+
if defined?(Rails) && Rails.application
60+
prepend_view_path(*Rails.application.paths["app/views"].existent)
61+
end
62+
63+
view_context.tap do |context|
64+
context.extend(Rails.application.helpers) if defined?(Rails) && Rails.application
65+
end
66+
end
67+
68+
# @rbs (ComponentRegistration, ActorSnapshot, untyped) -> String
69+
def component_frame(registration, snapshot, rendered)
70+
target = DomIdentity.component(
71+
registration.reference,
72+
registration.component_name
73+
)
74+
revision = "#{snapshot.instance_id}:#{snapshot.revision}"
75+
%(<turbo-frame id="#{target}" data-solid-objects-revision="#{revision}">#{rendered}</turbo-frame>).html_safe
76+
end
77+
end
78+
end

app/helpers/solid_objects/actor_helper.rb

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,20 @@ def solid_object(reference, authorization_context: self, &block)
99
view_context: self,
1010
authorization_context:
1111
)
12-
subscription = tag.turbo_cable_stream_source(
13-
channel: "SolidObjects::ActorChannel",
14-
token: StreamToken.generate(reference)
15-
)
1612
content = capture(actor, &block)
13+
subscription_attributes = {
14+
channel: "SolidObjects::ActorChannel",
15+
token: StreamToken.generate(
16+
reference,
17+
observables: actor.scalar_observable_names
18+
)
19+
}
20+
if actor.component_tokens.any?
21+
subscription_attributes[:data] = {
22+
components: JSON.generate(actor.component_tokens)
23+
}
24+
end
25+
subscription = tag.turbo_cable_stream_source(**subscription_attributes)
1726

1827
content_tag(
1928
:div,

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# rbs_inline: enabled
22

33
SolidObjects::Engine.routes.draw do
4+
get :components, to: "components#show"
45
resources :instances, only: %i[index show]
56
resources :dead_letters, only: %i[index] do
67
post :retry, on: :member
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# rbs_inline: enabled
2+
3+
class AddStateRevisionToSolidObjectsInstances < ActiveRecord::Migration[8.0]
4+
# @rbs () -> void
5+
def change
6+
add_column SolidObjects.table_name(:instances),
7+
:state_revision,
8+
:bigint,
9+
null: false,
10+
default: 0
11+
end
12+
end

0 commit comments

Comments
 (0)