From 95918da65a756c89fb08f58c49c81ce103adeef6 Mon Sep 17 00:00:00 2001 From: "Ben Sheldon [he/him]" Date: Sun, 31 May 2026 21:20:05 -0700 Subject: [PATCH] Replace `serializable` interceptor with Proxy object --- docs/guide/turbo_streams.md | 51 +++-- lib/view_component/active_job_serializer.rb | 19 ++ lib/view_component/engine.rb | 4 +- lib/view_component/serializable.rb | 18 +- lib/view_component/serializable/proxy.rb | 93 +++++++++ lib/view_component/serializable_serializer.rb | 38 ---- .../app/components/serializable_component.rb | 28 ++- test/sandbox/test/serializable_test.rb | 191 ++++++++++++------ 8 files changed, 314 insertions(+), 128 deletions(-) create mode 100644 lib/view_component/active_job_serializer.rb create mode 100644 lib/view_component/serializable/proxy.rb delete mode 100644 lib/view_component/serializable_serializer.rb 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 -
- <%= @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 @@ -65,7 +51,7 @@ class Message < ApplicationRecord "messages", action: :append, target: "messages", - renderable: MessageComponent.serializable(message: self), + renderable: MessageComponent.render_later(message: self), layout: false ) end @@ -74,14 +60,37 @@ 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. +### Slots + +Slot calls made on the proxy are captured and replayed at render time. Slots must use a component class — passthrough slots that accept blocks are not supported because blocks cannot be serialized. + +```ruby +class CardComponent < ViewComponent::Base + class BodyComponent < ViewComponent::Base + def initialize(text:) + @text = text + end + end + + renders_many :bodies, BodyComponent +end + +proxy = CardComponent.render_later(title: "Updates") +proxy.with_body(text: "First item") +proxy.with_body(text: "Second item") + +Turbo::StreamsChannel.broadcast_action_later_to("cards", renderable: proxy, ...) +``` + +Passing a block to a slot call raises `ViewComponent::Serializable::UnserializableError` immediately. + ### 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. +- `.render_later(*args, **kwargs)` returns a `ViewComponent::Serializable::Proxy` that stores the component class and initialization arguments without instantiating the component. +- `ViewComponent::ActiveJobSerializer` handles converting the proxy to and from a JSON-safe format. It is automatically registered when ActiveJob is loaded. +- ActiveRecord objects passed as 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. +- Blocks passed to `render_in` or to slot calls raise `ViewComponent::Serializable::UnserializableError`. Use component-based slots instead. - The component class must be `safe_constantize`-able at deserialization time (i.e., it must be autoloadable). diff --git a/lib/view_component/active_job_serializer.rb b/lib/view_component/active_job_serializer.rb new file mode 100644 index 000000000..71d07019e --- /dev/null +++ b/lib/view_component/active_job_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "active_job/serializers" + +module ViewComponent + class ActiveJobSerializer < ActiveJob::Serializers::ObjectSerializer + def serialize?(argument) + argument.is_a?(ViewComponent::Serializable::Proxy) + end + + def serialize(proxy) + super(proxy.serialize) + end + + def deserialize(hash) + ViewComponent::Serializable::Proxy.deserialize(hash) + end + end +end diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index e025d2040..638362911 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -79,8 +79,8 @@ class Engine < Rails::Engine # :nodoc: initializer "view_component.serializable" do ActiveSupport.on_load(:active_job) do - require "view_component/serializable_serializer" - ActiveJob::Serializers.add_serializers(ViewComponent::SerializableSerializer) + require "view_component/active_job_serializer" + ActiveJob::Serializers.add_serializers(ViewComponent::ActiveJobSerializer) end end diff --git a/lib/view_component/serializable.rb b/lib/view_component/serializable.rb index dcf81b1ba..a63186f6f 100644 --- a/lib/view_component/serializable.rb +++ b/lib/view_component/serializable.rb @@ -1,21 +1,25 @@ # frozen_string_literal: true require "active_support/concern" +require "view_component/serializable/proxy" module ViewComponent module Serializable extend ActiveSupport::Concern - included do - attr_reader :serializable_kwargs - end + class UnserializableError < ArgumentError; end class_methods do - def serializable(**kwargs) - new(**kwargs).tap do |instance| - instance.instance_variable_set(:@serializable_kwargs, kwargs) - end + # Returns a Proxy that captures this class and its initialization arguments, + # deferring instantiation until render time. The proxy is renderable and + # serializable for ActiveJob (e.g. Turbo Streams). Slot calls made on the + # proxy are captured and replayed at render time. Blocks are not supported. + # + # MyComponent.render_later("title", size: :large).with_item(label: "One") + def render_later(*args) + ViewComponent::Serializable::Proxy.new(self, *args) end + ruby2_keywords :render_later end end end diff --git a/lib/view_component/serializable/proxy.rb b/lib/view_component/serializable/proxy.rb new file mode 100644 index 000000000..b7315c783 --- /dev/null +++ b/lib/view_component/serializable/proxy.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module ViewComponent + module Serializable + # A proxy that wraps a component class and its initialization arguments, deferring + # component instantiation until render time. This allows slot calls and other + # post-initialize configuration to be captured and replayed, and enables the + # proxy itself (rather than a live component instance) to be serialized for + # background jobs (e.g. ActiveJob / Turbo Streams). + class Proxy + # Rebuilds a Proxy from a serialized hash produced by +serialize+ + def self.deserialize(hash) + klass = hash["component_class"].safe_constantize + raise ArgumentError, "Cannot deserialize unknown component: #{hash["component_class"]}" unless klass + + args = ActiveJob::Arguments.deserialize(hash["initialize_args"] || []) + proxy = new(klass, *args) + + Array(hash["slot_calls"]).each do |call| + method_name = call["method"].to_sym + slot_args = ActiveJob::Arguments.deserialize(call["args"]) + proxy.public_send(method_name, *slot_args) + end + + proxy + end + + attr_reader :component_class, :initialize_args, :slot_calls + + def initialize(component_class, *args) + @component_class = component_class + @initialize_args = args + @slot_calls = [] + end + ruby2_keywords :initialize + + # Implements the Rails renderable interface + def render_in(view_context, &block) + if block + raise UnserializableError, "Cannot serialize render_in with a block" + end + build_component.render_in(view_context) + end + + def method_missing(method_name, *args, &block) + if slot_method?(method_name) + if block + raise UnserializableError, "Cannot serialize slot call '#{method_name}' with a block" + end + @slot_calls << {method: method_name, args: args} + self + else + super + end + end + ruby2_keywords :method_missing + + def respond_to_missing?(method_name, include_private = false) + slot_method?(method_name) || super + end + + # Returns a hash that can be rebuilt into a Proxy instance using +deserialize+ + def serialize + serialized_slot_calls = @slot_calls.map do |call| + { + "method" => call[:method].to_s, + "args" => ActiveJob::Arguments.serialize(call[:args]) + } + end + + { + "component_class" => @component_class.name, + "initialize_args" => ActiveJob::Arguments.serialize(@initialize_args), + "slot_calls" => serialized_slot_calls + } + end + + private + + def slot_method?(method_name) + method_name.to_s.start_with?("with_") && @component_class.method_defined?(method_name) + end + + def build_component + @component_class.new(*@initialize_args).tap do |instance| + @slot_calls.each do |call| + instance.public_send(call[:method], *call[:args]) + end + end + end + end + end +end diff --git a/lib/view_component/serializable_serializer.rb b/lib/view_component/serializable_serializer.rb deleted file mode 100644 index 8b0658a24..000000000 --- a/lib/view_component/serializable_serializer.rb +++ /dev/null @@ -1,38 +0,0 @@ -# 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 index 51794aa38..403a70187 100644 --- a/test/sandbox/app/components/serializable_component.rb +++ b/test/sandbox/app/components/serializable_component.rb @@ -3,15 +3,39 @@ class SerializableComponent < ViewComponent::Base include ViewComponent::Serializable - def initialize(title:, count: 0) + class HeaderComponent < ViewComponent::Base + def initialize(text:) + @text = text + end + + erb_template "<%= @text %>" + end + + class ItemComponent < ViewComponent::Base + def initialize(label, highlighted: false) + @label = label + @highlighted = highlighted + end + + erb_template "\"><%= @label %>" + end + + renders_one :header, HeaderComponent + renders_many :items, ItemComponent + + def initialize(title, count: 0) @title = title @count = count 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"