Skip to content

Commit 96840b4

Browse files
committed
fix: decode Action Cable broadcasts
Use Action Cable's documented JSON decoder for the custom stream callback so observable invalidations receive raw Turbo Stream HTML instead of encoded strings.
1 parent a561c6c commit 96840b4

3 files changed

Lines changed: 82 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Decode Action Cable broadcast payloads before parsing observable invalidations
6+
so scalar updates and component refreshes transmit as raw Turbo Stream HTML.
7+
38
## 0.4.1 - 2026-08-07
49

510
- Load `SolidObjects::ActorChannel` with the gem and pass stream and component

lib/solid_objects/actor_channel.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def subscribed
2424
params["components"],
2525
reference:
2626
)
27-
stream_from StreamName.for(reference) do |stream|
27+
stream_from StreamName.for(reference), coder: ActiveSupport::JSON do |stream|
2828
receive_broadcast(stream)
2929
end
3030
snapshot = ActorSnapshot.new(reference)

test/integration/actor_channel_test.rb

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ def update_missing
3535
end
3636
end
3737

38+
class CapturingActorChannel < SolidObjects::ActorChannel
39+
attr_reader :stream_callback, :stream_coder
40+
41+
def stream_from(_broadcasting, callback = nil, coder: nil, &block)
42+
@stream_callback = callback || block
43+
@stream_coder = coder
44+
end
45+
end
46+
3847
setup do
3948
SolidObjects.reset!
4049
ChannelActor.ensure_registered!
@@ -115,6 +124,71 @@ def update_missing
115124
worker&.stop
116125
end
117126

127+
test "decodes Action Cable broadcasts before reactive processing" do
128+
reference = ChannelActor.ref("actor-1")
129+
SolidObjects.configuration.authorize_subscription = ->(**) { true }
130+
connection = ActionCable::Channel::ConnectionStub.new
131+
channel = CapturingActorChannel.new(
132+
connection,
133+
"actor-channel",
134+
{
135+
token: SolidObjects::StreamToken.generate(
136+
reference,
137+
observables: %w[missing]
138+
),
139+
components: JSON.generate(
140+
[
141+
component_token(
142+
reference,
143+
component_name: "summary",
144+
dependencies: %w[missing],
145+
revision: 0
146+
)
147+
]
148+
)
149+
}.with_indifferent_access
150+
)
151+
channel.subscribe_to_channel
152+
153+
assert_equal ActiveSupport::JSON, channel.stream_coder
154+
connection.transmissions.clear
155+
156+
reference.async(:update_missing)
157+
worker = SolidObjects::Worker.new
158+
worker.run_until_idle
159+
broadcast = SolidObjects::Broadcast.find_by!(observable_name: "missing")
160+
SolidObjects::ActionCableBroadcastAdapter.new.call(broadcast)
161+
stream_name = SolidObjects::StreamName.for(reference)
162+
encoded_stream = ActionCable.server.pubsub.broadcasts(stream_name).sole
163+
handler = channel.__send__(
164+
:stream_handler,
165+
stream_name,
166+
channel.stream_callback,
167+
coder: channel.stream_coder
168+
)
169+
170+
handler.call(encoded_stream)
171+
172+
channel_transmissions = connection.transmissions.filter_map do |transmission|
173+
transmission["message"]
174+
end
175+
scalar_target = SolidObjects::DomIdentity.observable(reference, :missing)
176+
scalar_update = channel_transmissions.find do |transmission|
177+
transmission.include?(scalar_target) &&
178+
transmission.include?(">1</span>")
179+
end
180+
assert scalar_update
181+
assert_equal 1,
182+
component_refreshes(
183+
reference,
184+
:summary,
185+
messages: channel_transmissions
186+
).length
187+
refute channel_transmissions.any? { |transmission| transmission.start_with?("\"") }
188+
ensure
189+
worker&.stop
190+
end
191+
118192
test "streams only after token verification and host authorization" do
119193
reference = ChannelActor.ref("actor-1")
120194
SolidObjects.configuration.authorize_subscription = lambda do |actor_type:, actor_id:, authorization_context:|
@@ -371,9 +445,9 @@ def component_token(reference, component_name:, dependencies:, revision:)
371445
)
372446
end
373447

374-
def component_refreshes(reference, component_name)
448+
def component_refreshes(reference, component_name, messages: transmissions)
375449
target = SolidObjects::DomIdentity.component(reference, component_name)
376-
transmissions.select do |transmission|
450+
messages.select do |transmission|
377451
transmission.include?(%(target="#{target}")) &&
378452
transmission.include?("<turbo-frame")
379453
end

0 commit comments

Comments
 (0)