Skip to content

Commit ccb23bd

Browse files
committed
fix: invalidate payloads on observable-free mutations
A message that changed state a payload reads without changing a declared observable enqueued no broadcast, so subscribers kept stale personalized state until an unrelated observable changed. Actors with payload broadcasts now enqueue a revision-only broadcast for those commits. The renderer emits invalidation metadata without an observable turbo stream, and the channel does not forward it, so no actor value reaches the browser through this path. Queries still enqueue nothing.
1 parent 2785fe9 commit ccb23bd

8 files changed

Lines changed: 88 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
revision, and both the channel and the browser drop stale revisions. Subscribe
1010
with `solid_object room, payloads: :playmat_state` and handle the
1111
`solid-objects:payload` DOM event. ERB component refreshes remain the default
12-
and are unchanged.
12+
and are unchanged. A mutation that changes payload state without changing a
13+
declared observable still invalidates subscribers, through a revision-only
14+
broadcast that carries no observable value to the browser.
1315

1416
## 0.5.2 - 2026-08-09
1517

lib/solid_objects/actor_channel.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,12 @@ def subscribed
5757
# @rbs (String) -> void
5858
def receive_broadcast(stream)
5959
invalidation = TurboStreamRenderer.invalidation(stream)
60-
if !invalidation ||
61-
scalar_observables.nil? ||
62-
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")))
6366
transmit stream
6467
end
6568
return unless invalidation

lib/solid_objects/executor.rb

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def call
2727
result = invoke_actor(message_context)
2828
ensure_query_did_not_mutate_state!(state_before)
2929
observable_changes = changed_observables(observables_before, actor.observable_values)
30-
complete(result, observable_changes)
30+
complete(result, observable_changes, state_changed: actor.state.to_h != state_before)
3131
true
3232
rescue LostActivation
3333
raise
@@ -75,8 +75,8 @@ def changed_observables(before, after)
7575
end
7676
end
7777

78-
# @rbs (untyped, Hash[String, untyped]) -> void
79-
def complete(result, observable_changes)
78+
# @rbs (untyped, Hash[String, untyped], state_changed: bool) -> void
79+
def complete(result, observable_changes, state_changed:)
8080
serialized_state = Serialization.dump(
8181
actor.state.to_h,
8282
max_bytes: SolidObjects.configuration.max_state_bytes
@@ -111,7 +111,12 @@ def complete(result, observable_changes)
111111
enqueued_effects.concat(
112112
enqueue_actor_messages(locked_message, instance, outbound_message_intents)
113113
)
114-
enqueue_broadcasts(locked_message, instance, observable_changes)
114+
enqueue_broadcasts(
115+
locked_message,
116+
instance,
117+
observable_changes,
118+
state_changed:
119+
)
115120
claimed_message.destroy!
116121
end
117122

@@ -249,9 +254,14 @@ def enqueue_actor_messages(locked_message, instance, intents)
249254
end
250255
end
251256

252-
# @rbs (Message, Instance, Hash[String, untyped]) -> void
253-
def enqueue_broadcasts(locked_message, instance, observable_changes)
254-
observable_changes.each do |observable_name, value|
257+
# @rbs (Message, Instance, Hash[String, untyped], state_changed: bool) -> void
258+
def enqueue_broadcasts(locked_message, instance, observable_changes, state_changed:)
259+
broadcasts = observable_changes
260+
if broadcasts.empty? && state_changed && payload_broadcasts?
261+
broadcasts = { PayloadBroadcast::REVISION_OBSERVABLE => {} }
262+
end
263+
264+
broadcasts.each do |observable_name, value|
255265
Broadcast.create!(
256266
message: locked_message,
257267
instance:,
@@ -266,6 +276,11 @@ def enqueue_broadcasts(locked_message, instance, observable_changes)
266276
end
267277
end
268278

279+
# @rbs () -> bool
280+
def payload_broadcasts?
281+
actor.class.definition.payload_broadcasts.any?
282+
end
283+
269284
# @rbs (Exception) -> void
270285
def fail_message(error)
271286
error_details = serialized_error(error)

lib/solid_objects/payload_broadcast.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
module SolidObjects
44
class PayloadBroadcast
55
MAXIMUM_PAYLOAD_BYTES = 1_048_576
6+
REVISION_OBSERVABLE = "solid_objects.revision"
67

78
# @rbs @snapshot: ActorSnapshot
89
# @rbs @name: String

lib/solid_objects/turbo_stream_renderer.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ def observable(broadcast)
1616
actor_type: broadcast.instance.actor_type,
1717
actor_id: broadcast.instance.actor_id
1818
)
19-
stream = observable_value(
20-
reference,
21-
broadcast.observable_name,
22-
broadcast.value
23-
)
19+
stream = if broadcast.observable_name == PayloadBroadcast::REVISION_OBSERVABLE
20+
""
21+
else
22+
observable_value(
23+
reference,
24+
broadcast.observable_name,
25+
broadcast.value
26+
)
27+
end
2428
metadata = Base64.urlsafe_encode64(
2529
JSON.generate(
2630
"instance_id" => broadcast.instance_id,

sig/generated/lib/solid_objects/executor.rbs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ module SolidObjects
3030
# @rbs (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
3131
def changed_observables: (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
3232

33-
# @rbs (untyped, Hash[String, untyped]) -> void
34-
def complete: (untyped, Hash[String, untyped]) -> void
33+
# @rbs (untyped, Hash[String, untyped], state_changed: bool) -> void
34+
def complete: (untyped, Hash[String, untyped], state_changed: bool) -> void
3535

3636
# @rbs (Array[Actor::CommitActionIntent]) -> void
3737
def execute_commit_actions: (Array[Actor::CommitActionIntent]) -> void
@@ -51,8 +51,11 @@ module SolidObjects
5151
# @rbs (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
5252
def enqueue_actor_messages: (Message, Instance, Array[Actor::OutboundMessageIntent]) -> Array[Effect]
5353

54-
# @rbs (Message, Instance, Hash[String, untyped]) -> void
55-
def enqueue_broadcasts: (Message, Instance, Hash[String, untyped]) -> void
54+
# @rbs (Message, Instance, Hash[String, untyped], state_changed: bool) -> void
55+
def enqueue_broadcasts: (Message, Instance, Hash[String, untyped], state_changed: bool) -> void
56+
57+
# @rbs () -> bool
58+
def payload_broadcasts?: () -> bool
5659

5760
# @rbs (Exception) -> void
5861
def fail_message: (Exception) -> void

sig/generated/lib/solid_objects/payload_broadcast.rbs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ module SolidObjects
44
class PayloadBroadcast
55
MAXIMUM_PAYLOAD_BYTES: ::Integer
66

7+
REVISION_OBSERVABLE: ::String
8+
79
@snapshot: ActorSnapshot
810

911
@name: String

test/integration/payload_broadcast_test.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,44 @@ def bump = self.value += 1
150150
assert_includes element, "Island"
151151
end
152152

153+
test "a mutation that changes no observable still invalidates the payload" do
154+
reference = RoomActor.ref("table")
155+
reference.advance_turn
156+
SolidObjects::Broadcast.delete_all
157+
158+
reference.deal(session_id: "alice", cards: %w[Island])
159+
160+
broadcast = SolidObjects::Broadcast.order(:id).last
161+
refute_nil broadcast, "a payload-only mutation must still reach subscribers"
162+
assert_equal SolidObjects::PayloadBroadcast::REVISION_OBSERVABLE,
163+
broadcast.observable_name
164+
end
165+
166+
test "the revision invalidation carries no observable value to the browser" do
167+
reference = RoomActor.ref("table")
168+
reference.advance_turn
169+
SolidObjects::Broadcast.delete_all
170+
reference.deal(session_id: "alice", cards: %w[Black Lotus])
171+
172+
stream = SolidObjects::TurboStreamRenderer.observable(
173+
SolidObjects::Broadcast.order(:id).last
174+
)
175+
176+
refute_includes stream, "turbo-stream"
177+
refute_includes stream, "Black Lotus"
178+
refute_nil SolidObjects::TurboStreamRenderer.invalidation(stream)
179+
end
180+
181+
test "a query does not invalidate the payload" do
182+
reference = RoomActor.ref("table")
183+
reference.deal(session_id: "alice", cards: %w[Island])
184+
SolidObjects::Broadcast.delete_all
185+
186+
reference.turn
187+
188+
assert_equal 0, SolidObjects::Broadcast.count
189+
end
190+
153191
test "actors without a payload broadcast are unaffected" do
154192
plain = Class.new(SolidObjects::Actor) do
155193
actor_type "payload-none"

0 commit comments

Comments
 (0)