@@ -110,6 +110,93 @@ applications discover the namespaced engine asset. Applications created with
110110explicitly serve the module. Turbo's normal morph rules still apply; use
111111` data-turbo-permanent ` for elements that must never be changed.
112112
113+ ## Personalized state payloads
114+
115+ Reactive ERB components cost one browser request per changed component. When a
116+ single actor mutation changes several components, an application pays several
117+ round trips for one logical update. A payload broadcast collapses that into one
118+ message on the stream the page already has open.
119+
120+ Declare the payload on the actor. The block receives the actor and the
121+ subscriber's authorization context, and it runs ** once per subscriber** , so two
122+ sessions watching the same actor never see each other's private state:
123+
124+ ``` ruby
125+ class PlaymatRoom < SolidObjects ::Actor
126+ actor_type " playmat_room"
127+
128+ attribute :hands , default: -> { {} }
129+ attribute :turn , default: 1
130+
131+ observable :turn
132+
133+ broadcast_payload :playmat_state do |room , authorization_context |
134+ {
135+ " turn" => room.turn,
136+ " hand" => room.hands.fetch(authorization_context.session_id, [])
137+ }
138+ end
139+ end
140+ ```
141+
142+ Subscribe the scope to it:
143+
144+ ``` erb
145+ <%= solid_object room, payloads: :playmat_state do |actor| %>
146+ <div data-playmat></div>
147+ <% end %>
148+ ```
149+
150+ Handle it with any JavaScript. The gem dispatches a DOM event and requires no
151+ framework:
152+
153+ ``` javascript
154+ document .addEventListener (" solid-objects:payload" , (event ) => {
155+ const { name , revision , payload } = event .detail
156+ if (name !== " playmat_state" ) return
157+
158+ renderPlaymat (payload)
159+ })
160+ ```
161+
162+ ### What the protocol guarantees
163+
164+ The payload travels as a Turbo Stream element on the existing actor stream, so
165+ applications do not run a second WebSocket system. Each message carries the
166+ actor identity plus the ` instance_id ` and monotonic ` state_revision ` that fence
167+ component refreshes, and both the channel and the browser drop a payload that
168+ is not newer than the last one delivered for that scope and name. A reconnecting
169+ client receives the current payload on subscribe.
170+
171+ Authorization is the same ` authorize_query ` boundary that components use, called
172+ with the payload name and the subscriber's Cable connection. A subscriber that
173+ fails the check is skipped rather than served a partial payload, and the payload
174+ name is signed into the stream token, so a browser cannot ask for a payload the
175+ server did not offer.
176+
177+ Payload blocks read committed actor state through the same snapshot components
178+ use. They cannot write application records, and the return value must be a JSON
179+ object or array so the wire format stays inspectable.
180+
181+ ### mtg-playmat before and after
182+
183+ Before, one mutation that touched three observables produced three refresh
184+ elements and three HTTP requests:
185+
186+ ```
187+ commit -> 3 Action Cable messages -> 3 GET /solid_objects/components -> 3 renders
188+ ```
189+
190+ After, the same mutation delivers one personalized payload and the page renders
191+ once:
192+
193+ ```
194+ commit -> 1 Action Cable message -> 0 HTTP requests -> 1 render
195+ ```
196+
197+ Components remain the default. An actor with no ` broadcast_payload ` and a scope
198+ with no ` payloads: ` option behave exactly as before.
199+
113200## Authorization
114201
115202The HTML contains a signed actor identity token. Signing prevents modification;
0 commit comments