@@ -173,42 +173,105 @@ rolled-back state change cannot leak into the page.
173173Define 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
182186end
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
198230No client-side state store, custom Stimulus controller, channel class, manual
199231broadcast, 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
209266Reactive 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
296359One 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
0 commit comments