Skip to content

Commit 38b8a6a

Browse files
committed
Add reactive ERB components
1 parent c70a958 commit 38b8a6a

62 files changed

Lines changed: 2511 additions & 124 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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
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.

README.md

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -173,42 +173,105 @@ rolled-back state change cannot leak into the page.
173173
Define an observable:
174174

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

179-
observable :items_count do
180-
items.sum { |item| item.fetch("quantity") }
180+
observable :message_count do
181+
recent_messages.length
181182
end
183+
184+
observable :recent_messages
185+
observable :status
182186
end
183187
```
184188

185-
Render it:
189+
Scalar observables remain stable `<span>` targets:
186190

187191
```erb
188-
<%= solid_object current_cart do |cart| %>
189-
Cart items: <%= cart.items_count %>
192+
<%= solid_object @room, authorization_context: current_user do |room| %>
193+
Messages: <%= room.message_count %>
190194
<% end %>
191195
```
192196

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.
197+
Reactive components rerender a host ERB partial when one of their explicit
198+
dependencies changes:
199+
200+
```erb
201+
<%= solid_object @room, authorization_context: current_user do |room| %>
202+
<%= room.component :messages, observes: :recent_messages %>
203+
<%= room.component :presence, observes: %i[recent_messages status] %>
204+
<% end %>
205+
```
206+
207+
`room.component(:messages)` resolves only
208+
`actors/chat_room/_messages`. Its partial receives `actor` and
209+
`authorization_context` locals:
210+
211+
```erb
212+
<ul>
213+
<% actor.recent_messages.each do |message| %>
214+
<li><%= message.fetch("body") %></li>
215+
<% end %>
216+
</ul>
217+
```
218+
219+
Declared observables are deeply frozen ordinary Ruby values inside a
220+
component. Arrays support loops, hashes support ordinary lookup, conditionals
221+
work normally, and ERB still escapes user strings. A reactive component cannot
222+
read `actor.state`, access an undeclared observable, or choose a dynamic
223+
partial path.
224+
225+
That template provides initial server rendering, stable opaque DOM targets,
226+
and live updates after committed actor turns. One `solid_object` block makes
227+
one Action Cable subscription for all scalar values and components inside it,
228+
and Action Cable multiplexes subscriptions over the browser's WebSocket.
197229

198230
No client-side state store, custom Stimulus controller, channel class, manual
199231
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.
232+
protect integrity, not access. Initial rendering authorizes with the
233+
`authorization_context` passed to `solid_object`; Cable authorizes with its
234+
connection; every component refresh authorizes again with a request-specific
235+
context:
236+
237+
```ruby
238+
SolidObjects.configure do |configuration|
239+
configuration.component_authorization_context = ->(controller:) { Current.user }
240+
end
241+
```
203242

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.
243+
The durable outbox stores one row per changed observable, never personalized
244+
HTML. Cable sends invalidation metadata over the shared actor stream, then a
245+
Turbo Frame requests the component with normal cookies. Only scalar targets
246+
that the server rendered into this `solid_object` scope are signed into its
247+
stream token and receive value payloads; component-only dependencies do not
248+
send their values to the browser. The endpoint renders the latest committed
249+
snapshot, returns `private, no-store`, and reauthorizes every declared
250+
dependency. Two viewers can therefore receive different HTML for the same
251+
actor without sharing either projection.
252+
253+
Reconnect compares the component's signed initial revision with the latest
254+
actor incarnation and state revision, then refreshes stale components. Cable
255+
coalesces several dependency changes from one actor turn into one component
256+
refresh and ignores older out-of-order invalidations. A newer invalidation
257+
replaces an in-flight frame, so its detached older response cannot overwrite
258+
newer state.
259+
260+
Reactive components add no HTML to durable rows, but each affected component
261+
causes an authorized HTTP render. One actor turn still inserts one broadcast
262+
row per changed observable; several dependencies from that turn coalesce at
263+
the subscriber. Keep components bounded, declare only necessary dependencies,
264+
and use scalar observables for inexpensive single-value replacement.
208265

209266
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.
267+
host application. The Solid Objects engine must be mounted so its signed
268+
component endpoint is reachable. Reactive views are optional; the actor
269+
runtime itself does not depend on Turbo.
270+
271+
```ruby
272+
# config/routes.rb
273+
mount SolidObjects::Engine => "/solid_objects"
274+
```
212275

213276
## Installation
214277

@@ -290,7 +353,7 @@ the runtime when the feature introduces asynchronous delivery or outboxes:
290353
| `emit` without an actor callback | Effect worker |
291354
| `emit` with success or failure callback | Effect worker and actor worker |
292355
| 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 |
356+
| Scalar or component Turbo updates | Broadcast worker, Action Cable, and the actor execution path |
294357
| Initial `solid_object` server render | No Solid Objects worker; normal Rails rendering |
295358

296359
One command starts every Solid Objects role:
@@ -947,7 +1010,8 @@ Implemented and tested in 0.3:
9471010
- one-shot and recurring per-actor reminders;
9481011
- authorized actor destruction with fenced stale-write rejection and cascading
9491012
durable-work cleanup;
950-
- durable observable broadcasts and authorized Action Cable refresh;
1013+
- durable observable invalidations, scalar Turbo replacement, and authorized
1014+
request-time ERB component refresh;
9511015
- process registration, heartbeats, caller shutdown, cleanup, and bounded
9521016
message/process retention plus opt-in actor-instance expiration;
9531017
- an opt-in Minitest helper for actor-state isolation and deterministic async
@@ -961,8 +1025,8 @@ Partially implemented:
9611025
run periodic maintenance automatically;
9621026
- cross-process wake-up uses polling; PostgreSQL notifications and optional
9631027
Redis acceleration are not implemented;
964-
- live observable replacement works, while live component replacement and
965-
Turbo append actions remain future work;
1028+
- live observable and component replacement work, while Turbo append actions
1029+
remain future work;
9661030
- local admission limits exist, but distributed rate limits and global
9671031
admission control do not; and
9681032
- 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)