solid_objects 0.5.2 → 0.7.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/app/assets/javascripts/solid_objects/component_batch_refresh.js +148 -0
  4. data/app/assets/javascripts/solid_objects/state_payload.js +69 -0
  5. data/app/controllers/solid_objects/components_controller.rb +84 -0
  6. data/app/helpers/solid_objects/actor_helper.rb +22 -4
  7. data/config/routes.rb +1 -0
  8. data/docs/realtime.md +161 -0
  9. data/lib/solid_objects/actor.rb +7 -0
  10. data/lib/solid_objects/actor_channel.rb +55 -4
  11. data/lib/solid_objects/actor_definition.rb +19 -0
  12. data/lib/solid_objects/actor_view.rb +14 -7
  13. data/lib/solid_objects/component_registration.rb +23 -4
  14. data/lib/solid_objects/component_subscriptions.rb +16 -10
  15. data/lib/solid_objects/component_token.rb +19 -2
  16. data/lib/solid_objects/errors.rb +6 -0
  17. data/lib/solid_objects/executor.rb +22 -7
  18. data/lib/solid_objects/payload_broadcast.rb +67 -0
  19. data/lib/solid_objects/stream_token.rb +19 -12
  20. data/lib/solid_objects/turbo_stream_renderer.rb +34 -5
  21. data/lib/solid_objects/version.rb +1 -1
  22. data/lib/solid_objects.rb +1 -0
  23. data/sig/generated/controllers/solid_objects/components_controller.rbs +11 -0
  24. data/sig/generated/helpers/solid_objects/actor_helper.rbs +2 -2
  25. data/sig/generated/lib/solid_objects/actor.rbs +3 -0
  26. data/sig/generated/lib/solid_objects/actor_channel.rbs +11 -0
  27. data/sig/generated/lib/solid_objects/actor_definition.rbs +7 -0
  28. data/sig/generated/lib/solid_objects/actor_view.rbs +7 -4
  29. data/sig/generated/lib/solid_objects/component_registration.rbs +11 -4
  30. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +3 -0
  31. data/sig/generated/lib/solid_objects/component_token.rbs +7 -2
  32. data/sig/generated/lib/solid_objects/errors.rbs +6 -0
  33. data/sig/generated/lib/solid_objects/executor.rbs +7 -4
  34. data/sig/generated/lib/solid_objects/payload_broadcast.rbs +35 -0
  35. data/sig/generated/lib/solid_objects/stream_token.rbs +5 -2
  36. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +6 -0
  37. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ac17dc212792f2dfa71c01e701a9e4de27f54b6198ec6e64696b855ff82c4c8
4
- data.tar.gz: 45bff1ddb2bbc0ff80ddfb55488fec0380d02c729e08a7f06b000b744dddb82d
3
+ metadata.gz: 0614e42e260b81ab323d292435cd4f518a4f0b26a21865e5f63c9768db362231
4
+ data.tar.gz: a8c7d4bc22e431b89ab07058c0ad35ca82a44eef08d30b0a547b28c0dd76f82e
5
5
  SHA512:
6
- metadata.gz: ffb590f2e07a0de4d4ba2ab5ac1cb7d76420b742d36d656b85ecdb0fc401dbabd92b75e47df73948f7e1d42fe4a289d2f16fa8d86086c8bff336ac4e33ae89dc
7
- data.tar.gz: 5a43e24c8358e2352de0ca1428cdc10d15bdf679c62ca1983b420fa1e739d0c975004e0e33ffb64a96fe3d09cbbcd30181db6a95a678c071fd80f85b935811cd
6
+ metadata.gz: 07caec47a1c3452bb3f3b6fc6f950f60f9c68277ebb558630d112fcc7bfb9e3591f893e8bdaefea4329680cde6d6a86c36c0d9b6531d6ac889507abfda939920
7
+ data.tar.gz: 23d756629a2b8212d06077569b9d0c35552c6ee416c95851ee78357ecfe1abf672e0a973d9715f0b66942ecebf8e1c007f21d33bde3bfb7e79af30f323a809bf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 - 2026-08-09
4
+
5
+ - Add `batch:` to reactive components. Components sharing a batch in one actor
6
+ scope collapse into a single browser request per revision instead of one
7
+ request per component. The new `GET /solid_objects/components/batch` endpoint
8
+ returns HTML frames inside a documented JSON envelope, so Turbo morph and ERB
9
+ rendering are unchanged while the contract stays machine readable. Duplicate
10
+ notifications for the same batch and revision coalesce in the browser,
11
+ unchanged components are never requested, and stale frames cannot overwrite a
12
+ newer target. Components without `batch:` behave exactly as before.
13
+ - Add a JavaScript test suite for the browser modules, run in CI with Node's
14
+ test runner and jsdom.
15
+
16
+ ## 0.6.0 - 2026-08-09
17
+
18
+ - Add `broadcast_payload`, an actor DSL for sending one personalized JSON state
19
+ payload over the actor stream a page already has open. The block runs once per
20
+ subscriber with that subscriber's authorization context, so private state
21
+ never crosses sessions. Payloads carry actor identity and the monotonic state
22
+ revision, and both the channel and the browser drop stale revisions. Subscribe
23
+ with `solid_object room, payloads: :playmat_state` and handle the
24
+ `solid-objects:payload` DOM event. ERB component refreshes remain the default
25
+ and are unchanged. A mutation that changes payload state without changing a
26
+ declared observable still invalidates subscribers, through a revision-only
27
+ broadcast that carries no observable value to the browser.
28
+
3
29
  ## 0.5.2 - 2026-08-09
4
30
 
5
31
  - Read the database clock once per transaction instead of once per step, and
@@ -0,0 +1,148 @@
1
+ const pendingBatches = new Map()
2
+ const activeBatches = new Map()
3
+
4
+ class SolidObjectsBatchRefreshElement extends HTMLElement {
5
+ connectedCallback() {
6
+ if (this.dataset.started === "true") return
7
+
8
+ this.dataset.started = "true"
9
+ this.enqueue()
10
+ }
11
+
12
+ // Several observables changing in one commit produce several notifications
13
+ // for the same batch and revision. Merging them in a microtask turns those
14
+ // into a single request.
15
+ enqueue() {
16
+ const batch = this.dataset.batch
17
+ const revision = this.dataset.revision
18
+ const source = this.dataset.source
19
+ const scope = this.closest("[id]")?.id
20
+ if (!batch || !revision || !source || !scope) return this.remove()
21
+
22
+ // Two actor scopes may reuse a batch name on one page. Keying by scope
23
+ // keeps their requests from merging or cancelling each other.
24
+ const group = `${scope}:${batch}`
25
+ const key = `${group}:${revision}`
26
+ const pending = pendingBatches.get(key)
27
+ if (pending) {
28
+ pending.sources.add(source)
29
+ this.remove()
30
+ return
31
+ }
32
+
33
+ const merged = { sources: new Set([ source ]) }
34
+ pendingBatches.set(key, merged)
35
+ queueMicrotask(() => {
36
+ pendingBatches.delete(key)
37
+ requestBatch(group, batch, merged.sources)
38
+ })
39
+ this.remove()
40
+ }
41
+ }
42
+
43
+ async function requestBatch(group, batch, sources) {
44
+ const previous = activeBatches.get(group)
45
+ previous?.abort()
46
+
47
+ const controller = new AbortController()
48
+ activeBatches.set(group, controller)
49
+
50
+ try {
51
+ const url = mergedUrl(sources)
52
+ if (!url) return
53
+
54
+ const response = await fetch(url, {
55
+ credentials: "same-origin",
56
+ headers: { Accept: "application/json" },
57
+ redirect: "error",
58
+ signal: controller.signal
59
+ })
60
+ if (!response.ok) return dispatchBatchError(batch, `http_${response.status}`)
61
+
62
+ const body = await response.json()
63
+ if (!Array.isArray(body?.frames)) {
64
+ return dispatchBatchError(batch, "invalid_response")
65
+ }
66
+
67
+ body.frames.forEach(applyFrame)
68
+ } catch (error) {
69
+ if (error.name !== "AbortError") dispatchBatchError(batch, "request_failed")
70
+ } finally {
71
+ if (activeBatches.get(group) === controller) activeBatches.delete(group)
72
+ }
73
+ }
74
+
75
+ // Every notification for one batch and revision carries the same endpoint and
76
+ // differs only by which components changed, so the union of their tokens is the
77
+ // complete set to render.
78
+ function mergedUrl(sources) {
79
+ const urls = [ ...sources ].map((source) => new URL(source, window.location.href))
80
+ const first = urls[0]
81
+ if (!first || first.origin !== window.location.origin) return
82
+
83
+ const tokens = new Set()
84
+ urls.forEach((url) => {
85
+ url.searchParams.getAll("tokens[]").forEach((token) => tokens.add(token))
86
+ })
87
+ first.searchParams.delete("tokens[]")
88
+ tokens.forEach((token) => first.searchParams.append("tokens[]", token))
89
+ return first
90
+ }
91
+
92
+ function applyFrame(frame) {
93
+ const target = document.getElementById(frame?.target)
94
+ if (!target || !frame.html) return
95
+ if (!newerRevision(frame.revision, target.dataset.solidObjectsRevision)) return
96
+
97
+ const parsed = new DOMParser().parseFromString(frame.html, "text/html")
98
+ const replacement = parsed.getElementById(frame.target)
99
+ if (!replacement) return
100
+
101
+ const stream = document.createElement("turbo-stream")
102
+ stream.setAttribute("action", "replace")
103
+ if (frame.refresh_method === "morph") stream.setAttribute("method", "morph")
104
+ stream.setAttribute("target", frame.target)
105
+
106
+ const template = document.createElement("template")
107
+ template.content.append(document.importNode(replacement, true))
108
+ stream.append(template)
109
+ document.documentElement.append(stream)
110
+ }
111
+
112
+ function newerRevision(candidate, current) {
113
+ const candidateRevision = parseRevision(candidate)
114
+ const currentRevision = parseRevision(current)
115
+ if (!candidateRevision || !currentRevision) return false
116
+
117
+ return candidateRevision[0] > currentRevision[0] ||
118
+ (candidateRevision[0] === currentRevision[0] &&
119
+ candidateRevision[1] > currentRevision[1])
120
+ }
121
+
122
+ function parseRevision(revision) {
123
+ if (!revision) return
124
+
125
+ const values = String(revision).split(":").map(Number)
126
+ if (
127
+ values.length !== 2 ||
128
+ values.some((value) => !Number.isSafeInteger(value) || value < 0)
129
+ ) return
130
+
131
+ return values
132
+ }
133
+
134
+ function dispatchBatchError(batch, reason) {
135
+ document.dispatchEvent(
136
+ new CustomEvent("solid-objects:batch-refresh-error", {
137
+ bubbles: true,
138
+ detail: { batch, reason }
139
+ })
140
+ )
141
+ }
142
+
143
+ if (!customElements.get("solid-objects-batch-refresh")) {
144
+ customElements.define(
145
+ "solid-objects-batch-refresh",
146
+ SolidObjectsBatchRefreshElement
147
+ )
148
+ }
@@ -0,0 +1,69 @@
1
+ const deliveredRevisions = new Map()
2
+
3
+ class SolidObjectsPayloadElement extends HTMLElement {
4
+ connectedCallback() {
5
+ if (this.dataset.started === "true") return
6
+
7
+ this.dataset.started = "true"
8
+ this.deliver()
9
+ }
10
+
11
+ deliver() {
12
+ try {
13
+ const name = this.dataset.name
14
+ const revision = revisionFor(this)
15
+ const scope = this.closest("[id]")
16
+ if (!name || !revision || !scope) return
17
+
18
+ const key = `${scope.id}:${name}`
19
+ if (!newerRevision(revision, deliveredRevisions.get(key))) return
20
+
21
+ const payload = JSON.parse(this.textContent)
22
+ deliveredRevisions.set(key, revision)
23
+ scope.dispatchEvent(
24
+ new CustomEvent("solid-objects:payload", {
25
+ bubbles: true,
26
+ detail: {
27
+ name,
28
+ instanceId: revision[0],
29
+ revision: revision[1],
30
+ payload
31
+ }
32
+ })
33
+ )
34
+ } catch {
35
+ this.dispatchEvent(
36
+ new CustomEvent("solid-objects:payload-error", {
37
+ bubbles: true,
38
+ detail: { reason: "invalid_payload" }
39
+ })
40
+ )
41
+ } finally {
42
+ this.remove()
43
+ }
44
+ }
45
+ }
46
+
47
+ function newerRevision(candidate, current) {
48
+ if (!current) return true
49
+
50
+ return candidate[0] > current[0] ||
51
+ (candidate[0] === current[0] && candidate[1] > current[1])
52
+ }
53
+
54
+ function revisionFor(element) {
55
+ const revision = element.dataset.revision
56
+ if (!revision) return
57
+
58
+ const values = revision.split(":").map(Number)
59
+ if (
60
+ values.length !== 2 ||
61
+ values.some((value) => !Number.isSafeInteger(value) || value < 0)
62
+ ) return
63
+
64
+ return values
65
+ }
66
+
67
+ if (!customElements.get("solid-objects-payload")) {
68
+ customElements.define("solid-objects-payload", SolidObjectsPayloadElement)
69
+ }
@@ -6,13 +6,97 @@ module SolidObjects
6
6
  class ComponentsController < ActionController::Base
7
7
  protect_from_forgery with: :exception
8
8
 
9
+ BATCH_LIMIT = 50
10
+
9
11
  # @rbs () -> void
10
12
  def show
11
13
  SolidObjects.instrument(:"component.refreshed") { |payload| refresh(payload) }
12
14
  end
13
15
 
16
+ # @rbs () -> void
17
+ def batch
18
+ SolidObjects.instrument(:"component.batch_refreshed") { |payload| refresh_batch(payload) }
19
+ end
20
+
14
21
  private
15
22
 
23
+ # @rbs (Hash[Symbol, untyped]) -> void
24
+ def refresh_batch(payload)
25
+ tokens = Array(params.require(:tokens))
26
+ raise ActionController::ParameterMissing, :tokens if tokens.empty?
27
+ raise ArgumentError if tokens.length > BATCH_LIMIT
28
+
29
+ registrations = tokens.map { |token| ComponentRegistration.from_token(token) }
30
+ validate_single_batch!(registrations)
31
+ requested_revision = requested_revision_key
32
+ snapshot = ActorSnapshot.new(registrations.first.reference)
33
+ payload.merge!(
34
+ actor_type: snapshot.reference.actor_type,
35
+ actor_id: snapshot.reference.actor_id,
36
+ batch: registrations.first.batch,
37
+ components: registrations.map(&:component_name),
38
+ instance_id: snapshot.instance_id,
39
+ revision: snapshot.revision
40
+ )
41
+ if newer_than_snapshot?(requested_revision, snapshot)
42
+ payload[:outcome] = "conflict"
43
+ return head :conflict
44
+ end
45
+
46
+ authorization_context = SolidObjects
47
+ .configuration
48
+ .component_authorization_context
49
+ .call(controller: self)
50
+ frames = registrations.map do |registration|
51
+ rendered = ComponentRenderer.new(
52
+ snapshot:,
53
+ registration:,
54
+ view_context: component_view_context,
55
+ authorization_context:
56
+ ).call
57
+ {
58
+ "target" => registration.dom_id,
59
+ "revision" => "#{snapshot.instance_id}:#{snapshot.revision}",
60
+ "refresh_method" => registration.refresh_method,
61
+ "html" => component_frame(registration, snapshot, rendered)
62
+ }
63
+ end
64
+ response.headers["Cache-Control"] = "private, no-store"
65
+ payload[:outcome] = "rendered"
66
+ render json: {
67
+ "actor_type" => snapshot.reference.actor_type,
68
+ "actor_id" => snapshot.reference.actor_id,
69
+ "batch" => registrations.first.batch,
70
+ "instance_id" => snapshot.instance_id,
71
+ "revision" => snapshot.revision,
72
+ "frames" => frames
73
+ }
74
+ rescue Unauthorized
75
+ payload[:outcome] = "unauthorized"
76
+ head :forbidden
77
+ rescue UnknownComponent
78
+ payload[:outcome] = "unknown_component"
79
+ head :not_found
80
+ rescue ActionController::ParameterMissing,
81
+ ArgumentError,
82
+ InvalidComponentToken
83
+ payload[:outcome] = "invalid_token"
84
+ head :bad_request
85
+ end
86
+
87
+ # @rbs (Array[ComponentRegistration]) -> void
88
+ def validate_single_batch!(registrations)
89
+ first = registrations.first
90
+ raise ArgumentError unless first.batch
91
+ return if registrations.all? do |registration|
92
+ registration.batch == first.batch &&
93
+ registration.reference.actor_type == first.reference.actor_type &&
94
+ registration.reference.actor_id == first.reference.actor_id
95
+ end
96
+
97
+ raise ArgumentError
98
+ end
99
+
16
100
  # @rbs (Hash[Symbol, untyped]) -> void
17
101
  def refresh(payload)
18
102
  registration = ComponentRegistration.from_token(
@@ -2,8 +2,9 @@
2
2
 
3
3
  module SolidObjects
4
4
  module ActorHelper
5
- # @rbs (Reference, ?authorization_context: untyped) { (ActorView) -> untyped } -> untyped
6
- def solid_object(reference, authorization_context: self, &block)
5
+ # @rbs (Reference, ?authorization_context: untyped, ?payloads: untyped) { (ActorView) -> untyped } -> untyped
6
+ def solid_object(reference, authorization_context: self, payloads: nil, &block)
7
+ payload_names = Array(payloads).map(&:to_s).uniq.presence
7
8
  actor = ActorView.new(
8
9
  reference:,
9
10
  view_context: self,
@@ -13,7 +14,8 @@ module SolidObjects
13
14
  subscription_data = {
14
15
  token: StreamToken.generate(
15
16
  reference,
16
- observables: actor.scalar_observable_names
17
+ observables: actor.scalar_observable_names,
18
+ payloads: payload_names
17
19
  )
18
20
  }
19
21
  if actor.component_tokens.any?
@@ -30,10 +32,26 @@ module SolidObjects
30
32
  data: { turbo_track: "reload" }
31
33
  )
32
34
  end
35
+ batch_client = if actor.batched_components?
36
+ javascript_include_tag(
37
+ "solid_objects/component_batch_refresh",
38
+ type: "module",
39
+ data: { turbo_track: "reload" }
40
+ )
41
+ end
42
+ payload_client = if payload_names
43
+ javascript_include_tag(
44
+ "solid_objects/state_payload",
45
+ type: "module",
46
+ data: { turbo_track: "reload" }
47
+ )
48
+ end
33
49
 
34
50
  content_tag(
35
51
  :div,
36
- safe_join([ refresh_client, subscription, content ].compact),
52
+ safe_join(
53
+ [ refresh_client, batch_client, payload_client, subscription, content ].compact
54
+ ),
37
55
  id: DomIdentity.scope(reference)
38
56
  )
39
57
  end
data/config/routes.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  SolidObjects::Engine.routes.draw do
4
4
  get :components, to: "components#show"
5
+ get "components/batch", to: "components#batch"
5
6
  resources :instances, only: %i[index show]
6
7
  resources :dead_letters, only: %i[index] do
7
8
  post :retry, on: :member
data/docs/realtime.md CHANGED
@@ -110,6 +110,167 @@ applications discover the namespaced engine asset. Applications created with
110
110
  explicitly serve the module. Turbo's normal morph rules still apply; use
111
111
  `data-turbo-permanent` for elements that must never be changed.
112
112
 
113
+ ## Batched component refreshes
114
+
115
+ A component refresh costs one browser request. When one actor mutation changes
116
+ several components, the page pays one request per component. Adding `batch:`
117
+ groups them so a revision costs one request no matter how many components in the
118
+ group changed:
119
+
120
+ ```erb
121
+ <%= actor.component :player, key: 1,
122
+ observes: :player_one, batch: :playmat, refresh: :morph %>
123
+
124
+ <%= actor.component :player_controls, key: 1,
125
+ observes: :player_one_controls, batch: :playmat, refresh: :morph %>
126
+
127
+ <%= actor.component :library_search, key: 1,
128
+ observes: :library, batch: :playmat, refresh: :morph %>
129
+ ```
130
+
131
+ Before, one mutation touching all three observables produced three requests:
132
+
133
+ ```
134
+ commit -> 3 invalidations -> 3 refresh elements -> 3 GET /solid_objects/components
135
+ ```
136
+
137
+ After, the three notifications coalesce in the browser into one request:
138
+
139
+ ```
140
+ commit -> 3 invalidations -> 1 GET /solid_objects/components/batch -> 3 frames
141
+ ```
142
+
143
+ ### The batch endpoint
144
+
145
+ `GET /solid_objects/components/batch` takes the signed `tokens[]` of the
146
+ components to render plus the `instance_id` and `revision` the browser holds. It
147
+ returns HTML frames inside a JSON envelope:
148
+
149
+ ```json
150
+ {
151
+ "actor_type": "playmat_room",
152
+ "actor_id": "table-1",
153
+ "batch": "playmat",
154
+ "instance_id": 12,
155
+ "revision": 48,
156
+ "frames": [
157
+ {
158
+ "target": "solid-objects-component-...",
159
+ "revision": "12:48",
160
+ "refresh_method": "morph",
161
+ "html": "<turbo-frame id=\"...\" data-solid-objects-revision=\"12:48\">...</turbo-frame>"
162
+ }
163
+ ]
164
+ }
165
+ ```
166
+
167
+ **Why frames inside JSON rather than one HTML document or pure JSON state.** HTML
168
+ alone would force the browser to pick frames out of an undocumented document.
169
+ Pure JSON would mean a second renderer and would give up ERB and Turbo morph. A
170
+ JSON envelope of frame descriptors keeps `ComponentRenderer` and Turbo exactly as
171
+ they are while giving the client a documented contract with per-frame revisions.
172
+
173
+ ### What the protocol guarantees
174
+
175
+ Only components whose dependencies changed are requested; the rest are never
176
+ named in the batch. Duplicate notifications for the same batch and revision merge
177
+ into one request, and a superseded request for the same batch is aborted. Each
178
+ frame carries its own revision and cannot overwrite a target that already holds a
179
+ newer one. Authorization is unchanged: every component in the batch passes the
180
+ same `authorize_query` boundary an individual refresh uses, and the batch name is
181
+ signed into the component token, so a browser cannot invent or widen a group. A
182
+ batch mixing actors or groups is rejected.
183
+
184
+ Components without `batch:` keep issuing their own request, and a scope can mix
185
+ batched and unbatched components freely.
186
+
187
+ ## Personalized state payloads
188
+
189
+ Reactive ERB components cost one browser request per changed component. When a
190
+ single actor mutation changes several components, an application pays several
191
+ round trips for one logical update. A payload broadcast collapses that into one
192
+ message on the stream the page already has open.
193
+
194
+ Declare the payload on the actor. The block receives the actor and the
195
+ subscriber's authorization context, and it runs **once per subscriber**, so two
196
+ sessions watching the same actor never see each other's private state:
197
+
198
+ ```ruby
199
+ class PlaymatRoom < SolidObjects::Actor
200
+ actor_type "playmat_room"
201
+
202
+ attribute :hands, default: -> { {} }
203
+ attribute :turn, default: 1
204
+
205
+ observable :turn
206
+
207
+ broadcast_payload :playmat_state do |room, authorization_context|
208
+ {
209
+ "turn" => room.turn,
210
+ "hand" => room.hands.fetch(authorization_context.session_id, [])
211
+ }
212
+ end
213
+ end
214
+ ```
215
+
216
+ Subscribe the scope to it:
217
+
218
+ ```erb
219
+ <%= solid_object room, payloads: :playmat_state do |actor| %>
220
+ <div data-playmat></div>
221
+ <% end %>
222
+ ```
223
+
224
+ Handle it with any JavaScript. The gem dispatches a DOM event and requires no
225
+ framework:
226
+
227
+ ```javascript
228
+ document.addEventListener("solid-objects:payload", (event) => {
229
+ const { name, revision, payload } = event.detail
230
+ if (name !== "playmat_state") return
231
+
232
+ renderPlaymat(payload)
233
+ })
234
+ ```
235
+
236
+ ### What the protocol guarantees
237
+
238
+ The payload travels as a Turbo Stream element on the existing actor stream, so
239
+ applications do not run a second WebSocket system. Each message carries the
240
+ actor identity plus the `instance_id` and monotonic `state_revision` that fence
241
+ component refreshes, and both the channel and the browser drop a payload that
242
+ is not newer than the last one delivered for that scope and name. A reconnecting
243
+ client receives the current payload on subscribe.
244
+
245
+ Authorization is the same `authorize_query` boundary that components use, called
246
+ with the payload name and the subscriber's Cable connection. A subscriber that
247
+ fails the check is skipped rather than served a partial payload, and the payload
248
+ name is signed into the stream token, so a browser cannot ask for a payload the
249
+ server did not offer.
250
+
251
+ Payload blocks read committed actor state through the same snapshot components
252
+ use. They cannot write application records, and the return value must be a JSON
253
+ object or array so the wire format stays inspectable.
254
+
255
+ ### mtg-playmat before and after
256
+
257
+ Before, one mutation that touched three observables produced three refresh
258
+ elements and three HTTP requests:
259
+
260
+ ```
261
+ commit -> 3 Action Cable messages -> 3 GET /solid_objects/components -> 3 renders
262
+ ```
263
+
264
+ After, the same mutation delivers one personalized payload and the page renders
265
+ once:
266
+
267
+ ```
268
+ commit -> 1 Action Cable message -> 0 HTTP requests -> 1 render
269
+ ```
270
+
271
+ Components remain the default. An actor with no `broadcast_payload` and a scope
272
+ with no `payloads:` option behave exactly as before.
273
+
113
274
  ## Authorization
114
275
 
115
276
  The HTML contains a signed actor identity token. Signing prevents modification;
@@ -57,6 +57,13 @@ module SolidObjects
57
57
  definition.add_observable(name, block)
58
58
  end
59
59
 
60
+ # @rbs (Symbol | String) { (untyped, untyped) -> untyped } -> ActorDefinition::Handler
61
+ def broadcast_payload(name, &block)
62
+ raise InvalidActor, "payload broadcasts require a block" unless block
63
+
64
+ definition.add_payload_broadcast(name, block)
65
+ end
66
+
60
67
  # @rbs (?Integer) -> Integer
61
68
  def state_version(version = nil)
62
69
  definition.set_state_version(version) if version
@@ -19,7 +19,9 @@ module SolidObjects
19
19
 
20
20
  @reference = Reference.new(actor_type:, actor_id:)
21
21
  @scalar_observables = identity["observables"]
22
+ @payload_names = identity["payloads"]
22
23
  validate_scalar_observables!
24
+ validate_payload_names!
23
25
  @component_subscriptions = ComponentSubscriptions.parse(
24
26
  params["components"],
25
27
  reference:
@@ -36,6 +38,7 @@ module SolidObjects
36
38
  )
37
39
  end
38
40
  refresh_outdated_components(snapshot)
41
+ transmit_state_payloads(snapshot)
39
42
  rescue KeyError,
40
43
  JSON::ParserError,
41
44
  InvalidStreamToken,
@@ -46,14 +49,20 @@ module SolidObjects
46
49
 
47
50
  private
48
51
 
49
- attr_reader :reference, :component_subscriptions, :scalar_observables
52
+ attr_reader :reference,
53
+ :component_subscriptions,
54
+ :scalar_observables,
55
+ :payload_names
50
56
 
51
57
  # @rbs (String) -> void
52
58
  def receive_broadcast(stream)
53
59
  invalidation = TurboStreamRenderer.invalidation(stream)
54
- if !invalidation ||
55
- scalar_observables.nil? ||
56
- scalar_observables.include?(invalidation.fetch("observable_name"))
60
+ revision_only = invalidation &&
61
+ invalidation.fetch("observable_name") == PayloadBroadcast::REVISION_OBSERVABLE
62
+ if !revision_only &&
63
+ (!invalidation ||
64
+ scalar_observables.nil? ||
65
+ scalar_observables.include?(invalidation.fetch("observable_name")))
57
66
  transmit stream
58
67
  end
59
68
  return unless invalidation
@@ -61,6 +70,48 @@ module SolidObjects
61
70
  component_subscriptions
62
71
  .refreshes_for(invalidation)
63
72
  .each { |refresh| transmit refresh }
73
+ transmit_state_payloads(ActorSnapshot.new(reference))
74
+ end
75
+
76
+ # @rbs (ActorSnapshot) -> void
77
+ def transmit_state_payloads(snapshot)
78
+ return if payload_names.nil? || payload_names.empty?
79
+ return unless newer_payload_revision?(snapshot)
80
+
81
+ payload_names.each do |name|
82
+ payload = PayloadBroadcast.new(
83
+ snapshot:,
84
+ name:,
85
+ authorization_context: connection
86
+ ).call
87
+ transmit TurboStreamRenderer.state_payload(payload)
88
+ rescue Unauthorized
89
+ next
90
+ end
91
+ @payload_revision = [ snapshot.instance_id, snapshot.revision ]
92
+ end
93
+
94
+ # @rbs (ActorSnapshot) -> bool
95
+ def newer_payload_revision?(snapshot)
96
+ current = @payload_revision
97
+ return true unless current
98
+
99
+ (current <=> [ snapshot.instance_id, snapshot.revision ]) == -1
100
+ end
101
+
102
+ # @rbs () -> void
103
+ def validate_payload_names!
104
+ return unless payload_names
105
+
106
+ broadcasts = SolidObjects
107
+ .registry
108
+ .fetch(reference.actor_type)
109
+ .definition
110
+ .payload_broadcasts
111
+ unknown = payload_names.find { |name| !broadcasts.key?(name.to_sym) }
112
+ return unless unknown
113
+
114
+ raise InvalidStreamToken, "unknown payload broadcast #{unknown.inspect}"
64
115
  end
65
116
 
66
117
  # @rbs (ActorSnapshot) -> void