Skip to content
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

Support for page refreshes and broadcasting #499

Merged
merged 14 commits into from
Nov 23, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2,213 changes: 1,551 additions & 662 deletions app/assets/javascripts/turbo.js

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions app/assets/javascripts/turbo.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/javascripts/turbo.min.js.map

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions app/channels/turbo/streams/broadcasts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def broadcast_prepend_to(*streamables, **opts)
broadcast_action_to(*streamables, action: :prepend, **opts)
end

def broadcast_refresh_to(*streamables, **opts)
broadcast_stream_to(*streamables, content: turbo_stream_refresh_tag)
end

def broadcast_action_to(*streamables, action:, target: nil, targets: nil, attributes: {}, **rendering)
broadcast_stream_to(*streamables, content: turbo_stream_action_tag(action, target: target, targets: targets, template:
rendering.delete(:content) || rendering.delete(:html) || (rendering[:render] != false && rendering.any? ? render_format(:html, **rendering) : nil),
Expand Down Expand Up @@ -64,6 +68,10 @@ def broadcast_prepend_later_to(*streamables, **opts)
broadcast_action_later_to(*streamables, action: :prepend, **opts)
end

def broadcast_refresh_later_to(*streamables, **opts)
Turbo::Streams::BroadcastStreamJob.perform_later stream_name_from(streamables), content: turbo_stream_refresh_tag(**opts)
end

def broadcast_action_later_to(*streamables, action:, target: nil, targets: nil, attributes: {}, **rendering)
Turbo::Streams::ActionBroadcastJob.perform_later \
stream_name_from(streamables), action: action, target: target, targets: targets, attributes: attributes, **rendering
Expand Down
12 changes: 12 additions & 0 deletions app/controllers/concerns/turbo/request_id_tracking.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module Turbo::RequestIdTracking
extend ActiveSupport::Concern

included do
around_action :turbo_tracking_request_id
end

private
def turbo_tracking_request_id(&block)
Turbo.with_request_id(request.headers["X-Turbo-Request-Id"], &block)
end
end
8 changes: 8 additions & 0 deletions app/helpers/turbo/drive_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,12 @@ def turbo_exempts_page_from_preview
def turbo_page_requires_reload
provide :head, tag.meta(name: "turbo-visit-control", content: "reload")
end

def turbo_refreshes_with(method: :replace, scroll: :reset)
raise ArgumentError, "Invalid refresh option '#{method}'" unless method.in?(%i[ replace morph ])
raise ArgumentError, "Invalid scroll option '#{scroll}'" unless scroll.in?(%i[ reset preserve ])

provide :head, tag.meta(name: "turbo-refresh-method", content: method)
provide :head, tag.meta(name: "turbo-refresh-scroll", content: scroll)
end
end
6 changes: 5 additions & 1 deletion app/helpers/turbo/streams/action_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ module Turbo::Streams::ActionHelper
# # => <turbo-stream action="remove" target="special_message_1"></turbo-stream>
#
def turbo_stream_action_tag(action, target: nil, targets: nil, template: nil, **attributes)
template = action.to_sym == :remove ? "" : tag.template(template.to_s.html_safe)
template = action.to_sym.in?(%i[ remove refresh ]) ? "" : tag.template(template.to_s.html_safe)

if target = convert_to_turbo_stream_dom_id(target)
tag.turbo_stream(template, **attributes, action: action, target: target)
Expand All @@ -35,6 +35,10 @@ def turbo_stream_action_tag(action, target: nil, targets: nil, template: nil, **
end
end

def turbo_stream_refresh_tag(**attributes)
turbo_stream_action_tag(:refresh, **{ "request-id": Turbo.current_request_id }.compact, **attributes)
end

private
def convert_to_turbo_stream_dom_id(target, include_selector: false)
if Array(target).any? { |value| value.respond_to?(:to_key) }
Expand Down
7 changes: 7 additions & 0 deletions app/jobs/turbo/streams/broadcast_stream_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class Turbo::Streams::BroadcastStreamJob < ActiveJob::Base
discard_on ActiveJob::DeserializationError

def perform(stream, content:)
Turbo::StreamsChannel.broadcast_stream_to(stream, content: content)
end
end
91 changes: 77 additions & 14 deletions app/models/concerns/turbo/broadcastable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,32 @@
# In addition to the four basic actions, you can also use <tt>broadcast_render</tt>,
# <tt>broadcast_render_to</tt> <tt>broadcast_render_later</tt>, and <tt>broadcast_render_later_to</tt>
# to render a turbo stream template with multiple actions.
#
# == Suppressing broadcasts
#
# Sometimes, you need to disable broadcasts in certain scenarios. You can use <tt>.suppressing_turbo_broadcasts</tt> to create
# execution contexts where broadcasts are disabled:
#
# class Message < ApplicationRecord
# after_create_commit :update_message
#
# private
# def update_message
# broadcast_replace_to(user, :message, target: "message", renderable: MessageComponent.new)
# end
# end
#
# Message.suppressing_turbo_broadcasts do
# Message.create!(board: board) # This won't broadcast the replace action
# end
module Turbo::Broadcastable
extend ActiveSupport::Concern

included do
thread_mattr_accessor :suppressed_turbo_broadcasts, instance_accessor: false
delegate :suppressed_turbo_broadcasts?, to: "self.class"
end

module ClassMethods
# Configures the model to broadcast creates, updates, and destroys to a stream name derived at runtime by the
# <tt>stream</tt> symbol invocation. By default, the creates are appended to a dom id target name derived from
Expand Down Expand Up @@ -112,10 +135,34 @@ def broadcasts(stream = model_name.plural, inserts_by: :append, target: broadcas
after_destroy_commit -> { broadcast_remove }
end

# Configures the model to broadcast a "page refresh" on creates, updates, and destroys to a stream
# name derived at runtime by the <tt>stream</tt> symbol invocation.
def broadcasts_refreshes_to(stream)
after_commit -> { broadcast_refresh_later_to(stream.try(:call, self) || send(stream)) }
end

# Same as <tt>#broadcasts_refreshes_to</tt>, but the designated stream for page refreshes is automatically set to
# the current model.
def broadcasts_refreshes
after_commit -> { broadcast_refresh_later }
Comment on lines +146 to +147
Copy link
Contributor

@tonysm tonysm Nov 6, 2023

Choose a reason for hiding this comment

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

So, this will only send broadcast refreshes to the current model's channel, right? Shouldn't it be closer to the broadcasts method and accept the stream argument or default to the model's plural name when creating the model (see #295)? Otherwise, it will send a broadcast to a channel that no one will ever be listening on (because the model is being created), right?

Suggested change
def broadcasts_refreshes
after_commit -> { broadcast_refresh_later }
def broadcasts_refreshes(stream = model_name.plural)
after_create_commit -> { broadcast_refresh_later_to(stream) }
after_update_commit -> { broadcast_refresh_later }
after_destroy_commit -> { broadcast_refresh }

Copy link
Contributor

Choose a reason for hiding this comment

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

Sent a PR: #521

end

# All default targets will use the return of this method. Overwrite if you want something else than <tt>model_name.plural</tt>.
def broadcast_target_default
model_name.plural
end

# Executes +block+ preventing both synchronous and asynchronous broadcasts from this model.
def suppressing_turbo_broadcasts(&block)
original, self.suppressed_turbo_broadcasts = self.suppressed_turbo_broadcasts, true
yield
ensure
self.suppressed_turbo_broadcasts = original
end

def suppressed_turbo_broadcasts?
suppressed_turbo_broadcasts

Choose a reason for hiding this comment

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

Suggested change
suppressed_turbo_broadcasts
!!suppressed_turbo_broadcasts

end
end

# Remove this broadcastable model from the dom for subscribers of the stream name identified by the passed streamables.
Expand All @@ -124,7 +171,7 @@ def broadcast_target_default
# # Sends <turbo-stream action="remove" target="clearance_5"></turbo-stream> to the stream named "identity:2:clearances"
# clearance.broadcast_remove_to examiner.identity, :clearances
def broadcast_remove_to(*streamables, target: self)
Turbo::StreamsChannel.broadcast_remove_to(*streamables, target: target)
Turbo::StreamsChannel.broadcast_remove_to(*streamables, target: target) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_remove_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -143,7 +190,7 @@ def broadcast_remove
# # to the stream named "identity:2:clearances"
# clearance.broadcast_replace_to examiner.identity, :clearances, partial: "clearances/other_partial", locals: { a: 1 }
def broadcast_replace_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_replace_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_replace_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_replace_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -162,7 +209,7 @@ def broadcast_replace(**rendering)
# # to the stream named "identity:2:clearances"
# clearance.broadcast_update_to examiner.identity, :clearances, partial: "clearances/other_partial", locals: { a: 1 }
def broadcast_update_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_update_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_update_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_update_to</tt>, but the designated stream is automatically set to the current model.
Expand Down Expand Up @@ -215,7 +262,7 @@ def broadcast_after_to(*streamables, target:, **rendering)
# clearance.broadcast_append_to examiner.identity, :clearances, target: "clearances",
# partial: "clearances/other_partial", locals: { a: 1 }
def broadcast_append_to(*streamables, target: broadcast_target_default, **rendering)
Turbo::StreamsChannel.broadcast_append_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_append_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_append_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -236,21 +283,29 @@ def broadcast_append(target: broadcast_target_default, **rendering)
# clearance.broadcast_prepend_to examiner.identity, :clearances, target: "clearances",
# partial: "clearances/other_partial", locals: { a: 1 }
def broadcast_prepend_to(*streamables, target: broadcast_target_default, **rendering)
Turbo::StreamsChannel.broadcast_prepend_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_prepend_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_prepend_to</tt>, but the designated stream is automatically set to the current model.
def broadcast_prepend(target: broadcast_target_default, **rendering)
broadcast_prepend_to self, target: target, **rendering
end

def broadcast_refresh_to(*streamables)
Turbo::StreamsChannel.broadcast_refresh_to *streamables unless suppressed_turbo_broadcasts?
end

def broadcast_refresh
broadcast_refresh_to self
end

# Broadcast a named <tt>action</tt>, allowing for dynamic dispatch, instead of using the concrete action methods. Examples:
#
# # Sends <turbo-stream action="prepend" target="clearances"><template><div id="clearance_5">My Clearance</div></template></turbo-stream>
# # to the stream named "identity:2:clearances"
# clearance.broadcast_action_to examiner.identity, :clearances, action: :prepend, target: "clearances"
def broadcast_action_to(*streamables, action:, target: broadcast_target_default, attributes: {}, **rendering)
Turbo::StreamsChannel.broadcast_action_to(*streamables, action: action, target: target, attributes: attributes, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_action_to(*streamables, action: action, target: target, attributes: attributes, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_action_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -261,7 +316,7 @@ def broadcast_action(action, target: broadcast_target_default, attributes: {}, *

# Same as <tt>broadcast_replace_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
def broadcast_replace_later_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_replace_later_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_replace_later_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_replace_later_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -271,7 +326,7 @@ def broadcast_replace_later(**rendering)

# Same as <tt>broadcast_update_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
def broadcast_update_later_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_update_later_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_update_later_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_update_later_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -281,7 +336,7 @@ def broadcast_update_later(**rendering)

# Same as <tt>broadcast_append_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
def broadcast_append_later_to(*streamables, target: broadcast_target_default, **rendering)
Turbo::StreamsChannel.broadcast_append_later_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_append_later_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_append_later_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -291,17 +346,25 @@ def broadcast_append_later(target: broadcast_target_default, **rendering)

# Same as <tt>broadcast_prepend_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
def broadcast_prepend_later_to(*streamables, target: broadcast_target_default, **rendering)
Turbo::StreamsChannel.broadcast_prepend_later_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_prepend_later_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_prepend_later_to</tt>, but the designated stream is automatically set to the current model.
def broadcast_prepend_later(target: broadcast_target_default, **rendering)
broadcast_prepend_later_to self, target: target, **rendering
end

def broadcast_refresh_later_to(*streamables, target: broadcast_target_default, **rendering)
Turbo::StreamsChannel.broadcast_refresh_later_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering).merge(request_id: Turbo.current_request_id)) unless suppressed_turbo_broadcasts?
end

def broadcast_refresh_later(target: broadcast_target_default, **rendering)
broadcast_refresh_later_to self, target: target, **rendering
end

# Same as <tt>broadcast_action_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
def broadcast_action_later_to(*streamables, action:, target: broadcast_target_default, attributes: {}, **rendering)
Turbo::StreamsChannel.broadcast_action_later_to(*streamables, action: action, target: target, attributes: attributes, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_action_later_to(*streamables, action: action, target: target, attributes: attributes, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_action_later_to</tt>, but the designated stream is automatically set to the current model.
Expand Down Expand Up @@ -337,7 +400,7 @@ def broadcast_render(**rendering)
# desireable for model callbacks, certainly not if those callbacks are inside of a transaction. Most of the time you should
# be using `broadcast_render_later_to`, unless you specifically know why synchronous rendering is needed.
def broadcast_render_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_render_to(*streamables, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_render_to(*streamables, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>broadcast_action_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
Expand All @@ -348,7 +411,7 @@ def broadcast_render_later(**rendering)
# Same as <tt>broadcast_render_later</tt> but run with the added option of naming the stream using the passed
# <tt>streamables</tt>.
def broadcast_render_later_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_render_later_to(*streamables, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_render_later_to(*streamables, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end


Expand All @@ -361,7 +424,7 @@ def broadcast_rendering_with_defaults(options)
options.tap do |o|
# Add the current instance into the locals with the element name (which is the un-namespaced name)
# as the key. This parallels how the ActionView::ObjectRenderer would create a local variable.
o[:locals] = (o[:locals] || {}).reverse_merge!(model_name.element.to_sym => self)
o[:locals] = (o[:locals] || {}).reverse_merge!(model_name.element.to_sym => self, request_id: Turbo.current_request_id).compact

if o[:html] || o[:partial]
return o
Expand Down
9 changes: 9 additions & 0 deletions lib/turbo-rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ module Turbo

mattr_accessor :draw_routes, default: true

thread_mattr_accessor :current_request_id

class << self
attr_writer :signed_stream_verifier_key

Expand All @@ -15,5 +17,12 @@ def signed_stream_verifier
def signed_stream_verifier_key
@signed_stream_verifier_key or raise ArgumentError, "Turbo requires a signed_stream_verifier_key"
end

def with_request_id(request_id)
old_request_id, self.current_request_id = self.current_request_id, request_id
yield
ensure
self.current_request_id = old_request_id
end
end
end
6 changes: 6 additions & 0 deletions lib/turbo/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ class Engine < Rails::Engine
end
end

initializer "turbo.request_id_tracking" do
ActiveSupport.on_load(:action_controller) do
include Turbo::RequestIdTracking
end
end

initializer "turbo.broadcastable" do
ActiveSupport.on_load(:active_record) do
include Turbo::Broadcastable
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"devDependencies": {
"@rollup/plugin-node-resolve": "^11.0.1",
"rollup": "^2.35.1",
"rollup-plugin-terser": "^7.0.2"
"rollup-plugin-terser": "^7.0.2",
"terser": "^5.22.0"
},
"license": "MIT",
"author": "Basecamp, LLC",
Expand Down
28 changes: 28 additions & 0 deletions test/current_request_id_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
require "test_helper"
require "action_cable"

class Turbo::CurrentRequestIdTest < ActiveSupport::TestCase
test "sets the current request id for a block of code" do
assert_nil Turbo.current_request_id

result = Turbo.with_request_id("123") do
assert_equal "123", Turbo.current_request_id
:the_result
end

assert_equal :the_result, result
assert_nil Turbo.current_request_id
end

test "raised errors will raise and clear the current request id" do
assert_nil Turbo.current_request_id

assert_raise "Some error" do
Turbo.with_request_id("123") do
raise "Some error"
end
end

assert_nil Turbo.current_request_id
end
end
20 changes: 20 additions & 0 deletions test/drive/drive_helper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,24 @@ class Turbo::DriveHelperTest < ActionDispatch::IntegrationTest
get trays_path
assert_match(/<meta name="turbo-visit-control" content="reload">/, @response.body)
end

test "configuring refresh strategy" do
get trays_path
assert_match(/<meta name="turbo-refresh-method" content="morph">/, @response.body)
assert_match(/<meta name="turbo-refresh-scroll" content="preserve">/, @response.body)
end
end

class Turbo::DriverHelperUnitTest < ActionView::TestCase
include Turbo::DriveHelper

test "validate turbo refresh values" do
assert_raises ArgumentError do
turbo_refreshes_with(method: :invalid)
end

assert_raises ArgumentError do
turbo_refreshes_with(scroll: :invalid)
end
end
end
5 changes: 5 additions & 0 deletions test/dummy/app/controllers/request_ids_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class RequestIdsController < ApplicationController
def show
render json: { turbo_frame_request_id: Turbo.current_request_id }
end
end