From cd3f39dd68588f38e5c76c711343027aae26d81c Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Thu, 6 Aug 2026 19:25:54 -0700 Subject: [PATCH] fix: restore realtime subscriptions Load the actor channel with the gem and expose stream credentials as Turbo data attributes so Action Cable receives them. Exercise rendered scalar and component subscriptions through the channel harness. --- CHANGELOG.md | 5 + app/helpers/solid_objects/actor_helper.rb | 12 +-- lib/solid_objects.rb | 1 + lib/solid_objects/actor_channel.rb | 2 +- test/dummy/boot_check.rb | 1 + test/integration/actor_channel_test.rb | 106 +++++++++++++++++++++- test/integration/actor_helper_test.rb | 8 +- 7 files changed, 125 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1325032..a8edc40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Load `SolidObjects::ActorChannel` with the gem and pass stream and component + subscription tokens through Turbo-compatible `data-*` attributes. + ## 0.4.0 - 2026-08-06 - Add dependency-driven live ERB components with request-time authorization, diff --git a/app/helpers/solid_objects/actor_helper.rb b/app/helpers/solid_objects/actor_helper.rb index 93598f2..4d7c2db 100644 --- a/app/helpers/solid_objects/actor_helper.rb +++ b/app/helpers/solid_objects/actor_helper.rb @@ -10,19 +10,19 @@ def solid_object(reference, authorization_context: self, &block) authorization_context: ) content = capture(actor, &block) - subscription_attributes = { - channel: "SolidObjects::ActorChannel", + subscription_data = { token: StreamToken.generate( reference, observables: actor.scalar_observable_names ) } if actor.component_tokens.any? - subscription_attributes[:data] = { - components: JSON.generate(actor.component_tokens) - } + subscription_data[:components] = JSON.generate(actor.component_tokens) end - subscription = tag.turbo_cable_stream_source(**subscription_attributes) + subscription = tag.turbo_cable_stream_source( + channel: "SolidObjects::ActorChannel", + data: subscription_data + ) content_tag( :div, diff --git a/lib/solid_objects.rb b/lib/solid_objects.rb index d00fd9d..db26a27 100644 --- a/lib/solid_objects.rb +++ b/lib/solid_objects.rb @@ -42,6 +42,7 @@ require "solid_objects/component_renderer" require "solid_objects/state_snapshot" require "solid_objects/actor_view" +require "solid_objects/actor_channel" require "solid_objects/action_cable_broadcast_adapter" require "solid_objects/wake_up" require "solid_objects/effect_registry" diff --git a/lib/solid_objects/actor_channel.rb b/lib/solid_objects/actor_channel.rb index 520a94d..05c0e78 100644 --- a/lib/solid_objects/actor_channel.rb +++ b/lib/solid_objects/actor_channel.rb @@ -1,6 +1,6 @@ # rbs_inline: enabled -require "action_cable/channel/base" +require "action_cable" module SolidObjects class ActorChannel < ActionCable::Channel::Base diff --git a/test/dummy/boot_check.rb b/test/dummy/boot_check.rb index 62ca28f..2b43c40 100644 --- a/test/dummy/boot_check.rb +++ b/test/dummy/boot_check.rb @@ -9,5 +9,6 @@ abort "engine is not isolated" unless SolidObjects::Engine.isolated? abort "record is not loaded" unless SolidObjects::Record < ActiveRecord::Base abort "actor helper is not installed" unless ActionView::Base < SolidObjects::ActorHelper +abort "actor channel is not loaded" unless "SolidObjects::ActorChannel".safe_constantize puts "solid_objects_dummy_booted" diff --git a/test/integration/actor_channel_test.rb b/test/integration/actor_channel_test.rb index ae159b6..6edf744 100644 --- a/test/integration/actor_channel_test.rb +++ b/test/integration/actor_channel_test.rb @@ -3,7 +3,10 @@ require "database_test_helper" require "action_cable/test_helper" require "action_cable/channel/test_case" -require "solid_objects/actor_channel" +require "action_view/test_case" +require "action_view/testing/resolvers" +require "cgi/escape" +require_relative "../../app/helpers/solid_objects/actor_helper" ActionCable.server.config.cable = { "adapter" => "test" } @@ -38,9 +41,80 @@ def update_missing SolidObjects.configuration.stream_signing_secret = "test-stream-signing-secret" SolidObjects.configuration.authorize_message = ->(**) { true } SolidObjects.configuration.authorize_query = ->(**) { true } + SolidObjects.configuration.component_path_resolver = lambda do |view_context:| + "/solid_objects/components" + end ActionCable.server.config.logger = Logger.new(nil) end + test "loads the actor channel with the gem" do + assert_equal SolidObjects::ActorChannel, + "SolidObjects::ActorChannel".safe_constantize + end + + test "subscribes to scalar updates through rendered Turbo data" do + reference = ChannelActor.ref("actor-1") + SolidObjects.configuration.authorize_subscription = ->(**) { true } + parameters = rendered_subscription_parameters(reference) do |actor| + actor.missing + end + + assert_equal "SolidObjects::ActorChannel", parameters.fetch(:channel) + assert parameters.key?(:token) + refute parameters.key?(:components) + + subscribe(**parameters) + + assert subscription.confirmed? + assert_has_stream SolidObjects::StreamName.for(reference) + + reference.async(:update_missing) + worker = SolidObjects::Worker.new + worker.run_until_idle + broadcast = SolidObjects::Broadcast.find_by!(observable_name: "missing") + subscription.__send__( + :receive_broadcast, + SolidObjects::TurboStreamRenderer.observable(broadcast) + ) + + target = SolidObjects::DomIdentity.observable(reference, :missing) + updates = transmissions.select { |transmission| transmission.include?(target) } + assert_equal 2, updates.length + assert_includes updates.last, ">1" + ensure + worker&.stop + end + + test "subscribes to component updates through rendered Turbo data" do + reference = ChannelActor.ref("actor-1") + SolidObjects.configuration.authorize_subscription = ->(**) { true } + parameters = rendered_subscription_parameters(reference) do |actor| + actor.component(:summary, observes: :missing) + end + + assert_equal "SolidObjects::ActorChannel", parameters.fetch(:channel) + assert parameters.key?(:token) + assert parameters.key?(:components) + + subscribe(**parameters) + + assert subscription.confirmed? + assert_has_stream SolidObjects::StreamName.for(reference) + + reference.async(:update_missing) + worker = SolidObjects::Worker.new + worker.run_until_idle + broadcast = SolidObjects::Broadcast.find_by!(observable_name: "missing") + subscription.__send__( + :receive_broadcast, + SolidObjects::TurboStreamRenderer.observable(broadcast) + ) + + assert_equal 1, component_refreshes(reference, :summary).length + ensure + worker&.stop + end + test "streams only after token verification and host authorization" do reference = ChannelActor.ref("actor-1") SolidObjects.configuration.authorize_subscription = lambda do |actor_type:, actor_id:, authorization_context:| @@ -256,6 +330,36 @@ def update_missing private + def rendered_subscription_parameters(reference, &block) + html = actor_view.solid_object(reference, &block) + source = html.match(/[^>]*)>/) + attributes = source[:attributes] + .scan(/([a-z-]+)="([^"]*)"/) + .to_h + data = attributes + .select { |name, _value| name.start_with?("data-") } + .to_h do |name, value| + [ name.delete_prefix("data-").tr("-", "_").to_sym, CGI.unescapeHTML(value) ] + end + + { + channel: attributes.fetch("channel"), + **data + } + end + + def actor_view + resolver = ActionView::FixtureResolver.new( + "actors/actor_channel_test/channel_actor/_summary.html.erb" => + "

<%= actor.missing %>

" + ) + view = ActionView::Base + .with_empty_template_cache + .with_view_paths([ resolver ]) + view.extend(SolidObjects::ActorHelper) + view + end + def component_token(reference, component_name:, dependencies:, revision:) SolidObjects::ComponentToken.generate( reference:, diff --git a/test/integration/actor_helper_test.rb b/test/integration/actor_helper_test.rb index 6e6f284..3d26491 100644 --- a/test/integration/actor_helper_test.rb +++ b/test/integration/actor_helper_test.rb @@ -74,8 +74,10 @@ def close assert_includes html, %(channel="SolidObjects::ActorChannel") assert_includes html, %(id="#{SolidObjects::DomIdentity.scope(reference)}") refute_includes html, reference.actor_id + assert_includes html, "data-token=" + assert_nil html[/]*\stoken="/] - token = html[/token="([^"]+)"/, 1] + token = html[/data-token="([^"]+)"/, 1] identity = SolidObjects::StreamToken.verify(token) assert_equal [ "items_count" ], identity.fetch("observables") end @@ -110,8 +112,10 @@ def close assert_includes html, "
  • Second
  • " refute_includes html, JSON.generate(reference.snapshot.items) assert_includes html, "data-components=" + assert_includes html, "data-token=" + assert_nil html[/]*\stoken="/] - token = html[/token="([^"]+)"/, 1] + token = html[/data-token="([^"]+)"/, 1] identity = SolidObjects::StreamToken.verify(token) assert_empty identity.fetch("observables") end