Skip to content
Open
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
51 changes: 30 additions & 21 deletions docs/guide/turbo_streams.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@

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
Expand All @@ -41,20 +39,8 @@
def initialize(message:)
@message = message
end

erb_template <<~ERB
<div id="<%= dom_id(@message) %>">
<%= @message.body %>
</div>
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

Expand All @@ -65,7 +51,7 @@
"messages",
action: :append,
target: "messages",
renderable: MessageComponent.serializable(message: self),
renderable: MessageComponent.render_later(message: self),
layout: false
)
end
Expand All @@ -74,14 +60,37 @@

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.

Check failure on line 65 in docs/guide/turbo_streams.md

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Contractions] Use 'can't' instead of 'cannot'. Raw Output: {"message": "[Microsoft.Contractions] Use 'can't' instead of 'cannot'.", "location": {"path": "docs/guide/turbo_streams.md", "range": {"start": {"line": 65, "column": 177}}}, "severity": "ERROR"}

Check failure on line 65 in docs/guide/turbo_streams.md

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Contractions] Use 'aren't' instead of 'are not'. Raw Output: {"message": "[Microsoft.Contractions] Use 'aren't' instead of 'are not'.", "location": {"path": "docs/guide/turbo_streams.md", "range": {"start": {"line": 65, "column": 144}}}, "severity": "ERROR"}

Check failure on line 65 in docs/guide/turbo_streams.md

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Dashes] Remove the spaces around ' —'. Raw Output: {"message": "[Microsoft.Dashes] Remove the spaces around ' —'.", "location": {"path": "docs/guide/turbo_streams.md", "range": {"start": {"line": 65, "column": 104}}}, "severity": "ERROR"}

```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.

Check failure on line 90 in docs/guide/turbo_streams.md

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Contractions] Use 'it's' instead of 'It is'. Raw Output: {"message": "[Microsoft.Contractions] Use 'it's' instead of 'It is'.", "location": {"path": "docs/guide/turbo_streams.md", "range": {"start": {"line": 90, "column": 101}}}, "severity": "ERROR"}
- 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).
19 changes: 19 additions & 0 deletions lib/view_component/active_job_serializer.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions lib/view_component/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 11 additions & 7 deletions lib/view_component/serializable.rb
Original file line number Diff line number Diff line change
@@ -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
93 changes: 93 additions & 0 deletions lib/view_component/serializable/proxy.rb
Original file line number Diff line number Diff line change
@@ -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
38 changes: 0 additions & 38 deletions lib/view_component/serializable_serializer.rb

This file was deleted.

28 changes: 26 additions & 2 deletions test/sandbox/app/components/serializable_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<span class=\"header\"><%= @text %></span>"
end

class ItemComponent < ViewComponent::Base
def initialize(label, highlighted: false)
@label = label
@highlighted = highlighted
end

erb_template "<span class=\"item<%= ' highlighted' if @highlighted %>\"><%= @label %></span>"
end

renders_one :header, HeaderComponent
renders_many :items, ItemComponent

def initialize(title, count: 0)
@title = title
@count = count
end

erb_template <<~ERB
<div class="serializable">
<div class="serializable-component">
<h1><%= @title %></h1>
<span><%= @count %></span>
<%= header %>
<% items.each do |item| %>
<div class="item"><%= item %></div>
<% end %>
</div>
ERB
end
Loading
Loading