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
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
87 changes: 87 additions & 0 deletions docs/guide/turbo_streams.md
Original file line number Diff line number Diff line change
@@ -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
<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

private

def broadcast_append
Turbo::StreamsChannel.broadcast_action_later_to(
"messages",
action: :append,
target: "messages",
renderable: MessageComponent.serializable(message: self),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is surprising. I thought serializable was producing an artifact for passing into ActiveJob, but that renderable was describing something that could render HTML for the front end. Isn't this mixing unrelated things?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conceptually, it's that we're making a renderable that can pass through the Active Job boundary by being serializable.

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.

Check failure on line 85 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": 85, "column": 132}}}, "severity": "ERROR"}
- Components must be instantiated with `.serializable` instead of `.new` for serialization to work. Instances created with `.new` are not serializable.

Check failure on line 86 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": 86, "column": 131}}}, "severity": "ERROR"}
- The component class must be `safe_constantize`-able at deserialization time (i.e., it must be autoloadable).

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

View workflow job for this annotation

GitHub Actions / prose

[vale] reported by reviewdog 🐶 [Microsoft.Foreign] Use 'that is' instead of 'i.e.,'. Raw Output: {"message": "[Microsoft.Foreign] Use 'that is' instead of 'i.e.,'.", "location": {"path": "docs/guide/turbo_streams.md", "range": {"start": {"line": 87, "column": 80}}}, "severity": "ERROR"}
1 change: 1 addition & 0 deletions lib/view_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module ViewComponent
autoload :InlineTemplate
autoload :Instrumentation
autoload :Preview
autoload :Serializable
autoload :Translatable

if defined?(Rails.env) && Rails.env.test?
Expand Down
7 changes: 7 additions & 0 deletions lib/view_component/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions lib/view_component/serializable.rb
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions lib/view_component/serializable_serializer.rb
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions test/sandbox/app/components/serializable_component.rb
Original file line number Diff line number Diff line change
@@ -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
<div class="serializable">
<h1><%= @title %></h1>
<span><%= @count %></span>
</div>
ERB
end
124 changes: 124 additions & 0 deletions test/sandbox/test/serializable_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading