Skip to content

Commit 3a88b73

Browse files
authored
Merge pull request #2 from cardmagic/agent/fix-realtime-subscriptions
Fix realtime subscriptions
2 parents f9c7fcf + cd3f39d commit 3a88b73

7 files changed

Lines changed: 125 additions & 10 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+
- Load `SolidObjects::ActorChannel` with the gem and pass stream and component
6+
subscription tokens through Turbo-compatible `data-*` attributes.
7+
38
## 0.4.0 - 2026-08-06
49

510
- Add dependency-driven live ERB components with request-time authorization,

app/helpers/solid_objects/actor_helper.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ def solid_object(reference, authorization_context: self, &block)
1010
authorization_context:
1111
)
1212
content = capture(actor, &block)
13-
subscription_attributes = {
14-
channel: "SolidObjects::ActorChannel",
13+
subscription_data = {
1514
token: StreamToken.generate(
1615
reference,
1716
observables: actor.scalar_observable_names
1817
)
1918
}
2019
if actor.component_tokens.any?
21-
subscription_attributes[:data] = {
22-
components: JSON.generate(actor.component_tokens)
23-
}
20+
subscription_data[:components] = JSON.generate(actor.component_tokens)
2421
end
25-
subscription = tag.turbo_cable_stream_source(**subscription_attributes)
22+
subscription = tag.turbo_cable_stream_source(
23+
channel: "SolidObjects::ActorChannel",
24+
data: subscription_data
25+
)
2626

2727
content_tag(
2828
:div,

lib/solid_objects.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
require "solid_objects/component_renderer"
4343
require "solid_objects/state_snapshot"
4444
require "solid_objects/actor_view"
45+
require "solid_objects/actor_channel"
4546
require "solid_objects/action_cable_broadcast_adapter"
4647
require "solid_objects/wake_up"
4748
require "solid_objects/effect_registry"

lib/solid_objects/actor_channel.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# rbs_inline: enabled
22

3-
require "action_cable/channel/base"
3+
require "action_cable"
44

55
module SolidObjects
66
class ActorChannel < ActionCable::Channel::Base

test/dummy/boot_check.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
abort "engine is not isolated" unless SolidObjects::Engine.isolated?
1010
abort "record is not loaded" unless SolidObjects::Record < ActiveRecord::Base
1111
abort "actor helper is not installed" unless ActionView::Base < SolidObjects::ActorHelper
12+
abort "actor channel is not loaded" unless "SolidObjects::ActorChannel".safe_constantize
1213

1314
puts "solid_objects_dummy_booted"

test/integration/actor_channel_test.rb

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
require "database_test_helper"
44
require "action_cable/test_helper"
55
require "action_cable/channel/test_case"
6-
require "solid_objects/actor_channel"
6+
require "action_view/test_case"
7+
require "action_view/testing/resolvers"
8+
require "cgi/escape"
9+
require_relative "../../app/helpers/solid_objects/actor_helper"
710

811
ActionCable.server.config.cable = { "adapter" => "test" }
912

@@ -38,9 +41,80 @@ def update_missing
3841
SolidObjects.configuration.stream_signing_secret = "test-stream-signing-secret"
3942
SolidObjects.configuration.authorize_message = ->(**) { true }
4043
SolidObjects.configuration.authorize_query = ->(**) { true }
44+
SolidObjects.configuration.component_path_resolver = lambda do |view_context:|
45+
"/solid_objects/components"
46+
end
4147
ActionCable.server.config.logger = Logger.new(nil)
4248
end
4349

50+
test "loads the actor channel with the gem" do
51+
assert_equal SolidObjects::ActorChannel,
52+
"SolidObjects::ActorChannel".safe_constantize
53+
end
54+
55+
test "subscribes to scalar updates through rendered Turbo data" do
56+
reference = ChannelActor.ref("actor-1")
57+
SolidObjects.configuration.authorize_subscription = ->(**) { true }
58+
parameters = rendered_subscription_parameters(reference) do |actor|
59+
actor.missing
60+
end
61+
62+
assert_equal "SolidObjects::ActorChannel", parameters.fetch(:channel)
63+
assert parameters.key?(:token)
64+
refute parameters.key?(:components)
65+
66+
subscribe(**parameters)
67+
68+
assert subscription.confirmed?
69+
assert_has_stream SolidObjects::StreamName.for(reference)
70+
71+
reference.async(:update_missing)
72+
worker = SolidObjects::Worker.new
73+
worker.run_until_idle
74+
broadcast = SolidObjects::Broadcast.find_by!(observable_name: "missing")
75+
subscription.__send__(
76+
:receive_broadcast,
77+
SolidObjects::TurboStreamRenderer.observable(broadcast)
78+
)
79+
80+
target = SolidObjects::DomIdentity.observable(reference, :missing)
81+
updates = transmissions.select { |transmission| transmission.include?(target) }
82+
assert_equal 2, updates.length
83+
assert_includes updates.last, ">1</span>"
84+
ensure
85+
worker&.stop
86+
end
87+
88+
test "subscribes to component updates through rendered Turbo data" do
89+
reference = ChannelActor.ref("actor-1")
90+
SolidObjects.configuration.authorize_subscription = ->(**) { true }
91+
parameters = rendered_subscription_parameters(reference) do |actor|
92+
actor.component(:summary, observes: :missing)
93+
end
94+
95+
assert_equal "SolidObjects::ActorChannel", parameters.fetch(:channel)
96+
assert parameters.key?(:token)
97+
assert parameters.key?(:components)
98+
99+
subscribe(**parameters)
100+
101+
assert subscription.confirmed?
102+
assert_has_stream SolidObjects::StreamName.for(reference)
103+
104+
reference.async(:update_missing)
105+
worker = SolidObjects::Worker.new
106+
worker.run_until_idle
107+
broadcast = SolidObjects::Broadcast.find_by!(observable_name: "missing")
108+
subscription.__send__(
109+
:receive_broadcast,
110+
SolidObjects::TurboStreamRenderer.observable(broadcast)
111+
)
112+
113+
assert_equal 1, component_refreshes(reference, :summary).length
114+
ensure
115+
worker&.stop
116+
end
117+
44118
test "streams only after token verification and host authorization" do
45119
reference = ChannelActor.ref("actor-1")
46120
SolidObjects.configuration.authorize_subscription = lambda do |actor_type:, actor_id:, authorization_context:|
@@ -256,6 +330,36 @@ def update_missing
256330

257331
private
258332

333+
def rendered_subscription_parameters(reference, &block)
334+
html = actor_view.solid_object(reference, &block)
335+
source = html.match(/<turbo-cable-stream-source (?<attributes>[^>]*)>/)
336+
attributes = source[:attributes]
337+
.scan(/([a-z-]+)="([^"]*)"/)
338+
.to_h
339+
data = attributes
340+
.select { |name, _value| name.start_with?("data-") }
341+
.to_h do |name, value|
342+
[ name.delete_prefix("data-").tr("-", "_").to_sym, CGI.unescapeHTML(value) ]
343+
end
344+
345+
{
346+
channel: attributes.fetch("channel"),
347+
**data
348+
}
349+
end
350+
351+
def actor_view
352+
resolver = ActionView::FixtureResolver.new(
353+
"actors/actor_channel_test/channel_actor/_summary.html.erb" =>
354+
"<p><%= actor.missing %></p>"
355+
)
356+
view = ActionView::Base
357+
.with_empty_template_cache
358+
.with_view_paths([ resolver ])
359+
view.extend(SolidObjects::ActorHelper)
360+
view
361+
end
362+
259363
def component_token(reference, component_name:, dependencies:, revision:)
260364
SolidObjects::ComponentToken.generate(
261365
reference:,

test/integration/actor_helper_test.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ def close
7474
assert_includes html, %(channel="SolidObjects::ActorChannel")
7575
assert_includes html, %(id="#{SolidObjects::DomIdentity.scope(reference)}")
7676
refute_includes html, reference.actor_id
77+
assert_includes html, "data-token="
78+
assert_nil html[/<turbo-cable-stream-source[^>]*\stoken="/]
7779

78-
token = html[/token="([^"]+)"/, 1]
80+
token = html[/data-token="([^"]+)"/, 1]
7981
identity = SolidObjects::StreamToken.verify(token)
8082
assert_equal [ "items_count" ], identity.fetch("observables")
8183
end
@@ -110,8 +112,10 @@ def close
110112
assert_includes html, "<li>Second</li>"
111113
refute_includes html, JSON.generate(reference.snapshot.items)
112114
assert_includes html, "data-components="
115+
assert_includes html, "data-token="
116+
assert_nil html[/<turbo-cable-stream-source[^>]*\stoken="/]
113117

114-
token = html[/token="([^"]+)"/, 1]
118+
token = html[/data-token="([^"]+)"/, 1]
115119
identity = SolidObjects::StreamToken.verify(token)
116120
assert_empty identity.fetch("observables")
117121
end

0 commit comments

Comments
 (0)