Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
12 changes: 6 additions & 6 deletions app/helpers/solid_objects/actor_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lib/solid_objects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion lib/solid_objects/actor_channel.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# rbs_inline: enabled

require "action_cable/channel/base"
require "action_cable"

module SolidObjects
class ActorChannel < ActionCable::Channel::Base
Expand Down
1 change: 1 addition & 0 deletions test/dummy/boot_check.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
106 changes: 105 additions & 1 deletion test/integration/actor_channel_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand Down Expand Up @@ -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</span>"
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:|
Expand Down Expand Up @@ -256,6 +330,36 @@ def update_missing

private

def rendered_subscription_parameters(reference, &block)
html = actor_view.solid_object(reference, &block)
source = html.match(/<turbo-cable-stream-source (?<attributes>[^>]*)>/)
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" =>
"<p><%= actor.missing %></p>"
)
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:,
Expand Down
8 changes: 6 additions & 2 deletions test/integration/actor_helper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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[/<turbo-cable-stream-source[^>]*\stoken="/]

token = html[/token="([^"]+)"/, 1]
token = html[/data-token="([^"]+)"/, 1]
identity = SolidObjects::StreamToken.verify(token)
assert_equal [ "items_count" ], identity.fetch("observables")
end
Expand Down Expand Up @@ -110,8 +112,10 @@ def close
assert_includes html, "<li>Second</li>"
refute_includes html, JSON.generate(reference.snapshot.items)
assert_includes html, "data-components="
assert_includes html, "data-token="
assert_nil html[/<turbo-cable-stream-source[^>]*\stoken="/]

token = html[/token="([^"]+)"/, 1]
token = html[/data-token="([^"]+)"/, 1]
identity = SolidObjects::StreamToken.verify(token)
assert_empty identity.fetch("observables")
end
Expand Down