diff --git a/docs/guide/turbo_streams.md b/docs/guide/turbo_streams.md
index b7293beaf..219ffb682 100644
--- a/docs/guide/turbo_streams.md
+++ b/docs/guide/turbo_streams.md
@@ -30,9 +30,7 @@ end
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:
+Include `ViewComponent::Serializable` in the component and use `.render_later` instead of `.new` when broadcasting:
```ruby
class MessageComponent < ViewComponent::Base
@@ -41,20 +39,8 @@ class MessageComponent < ViewComponent::Base
def initialize(message:)
@message = message
end
-
- erb_template <<~ERB
-
+
<%= @title %>
<%= @count %>
+ <%= header %>
+ <% items.each do |item| %>
+
<%= item %>
+ <% end %>
ERB
end
diff --git a/test/sandbox/test/serializable_test.rb b/test/sandbox/test/serializable_test.rb
index 35a6ef570..73d4bfe23 100644
--- a/test/sandbox/test/serializable_test.rb
+++ b/test/sandbox/test/serializable_test.rb
@@ -2,120 +2,195 @@
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
+class SerializableProxyTest < ViewComponent::TestCase
+ def test_render_later_returns_proxy
+ proxy = SerializableComponent.render_later("Hello", count: 5)
+ assert_kind_of ViewComponent::Serializable::Proxy, proxy
end
- def test_serializable_stores_kwargs
- component = SerializableComponent.serializable(title: "Hello", count: 5)
- assert_equal({title: "Hello", count: 5}, component.serializable_kwargs)
+ def test_proxy_stores_component_class
+ proxy = SerializableComponent.render_later("Hello")
+ assert_equal SerializableComponent, proxy.component_class
end
- def test_new_does_not_set_serializable_kwargs
- component = SerializableComponent.new(title: "Hello")
- assert_nil component.serializable_kwargs
+ def test_proxy_stores_initialize_args_positional_only
+ proxy = SerializableComponent.render_later("Hello")
+ assert_equal ["Hello"], proxy.initialize_args
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"
+ def test_proxy_stores_initialize_args_mixed
+ proxy = SerializableComponent.render_later("Hello", count: 5)
+ assert_equal ["Hello", {count: 5}], proxy.initialize_args
end
- def test_serializable_with_default_kwargs
- component = SerializableComponent.serializable(title: "Defaults")
- assert_equal({title: "Defaults"}, component.serializable_kwargs)
+ def test_proxy_renders
+ proxy = SerializableComponent.render_later("Rendered", count: 3)
+ result = render_inline(proxy)
+ assert_includes result.to_html, "Rendered"
+ assert_includes result.to_html, "3"
+ end
- result = render_inline(component)
- assert_includes result.to_html, "Defaults"
+ def test_proxy_with_default_kwargs
+ proxy = SerializableComponent.render_later("Defaults Only")
+ result = render_inline(proxy)
+ assert_includes result.to_html, "Defaults Only"
assert_includes result.to_html, "0"
end
- def test_serializable_not_available_without_concern
- assert_raises(NoMethodError) do
- MyComponent.serializable(message: "nope")
+ def test_proxy_with_positional_args_renders
+ proxy = SerializableComponent.render_later("Positional", count: 7)
+ result = render_inline(proxy)
+ assert_includes result.to_html, "Positional"
+ assert_includes result.to_html, "7"
+ end
+
+ def test_proxy_captures_slot_calls
+ proxy = SerializableComponent.render_later("Slots")
+ proxy.with_header(text: "My Header")
+ assert_equal 1, proxy.slot_calls.length
+ assert_equal :with_header, proxy.slot_calls.first[:method]
+ end
+
+ def test_proxy_slot_calls_are_replayed_at_render
+ proxy = SerializableComponent.render_later("Slotted")
+ proxy.with_header(text: "Header Content")
+ proxy.with_item("Item One")
+ proxy.with_item("Item Two")
+ result = render_inline(proxy)
+ assert_includes result.to_html, "Slotted"
+ assert_includes result.to_html, "Header Content"
+ assert_includes result.to_html, "Item One"
+ assert_includes result.to_html, "Item Two"
+ end
+
+ def test_proxy_slot_call_with_kwargs
+ proxy = SerializableComponent.render_later("Kwarg Slots")
+ proxy.with_item("Label", highlighted: true)
+ assert_equal ["Label", {highlighted: true}], proxy.slot_calls.first[:args]
+ end
+
+ def test_proxy_is_not_a_component_instance
+ proxy = SerializableComponent.render_later("Proxy")
+ refute_kind_of ViewComponent::Base, proxy
+ end
+
+ def test_proxy_only_captures_real_slot_methods
+ proxy = SerializableComponent.render_later("Real Slots")
+ refute_respond_to proxy, :with_indifferent_access
+ assert_raises(NoMethodError) { proxy.with_nonexistent_slot }
+ assert_empty proxy.slot_calls
+ end
+
+ def test_render_in_with_block_raises
+ proxy = SerializableComponent.render_later("Block Render")
+ assert_raises(ViewComponent::Serializable::UnserializableError) do
+ render_inline(proxy) { "content" }
+ end
+ end
+
+ def test_block_slot_calls_raise_immediately
+ proxy = SerializableComponent.render_later("Blocks")
+ error = assert_raises(ViewComponent::Serializable::UnserializableError) do
+ proxy.with_header { "Header" }
end
+ assert_includes error.message, "with_header"
+ assert_includes error.message, "block"
+ assert_empty proxy.slot_calls
+ end
+
+ def test_unserializable_error_is_argument_error
+ assert ViewComponent::Serializable::UnserializableError < ArgumentError
+ end
+
+ def test_render_later_requires_include
+ assert_respond_to SerializableComponent, :render_later
+ refute_respond_to MyComponent, :render_later
end
end
-class SerializableSerializerTest < ActiveSupport::TestCase
+class ActiveJobSerializerTest < ActiveSupport::TestCase
def setup
- @serializer = ViewComponent::SerializableSerializer.instance
+ @serializer = ViewComponent::ActiveJobSerializer.instance
end
- def test_serialize_predicate_true_for_serializable_instance
- component = SerializableComponent.serializable(title: "Hi", count: 1)
- assert @serializer.serialize?(component)
+ def test_serializes_proxy
+ proxy = SerializableComponent.render_later("Hi", count: 1)
+ assert @serializer.serialize?(proxy)
end
- def test_serialize_predicate_true_for_new_instance_with_concern
- component = SerializableComponent.new(title: "Hi")
- assert @serializer.serialize?(component)
+ def test_does_not_serialize_plain_objects
+ refute @serializer.serialize?("not a proxy")
+ refute @serializer.serialize?(SerializableComponent.new("direct"))
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_round_trip_positional_args
+ original = SerializableComponent.render_later("Round Trip", count: 42)
+ serialized = @serializer.serialize(original)
+ deserialized = @serializer.deserialize(serialized)
- def test_serialize_predicate_false_for_non_component
- refute @serializer.serialize?("not a component")
+ assert_kind_of ViewComponent::Serializable::Proxy, deserialized
+ assert_equal SerializableComponent, deserialized.component_class
+ assert_equal ["Round Trip", {count: 42}], deserialized.initialize_args
end
- def test_round_trip_serialization
- original = SerializableComponent.serializable(title: "Round Trip", count: 42)
+ def test_round_trip_with_slot_kwargs
+ original = SerializableComponent.render_later("With Slots")
+ original.with_item("Label", highlighted: true)
+
serialized = @serializer.serialize(original)
deserialized = @serializer.deserialize(serialized)
- assert_kind_of SerializableComponent, deserialized
- assert_equal({title: "Round Trip", count: 42}, deserialized.serializable_kwargs)
+ assert_equal 1, deserialized.slot_calls.length
+ assert_equal :with_item, deserialized.slot_calls.first[:method]
+ assert_equal ["Label", {highlighted: true}], deserialized.slot_calls.first[:args]
end
- def test_round_trip_with_default_kwargs
- original = SerializableComponent.serializable(title: "Defaults Only")
+ def test_round_trip_with_slot_positional_args
+ original = SerializableComponent.render_later("Positional")
+ original.with_item("some string")
+
serialized = @serializer.serialize(original)
deserialized = @serializer.deserialize(serialized)
- assert_equal({title: "Defaults Only"}, deserialized.serializable_kwargs)
+ assert_equal 1, deserialized.slot_calls.length
+ assert_equal ["some string"], deserialized.slot_calls.first[:args]
end
- def test_serialized_format
- component = SerializableComponent.serializable(title: "Format", count: 9)
- serialized = @serializer.serialize(component)
+ def test_serialized_format_keys
+ proxy = SerializableComponent.render_later("Format", count: 9)
+ serialized = @serializer.serialize(proxy)
- assert_equal "SerializableComponent", serialized["component"]
- assert serialized.key?("kwargs")
+ assert_equal "SerializableComponent", serialized["component_class"]
+ assert serialized.key?("initialize_args")
+ assert serialized.key?("slot_calls")
+ assert_equal [], serialized["slot_calls"]
end
def test_deserialize_unknown_component_raises
assert_raises(ArgumentError) do
- @serializer.deserialize({"component" => "NonExistentComponent", "kwargs" => []})
+ @serializer.deserialize({"component_class" => "NonExistentComponent", "initialize_args" => [], "slot_calls" => []})
end
end
end
-class SerializableTurboStreamTest < ActiveJob::TestCase
+class RenderLaterTurboStreamTest < ActiveJob::TestCase
include Turbo::Broadcastable::TestHelper
- def test_broadcast_action_later_with_serializable_component
- component = SerializableComponent.serializable(title: "Broadcast Test", count: 7)
+ def test_broadcast_action_later_with_render_later_proxy
+ proxy = SerializableComponent.render_later("Broadcast Test", count: 7)
- assert_turbo_stream_broadcasts("serializable_test_stream") do
+ assert_turbo_stream_broadcasts("render_later_test_stream") do
Turbo::StreamsChannel.broadcast_action_later_to(
- "serializable_test_stream",
+ "render_later_test_stream",
action: :replace,
target: "my-target",
- renderable: component,
+ renderable: proxy,
layout: false
)
perform_enqueued_jobs
end
- broadcasts = capture_turbo_stream_broadcasts("serializable_test_stream")
+ broadcasts = capture_turbo_stream_broadcasts("render_later_test_stream")
assert_equal "replace", broadcasts.first["action"]
assert_equal "my-target", broadcasts.first["target"]
assert_includes broadcasts.first.to_html, "Broadcast Test"