diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index c917653d3..8cb4c4fd1 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -10,6 +10,10 @@ nav_order: 6
## main
+* Add support for Turbo-streaming ViewComponents.
+
+ *Ben Sheldon*, *Joel Hawksley*, *GitHub Copilot*
+
* Fix bug where inheritance of components with formatless templates improperly raised a NoMethodError.
*GitHub Copilot*, *Joel Hawksley*, *Cameron Dutro*
diff --git a/docs/guide/turbo_streams.md b/docs/guide/turbo_streams.md
new file mode 100644
index 000000000..b7293beaf
--- /dev/null
+++ b/docs/guide/turbo_streams.md
@@ -0,0 +1,87 @@
+---
+layout: default
+title: Turbo Streams
+parent: How-to guide
+---
+
+# Turbo Streams
+
+ViewComponents can be used with [Turbo Streams](https://turbo.hotwired.dev/handbook/streams) to broadcast updates over WebSockets.
+
+## Rendering in controllers
+
+In a controller action, render a component inside a Turbo Stream response using `render_to_string` or `view_context.render`:
+
+```ruby
+class MessagesController < ApplicationController
+ def create
+ @message = Message.create!(message_params)
+
+ Turbo::StreamsChannel.broadcast_append_to(
+ "messages",
+ target: "messages",
+ html: render_to_string(MessageComponent.new(message: @message))
+ )
+ end
+end
+```
+
+## Broadcasting later with ActiveJob
+
+To broadcast asynchronously via ActiveJob (using `broadcast_action_later_to`), components must be serializable so they can be passed to the background job.
+
+### Setup
+
+Include `ViewComponent::Serializable` in the component and use `.serializable` instead of `.new` when broadcasting:
+
+```ruby
+class MessageComponent < ViewComponent::Base
+ include ViewComponent::Serializable
+
+ def initialize(message:)
+ @message = message
+ end
+
+ erb_template <<~ERB
+
+ <%= @message.body %>
+
+ ERB
+end
+```
+
+### Broadcasting
+
+Use `.serializable` to create a component instance that can be serialized by ActiveJob:
+
+```ruby
+class Message < ApplicationRecord
+ after_create_commit :broadcast_append
+
+ private
+
+ def broadcast_append
+ Turbo::StreamsChannel.broadcast_action_later_to(
+ "messages",
+ action: :append,
+ target: "messages",
+ renderable: MessageComponent.serializable(message: self),
+ layout: false
+ )
+ end
+end
+```
+
+The component is serialized when the job is enqueued and deserialized when the job runs. The job renders the component via `ApplicationController.render` and broadcasts the resulting HTML over ActionCable.
+
+### How it works
+
+- `.serializable(**kwargs)` creates a normal component instance and stores the keyword arguments for later serialization.
+- `ViewComponent::SerializableSerializer` (an ActiveJob serializer) handles converting the component to and from a JSON-safe format. It's automatically registered when ActiveJob is loaded.
+- ActiveRecord objects passed as keyword arguments are serialized via GlobalID, just like any other ActiveJob argument.
+
+### Limitations
+
+- Only keyword arguments passed to `.serializable` are serialized. Slots, `with_content`, and other state set after initialization are not included.
+- Components must be instantiated with `.serializable` instead of `.new` for serialization to work. Instances created with `.new` are not serializable.
+- The component class must be `safe_constantize`-able at deserialization time (i.e., it must be autoloadable).
diff --git a/lib/view_component.rb b/lib/view_component.rb
index ac1102ed9..b47469426 100644
--- a/lib/view_component.rb
+++ b/lib/view_component.rb
@@ -15,6 +15,7 @@ module ViewComponent
autoload :InlineTemplate
autoload :Instrumentation
autoload :Preview
+ autoload :Serializable
autoload :Translatable
if defined?(Rails.env) && Rails.env.test?
diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb
index ed70c912d..e025d2040 100644
--- a/lib/view_component/engine.rb
+++ b/lib/view_component/engine.rb
@@ -77,6 +77,13 @@ class Engine < Rails::Engine # :nodoc:
end
end
+ initializer "view_component.serializable" do
+ ActiveSupport.on_load(:active_job) do
+ require "view_component/serializable_serializer"
+ ActiveJob::Serializers.add_serializers(ViewComponent::SerializableSerializer)
+ end
+ end
+
initializer "view_component.eager_load_actions" do
ActiveSupport.on_load(:after_initialize) do
ViewComponent::Base.descendants.each(&:__vc_compile) if Rails.application.config.eager_load
diff --git a/lib/view_component/serializable.rb b/lib/view_component/serializable.rb
new file mode 100644
index 000000000..dcf81b1ba
--- /dev/null
+++ b/lib/view_component/serializable.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require "active_support/concern"
+
+module ViewComponent
+ module Serializable
+ extend ActiveSupport::Concern
+
+ included do
+ attr_reader :serializable_kwargs
+ end
+
+ class_methods do
+ def serializable(**kwargs)
+ new(**kwargs).tap do |instance|
+ instance.instance_variable_set(:@serializable_kwargs, kwargs)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/view_component/serializable_serializer.rb b/lib/view_component/serializable_serializer.rb
new file mode 100644
index 000000000..8b0658a24
--- /dev/null
+++ b/lib/view_component/serializable_serializer.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require "active_job"
+require "view_component/serializable"
+
+module ViewComponent
+ class SerializableSerializer < ActiveJob::Serializers::ObjectSerializer
+ def klass
+ ViewComponent::Base
+ end
+
+ def serialize?(argument)
+ argument.is_a?(ViewComponent::Base) &&
+ argument.respond_to?(:serializable_kwargs)
+ end
+
+ def serialize(component)
+ unless component.serializable_kwargs
+ raise ArgumentError,
+ "#{component.class.name} was instantiated with .new instead of .serializable. " \
+ "Use #{component.class.name}.serializable(...) to create a serializable instance."
+ end
+
+ super(
+ "component" => component.class.name,
+ "kwargs" => ActiveJob::Arguments.serialize([component.serializable_kwargs])
+ )
+ end
+
+ def deserialize(hash)
+ klass = hash["component"].safe_constantize
+ raise ArgumentError, "Cannot deserialize unknown component: #{hash["component"]}" unless klass
+
+ kwargs = ActiveJob::Arguments.deserialize(hash["kwargs"]).first
+ klass.serializable(**kwargs.symbolize_keys)
+ end
+ end
+end
diff --git a/test/sandbox/app/components/serializable_component.rb b/test/sandbox/app/components/serializable_component.rb
new file mode 100644
index 000000000..51794aa38
--- /dev/null
+++ b/test/sandbox/app/components/serializable_component.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class SerializableComponent < ViewComponent::Base
+ include ViewComponent::Serializable
+
+ def initialize(title:, count: 0)
+ @title = title
+ @count = count
+ end
+
+ erb_template <<~ERB
+
+
<%= @title %>
+ <%= @count %>
+
+ ERB
+end
diff --git a/test/sandbox/test/serializable_test.rb b/test/sandbox/test/serializable_test.rb
new file mode 100644
index 000000000..35a6ef570
--- /dev/null
+++ b/test/sandbox/test/serializable_test.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class SerializableTest < ViewComponent::TestCase
+ def test_serializable_returns_component_instance
+ component = SerializableComponent.serializable(title: "Hello", count: 5)
+ assert_kind_of SerializableComponent, component
+ end
+
+ def test_serializable_stores_kwargs
+ component = SerializableComponent.serializable(title: "Hello", count: 5)
+ assert_equal({title: "Hello", count: 5}, component.serializable_kwargs)
+ end
+
+ def test_new_does_not_set_serializable_kwargs
+ component = SerializableComponent.new(title: "Hello")
+ assert_nil component.serializable_kwargs
+ end
+
+ def test_serializable_component_renders
+ result = render_inline(SerializableComponent.serializable(title: "Test", count: 3))
+ assert_includes result.to_html, "Test"
+ assert_includes result.to_html, "3"
+ end
+
+ def test_serializable_with_default_kwargs
+ component = SerializableComponent.serializable(title: "Defaults")
+ assert_equal({title: "Defaults"}, component.serializable_kwargs)
+
+ result = render_inline(component)
+ assert_includes result.to_html, "Defaults"
+ assert_includes result.to_html, "0"
+ end
+
+ def test_serializable_not_available_without_concern
+ assert_raises(NoMethodError) do
+ MyComponent.serializable(message: "nope")
+ end
+ end
+end
+
+class SerializableSerializerTest < ActiveSupport::TestCase
+ def setup
+ @serializer = ViewComponent::SerializableSerializer.instance
+ end
+
+ def test_serialize_predicate_true_for_serializable_instance
+ component = SerializableComponent.serializable(title: "Hi", count: 1)
+ assert @serializer.serialize?(component)
+ end
+
+ def test_serialize_predicate_true_for_new_instance_with_concern
+ component = SerializableComponent.new(title: "Hi")
+ assert @serializer.serialize?(component)
+ end
+
+ def test_serialize_raises_for_new_instance
+ component = SerializableComponent.new(title: "Hi")
+ error = assert_raises(ArgumentError) { @serializer.serialize(component) }
+ assert_includes error.message, ".serializable"
+ assert_includes error.message, "SerializableComponent"
+ end
+
+ def test_serialize_predicate_false_for_non_component
+ refute @serializer.serialize?("not a component")
+ end
+
+ def test_round_trip_serialization
+ original = SerializableComponent.serializable(title: "Round Trip", count: 42)
+ serialized = @serializer.serialize(original)
+ deserialized = @serializer.deserialize(serialized)
+
+ assert_kind_of SerializableComponent, deserialized
+ assert_equal({title: "Round Trip", count: 42}, deserialized.serializable_kwargs)
+ end
+
+ def test_round_trip_with_default_kwargs
+ original = SerializableComponent.serializable(title: "Defaults Only")
+ serialized = @serializer.serialize(original)
+ deserialized = @serializer.deserialize(serialized)
+
+ assert_equal({title: "Defaults Only"}, deserialized.serializable_kwargs)
+ end
+
+ def test_serialized_format
+ component = SerializableComponent.serializable(title: "Format", count: 9)
+ serialized = @serializer.serialize(component)
+
+ assert_equal "SerializableComponent", serialized["component"]
+ assert serialized.key?("kwargs")
+ end
+
+ def test_deserialize_unknown_component_raises
+ assert_raises(ArgumentError) do
+ @serializer.deserialize({"component" => "NonExistentComponent", "kwargs" => []})
+ end
+ end
+end
+
+class SerializableTurboStreamTest < ActiveJob::TestCase
+ include Turbo::Broadcastable::TestHelper
+
+ def test_broadcast_action_later_with_serializable_component
+ component = SerializableComponent.serializable(title: "Broadcast Test", count: 7)
+
+ assert_turbo_stream_broadcasts("serializable_test_stream") do
+ Turbo::StreamsChannel.broadcast_action_later_to(
+ "serializable_test_stream",
+ action: :replace,
+ target: "my-target",
+ renderable: component,
+ layout: false
+ )
+ perform_enqueued_jobs
+ end
+
+ broadcasts = capture_turbo_stream_broadcasts("serializable_test_stream")
+ assert_equal "replace", broadcasts.first["action"]
+ assert_equal "my-target", broadcasts.first["target"]
+ assert_includes broadcasts.first.to_html, "Broadcast Test"
+ assert_includes broadcasts.first.to_html, "7"
+ end
+end