-
Notifications
You must be signed in to change notification settings - Fork 485
Add support for Turbo-streaming ViewComponents. #2595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
joelhawksley
wants to merge
1
commit into
main
Choose a base branch
from
1106
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| 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
|
||
| - 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
|
||
| - 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
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
serializablewas producing an artifact for passing into ActiveJob, but thatrenderablewas describing something that could render HTML for the front end. Isn't this mixing unrelated things?There was a problem hiding this comment.
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.