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