From a9e011de75badb2ae71741846f9fa89bcdb84901 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 20 Sep 2023 14:40:58 +0200 Subject: [PATCH 01/14] Support for page refreshes and broadcasting This PR is the Rails companion for the Turbo changes to add page refreshes. ```ruby turbo_refreshes_with scroll method: :morph, scroll: :preserve ``` This adds new Active Record helpers to broadcast page refreshes from models: ```ruby class Board broadcast_refreshes end ``` This works great in hierarchical structures, where child record touch parent records automatically to invalidate cache: ```ruby class Column belongs_to :board, touch: true # +Board+ will trigger a page refresh on column changes end ``` You can also specify the streamable declaratively: ```ruby class Column belongs_to :board broadcast_refreshes_to :board end ``` There are also instance-level companion methods to broadcast page refreshes: - `broadcast_refresh_later` - `broadcast_refresh_later_to(*streamables)` This PR introduces a new mechanism to suppress broadcasting of turbo treams for arbitrary blocks of code: ```ruby Recording.suppressing_turbo_broadcasts do ... end ``` When broadcasting page refreshes, the system will automatically debounce multiple calls in a row to only broadcast the last one. This is meant for scenarios where you process records in mass. Because of the nature of such signals, it makes no sense to broadcast them repeatedly and individually. --- app/channels/turbo/streams/broadcasts.rb | 8 + .../concerns/turbo/request_id_tracking.rb | 12 + app/helpers/turbo/drive_helper.rb | 8 + app/helpers/turbo/streams/action_helper.rb | 6 +- .../turbo/streams/broadcast_stream_job.rb | 7 + app/models/concerns/turbo/broadcastable.rb | 91 +++++-- lib/turbo-rails.rb | 9 + lib/turbo/engine.rb | 6 + test/current_request_id_test.rb | 28 +++ test/drive/drive_helper_test.rb | 20 ++ .../app/controllers/request_ids_controller.rb | 5 + test/dummy/app/views/trays/index.html.erb | 1 + test/dummy/config/routes.rb | 1 + test/refreshes/request_id_tracking_test.rb | 8 + test/streams/action_helper_test.rb | 13 + test/streams/broadcastable_test.rb | 227 +++++++++++++++++- test/streams/streams_channel_test.rb | 14 ++ test/test_helper.rb | 4 + 18 files changed, 449 insertions(+), 19 deletions(-) create mode 100644 app/controllers/concerns/turbo/request_id_tracking.rb create mode 100644 app/jobs/turbo/streams/broadcast_stream_job.rb create mode 100644 test/current_request_id_test.rb create mode 100644 test/dummy/app/controllers/request_ids_controller.rb create mode 100644 test/refreshes/request_id_tracking_test.rb diff --git a/app/channels/turbo/streams/broadcasts.rb b/app/channels/turbo/streams/broadcasts.rb index 278e0fbf..3aecebad 100644 --- a/app/channels/turbo/streams/broadcasts.rb +++ b/app/channels/turbo/streams/broadcasts.rb @@ -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), @@ -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 diff --git a/app/controllers/concerns/turbo/request_id_tracking.rb b/app/controllers/concerns/turbo/request_id_tracking.rb new file mode 100644 index 00000000..2f41acb4 --- /dev/null +++ b/app/controllers/concerns/turbo/request_id_tracking.rb @@ -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 diff --git a/app/helpers/turbo/drive_helper.rb b/app/helpers/turbo/drive_helper.rb index 9aaa0a37..66e5994d 100644 --- a/app/helpers/turbo/drive_helper.rb +++ b/app/helpers/turbo/drive_helper.rb @@ -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 diff --git a/app/helpers/turbo/streams/action_helper.rb b/app/helpers/turbo/streams/action_helper.rb index 37e6e545..a43255a9 100644 --- a/app/helpers/turbo/streams/action_helper.rb +++ b/app/helpers/turbo/streams/action_helper.rb @@ -24,7 +24,7 @@ module Turbo::Streams::ActionHelper # # => # 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) @@ -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) } diff --git a/app/jobs/turbo/streams/broadcast_stream_job.rb b/app/jobs/turbo/streams/broadcast_stream_job.rb new file mode 100644 index 00000000..64cd8378 --- /dev/null +++ b/app/jobs/turbo/streams/broadcast_stream_job.rb @@ -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 diff --git a/app/models/concerns/turbo/broadcastable.rb b/app/models/concerns/turbo/broadcastable.rb index 2af2eeea..78735d8b 100644 --- a/app/models/concerns/turbo/broadcastable.rb +++ b/app/models/concerns/turbo/broadcastable.rb @@ -75,9 +75,32 @@ # In addition to the four basic actions, you can also use broadcast_render, # broadcast_render_to broadcast_render_later, and broadcast_render_later_to # to render a turbo stream template with multiple actions. +# +# == Suppressing broadcasts +# +# Sometimes, you need to disable broadcasts in certain scenarios. You can use .suppressing_turbo_broadcasts 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 # stream symbol invocation. By default, the creates are appended to a dom id target name derived from @@ -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 stream symbol invocation. + def broadcasts_refreshes_to(stream) + after_commit -> { broadcast_refresh_later_to(stream.try(:call, self) || send(stream)) } + end + + # Same as #broadcasts_refreshes_to, but the designated stream for page refreshes is automatically set to + # the current model. + def broadcasts_refreshes + after_commit -> { broadcast_refresh_later } + end + # All default targets will use the return of this method. Overwrite if you want something else than model_name.plural. 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 + end end # Remove this broadcastable model from the dom for subscribers of the stream name identified by the passed streamables. @@ -124,7 +171,7 @@ def broadcast_target_default # # Sends 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 #broadcast_remove_to, but the designated stream is automatically set to the current model. @@ -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 #broadcast_replace_to, but the designated stream is automatically set to the current model. @@ -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 #broadcast_update_to, but the designated stream is automatically set to the current model. @@ -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 #broadcast_append_to, but the designated stream is automatically set to the current model. @@ -236,7 +283,7 @@ 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 #broadcast_prepend_to, but the designated stream is automatically set to the current model. @@ -244,13 +291,21 @@ 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 action, allowing for dynamic dispatch, instead of using the concrete action methods. Examples: # # # Sends # # 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 #broadcast_action_to, but the designated stream is automatically set to the current model. @@ -261,7 +316,7 @@ def broadcast_action(action, target: broadcast_target_default, attributes: {}, * # Same as broadcast_replace_to but run asynchronously via a Turbo::Streams::BroadcastJob. 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 #broadcast_replace_later_to, but the designated stream is automatically set to the current model. @@ -271,7 +326,7 @@ def broadcast_replace_later(**rendering) # Same as broadcast_update_to but run asynchronously via a Turbo::Streams::BroadcastJob. 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 #broadcast_update_later_to, but the designated stream is automatically set to the current model. @@ -281,7 +336,7 @@ def broadcast_update_later(**rendering) # Same as broadcast_append_to but run asynchronously via a Turbo::Streams::BroadcastJob. 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 #broadcast_append_later_to, but the designated stream is automatically set to the current model. @@ -291,7 +346,7 @@ def broadcast_append_later(target: broadcast_target_default, **rendering) # Same as broadcast_prepend_to but run asynchronously via a Turbo::Streams::BroadcastJob. 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 #broadcast_prepend_later_to, but the designated stream is automatically set to the current model. @@ -299,9 +354,17 @@ 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 broadcast_action_to but run asynchronously via a Turbo::Streams::BroadcastJob. 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 #broadcast_action_later_to, but the designated stream is automatically set to the current model. @@ -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 broadcast_action_to but run asynchronously via a Turbo::Streams::BroadcastJob. @@ -348,7 +411,7 @@ def broadcast_render_later(**rendering) # Same as broadcast_render_later but run with the added option of naming the stream using the passed # streamables. 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 @@ -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 diff --git a/lib/turbo-rails.rb b/lib/turbo-rails.rb index 1a50bd7c..6c401aae 100644 --- a/lib/turbo-rails.rb +++ b/lib/turbo-rails.rb @@ -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 @@ -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 diff --git a/lib/turbo/engine.rb b/lib/turbo/engine.rb index 487bf39b..74c4cd79 100644 --- a/lib/turbo/engine.rb +++ b/lib/turbo/engine.rb @@ -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 diff --git a/test/current_request_id_test.rb b/test/current_request_id_test.rb new file mode 100644 index 00000000..9adbbad0 --- /dev/null +++ b/test/current_request_id_test.rb @@ -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 diff --git a/test/drive/drive_helper_test.rb b/test/drive/drive_helper_test.rb index d22cc52d..e071ed31 100644 --- a/test/drive/drive_helper_test.rb +++ b/test/drive/drive_helper_test.rb @@ -10,4 +10,24 @@ class Turbo::DriveHelperTest < ActionDispatch::IntegrationTest get trays_path assert_match(//, @response.body) end + + test "configuring refresh strategy" do + get trays_path + assert_match(//, @response.body) + assert_match(//, @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 diff --git a/test/dummy/app/controllers/request_ids_controller.rb b/test/dummy/app/controllers/request_ids_controller.rb new file mode 100644 index 00000000..5983dbde --- /dev/null +++ b/test/dummy/app/controllers/request_ids_controller.rb @@ -0,0 +1,5 @@ +class RequestIdsController < ApplicationController + def show + render json: { turbo_frame_request_id: Turbo.current_request_id } + end +end diff --git a/test/dummy/app/views/trays/index.html.erb b/test/dummy/app/views/trays/index.html.erb index c1241bd7..9658cb7d 100644 --- a/test/dummy/app/views/trays/index.html.erb +++ b/test/dummy/app/views/trays/index.html.erb @@ -1,4 +1,5 @@ <% turbo_exempts_page_from_cache %> <% turbo_page_requires_reload %> +<%= turbo_refreshes_with method: :morph, scroll: :preserve %>

Not in the cache!

diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index aae1d048..f84b8760 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -16,4 +16,5 @@ namespace :admin do resources :companies end + resource :request_id end diff --git a/test/refreshes/request_id_tracking_test.rb b/test/refreshes/request_id_tracking_test.rb new file mode 100644 index 00000000..2bb34cdc --- /dev/null +++ b/test/refreshes/request_id_tracking_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class Turbo::RequestIdTrackingTest < ActionDispatch::IntegrationTest + test "set the current turbo request id from the value in the X-Turbo-Request-Id header" do + get request_id_path, headers: { "X-Turbo-Request-Id" => "123" } + assert_equal "123", JSON.parse(response.body)["turbo_frame_request_id"] + end +end diff --git a/test/streams/action_helper_test.rb b/test/streams/action_helper_test.rb index d1818cd0..20a78836 100644 --- a/test/streams/action_helper_test.rb +++ b/test/streams/action_helper_test.rb @@ -85,4 +85,17 @@ class Turbo::ActionHelperTest < ActionCable::Channel::TestCase assert_equal "", action end + + test "turbo stream refresh tag" do + action = turbo_stream_refresh_tag + + assert_equal "", action + end + + test "turbo stream refresh tag that carries the current request id" do + Turbo.current_request_id = "123" + action = turbo_stream_refresh_tag + + assert_equal "", action + end end diff --git a/test/streams/broadcastable_test.rb b/test/streams/broadcastable_test.rb index 083f2b74..e13bbc1a 100644 --- a/test/streams/broadcastable_test.rb +++ b/test/streams/broadcastable_test.rb @@ -96,6 +96,18 @@ class Turbo::BroadcastableTest < ActionCable::Channel::TestCase end end + test "broadcasting refresh to stream now" do + assert_broadcast_on "stream", turbo_stream_refresh_tag do + @message.broadcast_refresh_to "stream" + end + end + + test "broadcasting refresh now" do + assert_broadcast_on @message.to_gid_param, turbo_stream_refresh_tag do + @message.broadcast_refresh + end + end + test "broadcasting action to stream now" do assert_broadcast_on "stream", turbo_stream_action_tag("prepend", target: "messages", template: render(@message)) do @message.broadcast_action_to "stream", action: "prepend" @@ -248,8 +260,8 @@ class Turbo::BroadcastableCommentTest < ActionCable::Channel::TestCase test "updating a comment broadcasts" do comment = @article.comments.create!(body: "random") - stream = "#{@article.to_gid_param}:comments" - target = "comment_#{comment.id}" + stream = "#{@article.to_gid_param}:comments" + target = "comment_#{comment.id}" assert_broadcast_on stream, turbo_stream_action_tag("replace", target: target, template: %(

precise

\n)) do perform_enqueued_jobs do @@ -260,11 +272,218 @@ class Turbo::BroadcastableCommentTest < ActionCable::Channel::TestCase test "destroying a comment broadcasts" do comment = @article.comments.create!(body: "comment") - stream = "#{@article.to_gid_param}:comments" - target = "comment_#{comment.id}" + stream = "#{@article.to_gid_param}:comments" + target = "comment_#{comment.id}" assert_broadcast_on stream, turbo_stream_action_tag("remove", target: target) do comment.destroy! end end end + +class Turbo::SuppressingBroadcastsTest < ActionCable::Channel::TestCase + include ActiveJob::TestHelper, Turbo::Streams::ActionHelper + + setup { @message = Message.new(id: 1, content: "Hello!") } + + test "suppressing broadcasting remove to stream now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_remove_to "stream" + end + end + + test "suppressing broadcasting remove now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_remove + end + end + + test "suppressing broadcasting replace to stream now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_replace_to "stream" + end + end + + test "suppressing broadcasting replace to stream later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_replace_later_to "stream" + end + end + + test "suppressing broadcasting replace now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_replace + end + end + + test "suppressing broadcasting replace later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_replace_later + end + end + + test "suppressing broadcasting update to stream now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_update_to "stream" + end + end + + test "suppressing broadcasting update to stream later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_update_later_to "stream" + end + end + + test "suppressing broadcasting update now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_update + end + end + + test "suppressing broadcasting update later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_update_later + end + end + + test "suppressing broadcasting before to stream now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_before_to "stream", target: "message_1" + end + end + + test "suppressing broadcasting after to stream now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_after_to "stream", target: "message_1" + end + end + + test "suppressing broadcasting append to stream now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_append_to "stream" + end + end + + test "suppressing broadcasting append to stream later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_append_later_to "stream" + end + end + + test "suppressing broadcasting append now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_append + end + end + + test "suppressing broadcasting append later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_append_later + end + end + + test "suppressing broadcasting prepend to stream now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_prepend_to "stream" + end + end + + test "suppressing broadcasting prepend to stream later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_prepend_later_to "stream" + end + end + + test "suppressing broadcasting refresh to stream now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_refresh_to "stream" + end + end + + test "suppressing broadcasting refresh to stream later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_refresh_later_to "stream" + end + end + + test "suppressing broadcasting prepend now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_prepend + end + end + + test "suppressing broadcasting prepend later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_prepend_later + end + end + + test "suppressing broadcasting action to stream now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_action_to "stream", action: "prepend" + end + end + + test "suppressing broadcasting action to stream later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_action_later_to "stream", action: "prepend" + end + end + + test "suppressing broadcasting action now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_action "prepend" + end + end + + test "suppressing broadcasting action later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_action_later action: "prepend" + end + end + + test "suppressing broadcast render now" do + assert_no_broadcasts_when_suppressing do + @message.broadcast_render + end + end + + test "suppressing broadcast render later" do + assert_no_broadcasts_later_when_supressing do + @message.broadcast_render_later + end + end + + test "suppressing broadcast render to stream now" do + @profile = Users::Profile.new(id: 1, name: "Ryan") + assert_no_broadcasts_when_suppressing do + @message.broadcast_render_to @profile + end + end + + test "suppressing broadcast render to stream later" do + @profile = Users::Profile.new(id: 1, name: "Ryan") + assert_no_broadcasts_later_when_supressing do + @message.broadcast_render_to @profile + end + end + + private + def assert_no_broadcasts_when_suppressing + assert_no_broadcasts @message.to_gid_param do + Message.suppressing_turbo_broadcasts do + yield + end + end + end + + def assert_no_broadcasts_later_when_supressing + assert_no_broadcasts_when_suppressing do + assert_no_enqueued_jobs do + yield + end + end + end +end + + diff --git a/test/streams/streams_channel_test.rb b/test/streams/streams_channel_test.rb index dea35c6f..9ea562f6 100644 --- a/test/streams/streams_channel_test.rb +++ b/test/streams/streams_channel_test.rb @@ -169,7 +169,21 @@ class Turbo::StreamsChannelTest < ActionCable::Channel::TestCase "stream", targets: ".message", **options end end + end + + test "broadcasting refresh later" do + assert_broadcast_on "stream", turbo_stream_refresh_tag do + perform_enqueued_jobs do + Turbo::StreamsChannel.broadcast_refresh_later_to "stream" + end + end + Turbo.current_request_id = "123" + assert_broadcast_on "stream", turbo_stream_refresh_tag do + perform_enqueued_jobs do + Turbo::StreamsChannel.broadcast_refresh_later_to "stream" + end + end end test "broadcasting action later" do diff --git a/test/test_helper.rb b/test/test_helper.rb index bc2878b8..bbe0f583 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -15,6 +15,10 @@ def render(...) class ActiveSupport::TestCase include ActiveJob::TestHelper + + setup do + Turbo.current_request_id = nil + end end class ActionDispatch::IntegrationTest From 6b944629ffc5e94b518136d809c916963ab7ce38 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 27 Oct 2023 09:50:58 +0200 Subject: [PATCH 02/14] Upgrade terser and its rollup plugin to support private method's syntax --- package.json | 3 +- yarn.lock | 225 ++++++++++++++++++++++++++++++++------------------- 2 files changed, 142 insertions(+), 86 deletions(-) diff --git a/package.json b/package.json index 8de3cda9..7df20840 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 8c0c19cc..d0066ce5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,24 +3,25 @@ "@babel/code-frame@^7.10.4": - version "7.12.11" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" - integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== + version "7.22.13" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e" + integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w== dependencies: - "@babel/highlight" "^7.10.4" - -"@babel/helper-validator-identifier@^7.10.4": - version "7.12.11" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" - integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== - -"@babel/highlight@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143" - integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA== + "@babel/highlight" "^7.22.13" + chalk "^2.4.2" + +"@babel/helper-validator-identifier@^7.22.20": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" + integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== + +"@babel/highlight@^7.22.13": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54" + integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg== dependencies: - "@babel/helper-validator-identifier" "^7.10.4" - chalk "^2.0.0" + "@babel/helper-validator-identifier" "^7.22.20" + chalk "^2.4.2" js-tokens "^4.0.0" "@hotwired/turbo@^7.3.0": @@ -28,15 +29,55 @@ resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.3.0.tgz#2226000fff1aabda9fd9587474565c9929dbf15d" integrity sha512-Dcu+NaSvHLT7EjrDrkEmH4qET2ZJZ5IcCWmNXxNQTBwlnE5tBZfN6WxZ842n5cHV52DH/AKNirbPBtcEXDLW4g== +"@jridgewell/gen-mapping@^0.3.0": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098" + integrity sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ== + dependencies: + "@jridgewell/set-array" "^1.0.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.9" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" + integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA== + +"@jridgewell/set-array@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" + integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== + +"@jridgewell/source-map@^0.3.3": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.5.tgz#a3bb4d5c6825aab0d281268f47f6ad5853431e91" + integrity sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.4.15" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" + integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== + +"@jridgewell/trace-mapping@^0.3.9": + version "0.3.20" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz#72e45707cf240fa6b081d0366f8265b0cd10197f" + integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@rails/actioncable@^7.0": - version "7.0.1" - resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.0.1.tgz#8f383b672e142d009f89b725d49b0832d99da74a" - integrity sha512-lbGc1z2RXdiWZJE/8o2GSe2gek82EoKd2YvjRrqV//0J3/JImONUYwZ2XPmS1R9R2oth1XlIG0YidqdeTty0TA== + version "7.1.1" + resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.1.1.tgz#e8c49769d41f35a4473133c259cc98adc04dddf8" + integrity sha512-ZRJ9rdwFQQjRbtgJnweY0/4UQyxN6ojEGRdib0JkjnuIciv+4ok/aAeZmBJqNreTMaBqS0eHyA9hCArwN58opg== "@rollup/plugin-node-resolve@^11.0.1": - version "11.0.1" - resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.0.1.tgz#d3765eec4bccf960801439a999382aed2dca959b" - integrity sha512-ltlsj/4Bhwwhb+Nb5xCz/6vieuEj2/BAkkqVIKmZwC7pIdl8srmgmglE4S0jFlZa32K4qvdQ6NHdmpRKD/LwoQ== + version "11.2.1" + resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz#82aa59397a29cd4e13248b106e6a4a1880362a60" + integrity sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg== dependencies: "@rollup/pluginutils" "^3.1.0" "@types/resolve" "1.17.1" @@ -60,9 +101,11 @@ integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== "@types/node@*": - version "14.14.14" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.14.tgz#f7fd5f3cc8521301119f63910f0fb965c7d761ae" - integrity sha512-UHnOPWVWV1z+VV8k6L1HhG7UbGBgIdghqF3l9Ny9ApPghbjICXkUJSd/b9gOgQfjM1r+37cipdw/HJ3F6ICEnQ== + version "20.8.9" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.9.tgz#646390b4fab269abce59c308fc286dcd818a2b08" + integrity sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg== + dependencies: + undici-types "~5.26.4" "@types/resolve@1.17.1": version "1.17.1" @@ -71,6 +114,11 @@ dependencies: "@types/node" "*" +acorn@^8.8.2: + version "8.11.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.1.tgz#29c6f12c3002d884b6f8baa37089e1917425cd3d" + integrity sha512-IJTNCJMRHfRfb8un89z1QtS0x890C2QUrUxFMK8zy+RizcId6mfnqOf68Bu9YkDgpLYuvCm6aYbwDatXVZPjMQ== + ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -79,16 +127,16 @@ ansi-styles@^3.2.1: color-convert "^1.9.0" buffer-from@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" - integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== builtin-modules@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.1.0.tgz#aad97c15131eb76b65b50ef208e7584cd76a7484" - integrity sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw== + version "3.3.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" + integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== -chalk@^2.0.0: +chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -107,7 +155,7 @@ color-convert@^1.9.0: color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== commander@^2.20.0: version "2.20.3" @@ -115,58 +163,58 @@ commander@^2.20.0: integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== deepmerge@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" - integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== estree-walker@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== -fsevents@~2.1.2: - version "2.1.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" - integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== +hasown@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c" + integrity sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA== dependencies: - function-bind "^1.1.1" + function-bind "^1.1.2" -is-core-module@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" - integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ== +is-core-module@^2.13.0: + version "2.13.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" + integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== dependencies: - has "^1.0.3" + hasown "^2.0.0" is-module@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" - integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= + integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g== jest-worker@^26.2.1: version "26.6.2" @@ -187,15 +235,15 @@ merge-stream@^2.0.0: resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== -path-parse@^1.0.6: +path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== picomatch@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" - integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== randombytes@^2.1.0: version "2.1.0" @@ -205,12 +253,13 @@ randombytes@^2.1.0: safe-buffer "^5.1.0" resolve@^1.19.0: - version "1.19.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" - integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg== + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== dependencies: - is-core-module "^2.1.0" - path-parse "^1.0.6" + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" rollup-plugin-terser@^7.0.2: version "7.0.2" @@ -223,11 +272,11 @@ rollup-plugin-terser@^7.0.2: terser "^5.0.0" rollup@^2.35.1: - version "2.35.1" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.35.1.tgz#e6bc8d10893556a638066f89e8c97f422d03968c" - integrity sha512-q5KxEyWpprAIcainhVy6HfRttD9kutQpHbeqDTWnqAFNJotiojetK6uqmcydNMymBEtC4I8bCYR+J3mTMqeaUA== + version "2.79.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7" + integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== optionalDependencies: - fsevents "~2.1.2" + fsevents "~2.3.2" safe-buffer@^5.1.0: version "5.2.1" @@ -241,10 +290,10 @@ serialize-javascript@^4.0.0: dependencies: randombytes "^2.1.0" -source-map-support@~0.5.19: - version "0.5.19" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" - integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== +source-map-support@~0.5.20: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== dependencies: buffer-from "^1.0.0" source-map "^0.6.0" @@ -254,11 +303,6 @@ source-map@^0.6.0: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@~0.7.2: - version "0.7.3" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" - integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== - supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -273,11 +317,22 @@ supports-color@^7.0.0: dependencies: has-flag "^4.0.0" -terser@^5.0.0: - version "5.5.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.5.1.tgz#540caa25139d6f496fdea056e414284886fb2289" - integrity sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ== +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +terser@^5.0.0, terser@^5.22.0: + version "5.22.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.22.0.tgz#4f18103f84c5c9437aafb7a14918273310a8a49d" + integrity sha512-hHZVLgRA2z4NWcN6aS5rQDc+7Dcy58HOf2zbYwmFcQ+ua3h6eEFf5lIDKTzbWwlazPyOZsFQO8V80/IjVNExEw== dependencies: + "@jridgewell/source-map" "^0.3.3" + acorn "^8.8.2" commander "^2.20.0" - source-map "~0.7.2" - source-map-support "~0.5.19" + source-map-support "~0.5.20" + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== From ddeeeb81d07eeea9423ab0c06bfed5b13f649c22 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Fri, 27 Oct 2023 09:51:32 +0200 Subject: [PATCH 03/14] Build latest turbo version from the page-refreshes branch in hotwired/turbo --- app/assets/javascripts/turbo.js | 2213 ++++++++++++++++------- app/assets/javascripts/turbo.min.js | 14 +- app/assets/javascripts/turbo.min.js.map | 2 +- 3 files changed, 1561 insertions(+), 668 deletions(-) diff --git a/app/assets/javascripts/turbo.js b/app/assets/javascripts/turbo.js index 971cfac7..a7f79b35 100644 --- a/app/assets/javascripts/turbo.js +++ b/app/assets/javascripts/turbo.js @@ -1,19 +1,7 @@ -(function() { - if (window.Reflect === undefined || window.customElements === undefined || window.customElements.polyfillWrapFlushCallback) { - return; - } - const BuiltInHTMLElement = HTMLElement; - const wrapperForTheName = { - HTMLElement: function HTMLElement() { - return Reflect.construct(BuiltInHTMLElement, [], this.constructor); - } - }; - window.HTMLElement = wrapperForTheName["HTMLElement"]; - HTMLElement.prototype = BuiltInHTMLElement.prototype; - HTMLElement.prototype.constructor = HTMLElement; - Object.setPrototypeOf(HTMLElement, BuiltInHTMLElement); -})(); - +/*! +Turbo 7.3.0 +Copyright © 2023 37signals LLC + */ (function(prototype) { if (typeof prototype.requestSubmit == "function") return; prototype.requestSubmit = function(submitter) { @@ -44,7 +32,7 @@ const submittersByForm = new WeakMap; function findSubmitterFromClickTarget(target) { const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; const candidate = element ? element.closest("input, button") : null; - return (candidate === null || candidate === void 0 ? void 0 : candidate.type) == "submit" ? candidate : null; + return candidate?.type == "submit" ? candidate : null; } function clickCaptured(event) { @@ -57,10 +45,13 @@ function clickCaptured(event) { (function() { if ("submitter" in Event.prototype) return; let prototype = window.Event.prototype; - if ("SubmitEvent" in window && /Apple Computer/.test(navigator.vendor)) { - prototype = window.SubmitEvent.prototype; - } else if ("SubmitEvent" in window) { - return; + if ("SubmitEvent" in window) { + const prototypeOfSubmitEvent = window.SubmitEvent.prototype; + if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) { + prototype = prototypeOfSubmitEvent; + } else { + return; + } } addEventListener("click", clickCaptured, true); Object.defineProperty(prototype, "submitter", { @@ -72,20 +63,19 @@ function clickCaptured(event) { }); })(); -var FrameLoadingStyle; - -(function(FrameLoadingStyle) { - FrameLoadingStyle["eager"] = "eager"; - FrameLoadingStyle["lazy"] = "lazy"; -})(FrameLoadingStyle || (FrameLoadingStyle = {})); +const FrameLoadingStyle = { + eager: "eager", + lazy: "lazy" +}; class FrameElement extends HTMLElement { + static delegateConstructor=undefined; + loaded=Promise.resolve(); static get observedAttributes() { return [ "disabled", "complete", "loading", "src" ]; } constructor() { super(); - this.loaded = Promise.resolve(); this.delegate = new FrameElement.delegateConstructor(this); } connectedCallback() { @@ -118,6 +108,16 @@ class FrameElement extends HTMLElement { this.removeAttribute("src"); } } + get refresh() { + return this.getAttribute("refresh"); + } + set refresh(value) { + if (value) { + this.setAttribute("refresh", value); + } else { + this.removeAttribute("refresh"); + } + } get loading() { return frameLoadingStyleFromString(this.getAttribute("loading") || ""); } @@ -155,8 +155,7 @@ class FrameElement extends HTMLElement { return this.ownerDocument === document && !this.isPreview; } get isPreview() { - var _a, _b; - return (_b = (_a = this.ownerDocument) === null || _a === void 0 ? void 0 : _a.documentElement) === null || _b === void 0 ? void 0 : _b.hasAttribute("data-turbo-preview"); + return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview"); } } @@ -183,8 +182,8 @@ function getAnchor(url) { } } -function getAction(form, submitter) { - const action = (submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("formaction")) || form.getAttribute("action") || form.action; +function getAction$1(form, submitter) { + const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action; return expandURL(action); } @@ -370,7 +369,7 @@ function uuid() { } function getAttribute(attributeName, ...elements) { - for (const value of elements.map((element => element === null || element === void 0 ? void 0 : element.getAttribute(attributeName)))) { + for (const value of elements.map((element => element?.getAttribute(attributeName)))) { if (typeof value == "string") return value; } return null; @@ -456,21 +455,38 @@ function setMetaContent(name, content) { } function findClosestRecursively(element, selector) { - var _a; if (element instanceof Element) { - return element.closest(selector) || findClosestRecursively(element.assignedSlot || ((_a = element.getRootNode()) === null || _a === void 0 ? void 0 : _a.host), selector); + return element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector); } } -var FetchMethod; +function elementIsFocusable(element) { + const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])"; + return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function"; +} + +function queryAutofocusableElement(elementOrDocumentFragment) { + return Array.from(elementOrDocumentFragment.querySelectorAll("[autofocus]")).find(elementIsFocusable); +} + +async function around(callback, reader) { + const before = reader(); + callback(); + await nextAnimationFrame(); + const after = reader(); + return [ before, after ]; +} -(function(FetchMethod) { - FetchMethod[FetchMethod["get"] = 0] = "get"; - FetchMethod[FetchMethod["post"] = 1] = "post"; - FetchMethod[FetchMethod["put"] = 2] = "put"; - FetchMethod[FetchMethod["patch"] = 3] = "patch"; - FetchMethod[FetchMethod["delete"] = 4] = "delete"; -})(FetchMethod || (FetchMethod = {})); +function fetch(url, options = {}) { + const modifiedHeaders = new Headers(options.headers || {}); + const requestUID = uuid(); + window.Turbo.session.recentRequests.add(requestUID); + modifiedHeaders.append("X-Turbo-Request-Id", requestUID); + return window.fetch(url, { + ...options, + headers: modifiedHeaders + }); +} function fetchMethodFromString(method) { switch (method.toLowerCase()) { @@ -491,16 +507,81 @@ function fetchMethodFromString(method) { } } +const FetchMethod = { + get: "get", + post: "post", + put: "put", + patch: "patch", + delete: "delete" +}; + +function fetchEnctypeFromString(encoding) { + switch (encoding.toLowerCase()) { + case FetchEnctype.multipart: + return FetchEnctype.multipart; + + case FetchEnctype.plain: + return FetchEnctype.plain; + + default: + return FetchEnctype.urlEncoded; + } +} + +const FetchEnctype = { + urlEncoded: "application/x-www-form-urlencoded", + multipart: "multipart/form-data", + plain: "text/plain" +}; + class FetchRequest { - constructor(delegate, method, location, body = new URLSearchParams, target = null) { - this.abortController = new AbortController; - this.resolveRequestPromise = _value => {}; + abortController=new AbortController; + #resolveRequestPromise=_value => {}; + constructor(delegate, method, location, requestBody = new URLSearchParams, target = null, enctype = FetchEnctype.urlEncoded) { + const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype); this.delegate = delegate; - this.method = method; - this.headers = this.defaultHeaders; - this.body = body; - this.url = location; + this.url = url; this.target = target; + this.fetchOptions = { + credentials: "same-origin", + redirect: "follow", + method: method, + headers: { + ...this.defaultHeaders + }, + body: body, + signal: this.abortSignal, + referrer: this.delegate.referrer?.href + }; + this.enctype = enctype; + } + get method() { + return this.fetchOptions.method; + } + set method(value) { + const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData; + const fetchMethod = fetchMethodFromString(value) || FetchMethod.get; + this.url.search = ""; + const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype); + this.url = url; + this.fetchOptions.body = body; + this.fetchOptions.method = fetchMethod; + } + get headers() { + return this.fetchOptions.headers; + } + set headers(value) { + this.fetchOptions.headers = value; + } + get body() { + if (this.isSafe) { + return this.url.searchParams; + } else { + return this.fetchOptions.body; + } + } + set body(value) { + this.fetchOptions.body = value; } get location() { return this.url; @@ -517,14 +598,14 @@ class FetchRequest { async perform() { const {fetchOptions: fetchOptions} = this; this.delegate.prepareRequest(this); - await this.allowRequestToBeIntercepted(fetchOptions); + await this.#allowRequestToBeIntercepted(fetchOptions); try { this.delegate.requestStarted(this); const response = await fetch(this.url.href, fetchOptions); return await this.receive(response); } catch (error) { if (error.name !== "AbortError") { - if (this.willDelegateErrorHandling(error)) { + if (this.#willDelegateErrorHandling(error)) { this.delegate.requestErrored(this, error); } throw error; @@ -551,25 +632,13 @@ class FetchRequest { } return fetchResponse; } - get fetchOptions() { - var _a; - return { - method: FetchMethod[this.method].toUpperCase(), - credentials: "same-origin", - headers: this.headers, - redirect: "follow", - body: this.isSafe ? null : this.body, - signal: this.abortSignal, - referrer: (_a = this.delegate.referrer) === null || _a === void 0 ? void 0 : _a.href - }; - } get defaultHeaders() { return { Accept: "text/html, application/xhtml+xml" }; } get isSafe() { - return this.method === FetchMethod.get; + return isSafe(this.method); } get abortSignal() { return this.abortController.signal; @@ -577,20 +646,21 @@ class FetchRequest { acceptResponseType(mimeType) { this.headers["Accept"] = [ mimeType, this.headers["Accept"] ].join(", "); } - async allowRequestToBeIntercepted(fetchOptions) { - const requestInterception = new Promise((resolve => this.resolveRequestPromise = resolve)); + async #allowRequestToBeIntercepted(fetchOptions) { + const requestInterception = new Promise((resolve => this.#resolveRequestPromise = resolve)); const event = dispatch("turbo:before-fetch-request", { cancelable: true, detail: { fetchOptions: fetchOptions, url: this.url, - resume: this.resolveRequestPromise + resume: this.#resolveRequestPromise }, target: this.target }); + this.url = event.detail.url; if (event.defaultPrevented) await requestInterception; } - willDelegateErrorHandling(error) { + #willDelegateErrorHandling(error) { const event = dispatch("turbo:fetch-request-error", { target: this.target, cancelable: true, @@ -603,15 +673,38 @@ class FetchRequest { } } +function isSafe(fetchMethod) { + return fetchMethodFromString(fetchMethod) == FetchMethod.get; +} + +function buildResourceAndBody(resource, method, requestBody, enctype) { + const searchParams = Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams; + if (isSafe(method)) { + return [ mergeIntoURLSearchParams(resource, searchParams), null ]; + } else if (enctype == FetchEnctype.urlEncoded) { + return [ resource, searchParams ]; + } else { + return [ resource, requestBody ]; + } +} + +function entriesExcludingFiles(requestBody) { + const entries = []; + for (const [name, value] of requestBody) { + if (value instanceof File) continue; else entries.push([ name, value ]); + } + return entries; +} + +function mergeIntoURLSearchParams(url, requestBody) { + const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody)); + url.search = searchParams.toString(); + return url; +} + class AppearanceObserver { + started=false; constructor(delegate, element) { - this.started = false; - this.intersect = entries => { - const lastEntry = entries.slice(-1)[0]; - if (lastEntry === null || lastEntry === void 0 ? void 0 : lastEntry.isIntersecting) { - this.delegate.elementAppearedInViewport(this.element); - } - }; this.delegate = delegate; this.element = element; this.intersectionObserver = new IntersectionObserver(this.intersect); @@ -628,9 +721,16 @@ class AppearanceObserver { this.intersectionObserver.unobserve(this.element); } } + intersect=entries => { + const lastEntry = entries.slice(-1)[0]; + if (lastEntry?.isIntersecting) { + this.delegate.elementAppearedInViewport(this.element); + } + }; } class StreamMessage { + static contentType="text/vnd.turbo-stream.html"; static wrap(message) { if (typeof message == "string") { return new this(createDocumentFragment(message)); @@ -643,8 +743,6 @@ class StreamMessage { } } -StreamMessage.contentType = "text/vnd.turbo-stream.html"; - function importStreamElements(fragment) { for (const element of fragment.querySelectorAll("turbo-stream")) { const streamElement = document.importNode(element, true); @@ -656,85 +754,54 @@ function importStreamElements(fragment) { return fragment; } -var FormSubmissionState; - -(function(FormSubmissionState) { - FormSubmissionState[FormSubmissionState["initialized"] = 0] = "initialized"; - FormSubmissionState[FormSubmissionState["requesting"] = 1] = "requesting"; - FormSubmissionState[FormSubmissionState["waiting"] = 2] = "waiting"; - FormSubmissionState[FormSubmissionState["receiving"] = 3] = "receiving"; - FormSubmissionState[FormSubmissionState["stopping"] = 4] = "stopping"; - FormSubmissionState[FormSubmissionState["stopped"] = 5] = "stopped"; -})(FormSubmissionState || (FormSubmissionState = {})); - -var FormEnctype; - -(function(FormEnctype) { - FormEnctype["urlEncoded"] = "application/x-www-form-urlencoded"; - FormEnctype["multipart"] = "multipart/form-data"; - FormEnctype["plain"] = "text/plain"; -})(FormEnctype || (FormEnctype = {})); - -function formEnctypeFromString(encoding) { - switch (encoding.toLowerCase()) { - case FormEnctype.multipart: - return FormEnctype.multipart; - - case FormEnctype.plain: - return FormEnctype.plain; - - default: - return FormEnctype.urlEncoded; - } -} +const FormSubmissionState = { + initialized: "initialized", + requesting: "requesting", + waiting: "waiting", + receiving: "receiving", + stopping: "stopping", + stopped: "stopped" +}; class FormSubmission { + state=FormSubmissionState.initialized; static confirmMethod(message, _element, _submitter) { return Promise.resolve(confirm(message)); } constructor(delegate, formElement, submitter, mustRedirect = false) { - this.state = FormSubmissionState.initialized; + const method = getMethod(formElement, submitter); + const action = getAction(getFormAction(formElement, submitter), method); + const body = buildFormData(formElement, submitter); + const enctype = getEnctype(formElement, submitter); this.delegate = delegate; this.formElement = formElement; this.submitter = submitter; - this.formData = buildFormData(formElement, submitter); - this.location = expandURL(this.action); - if (this.method == FetchMethod.get) { - mergeFormDataEntries(this.location, [ ...this.body.entries() ]); - } - this.fetchRequest = new FetchRequest(this, this.method, this.location, this.body, this.formElement); + this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype); this.mustRedirect = mustRedirect; } get method() { - var _a; - const method = ((_a = this.submitter) === null || _a === void 0 ? void 0 : _a.getAttribute("formmethod")) || this.formElement.getAttribute("method") || ""; - return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get; + return this.fetchRequest.method; + } + set method(value) { + this.fetchRequest.method = value; } get action() { - var _a; - const formElementAction = typeof this.formElement.action === "string" ? this.formElement.action : null; - if ((_a = this.submitter) === null || _a === void 0 ? void 0 : _a.hasAttribute("formaction")) { - return this.submitter.getAttribute("formaction") || ""; - } else { - return this.formElement.getAttribute("action") || formElementAction || ""; - } + return this.fetchRequest.url.toString(); + } + set action(value) { + this.fetchRequest.url = expandURL(value); } get body() { - if (this.enctype == FormEnctype.urlEncoded || this.method == FetchMethod.get) { - return new URLSearchParams(this.stringFormData); - } else { - return this.formData; - } + return this.fetchRequest.body; } get enctype() { - var _a; - return formEnctypeFromString(((_a = this.submitter) === null || _a === void 0 ? void 0 : _a.getAttribute("formenctype")) || this.formElement.enctype); + return this.fetchRequest.enctype; } get isSafe() { return this.fetchRequest.isSafe; } - get stringFormData() { - return [ ...this.formData ].reduce(((entries, [name, value]) => entries.concat(typeof value == "string" ? [ [ name, value ] ] : [])), []); + get location() { + return this.fetchRequest.url; } async start() { const {initialized: initialized, requesting: requesting} = FormSubmissionState; @@ -770,9 +837,8 @@ class FormSubmission { } } requestStarted(_request) { - var _a; this.state = FormSubmissionState.waiting; - (_a = this.submitter) === null || _a === void 0 ? void 0 : _a.setAttribute("disabled", ""); + this.submitter?.setAttribute("disabled", ""); this.setSubmitsWith(); dispatch("turbo:submit-start", { target: this.formElement, @@ -818,15 +884,15 @@ class FormSubmission { this.delegate.formSubmissionErrored(this, error); } requestFinished(_request) { - var _a; this.state = FormSubmissionState.stopped; - (_a = this.submitter) === null || _a === void 0 ? void 0 : _a.removeAttribute("disabled"); + this.submitter?.removeAttribute("disabled"); this.resetSubmitterText(); dispatch("turbo:submit-end", { target: this.formElement, - detail: Object.assign({ - formSubmission: this - }, this.result) + detail: { + formSubmission: this, + ...this.result + } }); this.delegate.formSubmissionFinished(this); } @@ -857,15 +923,14 @@ class FormSubmission { return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement); } get submitsWith() { - var _a; - return (_a = this.submitter) === null || _a === void 0 ? void 0 : _a.getAttribute("data-turbo-submits-with"); + return this.submitter?.getAttribute("data-turbo-submits-with"); } } function buildFormData(formElement, submitter) { const formData = new FormData(formElement); - const name = submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("name"); - const value = submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("value"); + const name = submitter?.getAttribute("name"); + const value = submitter?.getAttribute("value"); if (name) { formData.append(name, value || ""); } @@ -887,14 +952,30 @@ function responseSucceededWithoutRedirect(response) { return response.statusCode == 200 && !response.redirected; } -function mergeFormDataEntries(url, entries) { - const searchParams = new URLSearchParams; - for (const [name, value] of entries) { - if (value instanceof File) continue; - searchParams.append(name, value); +function getFormAction(formElement, submitter) { + const formElementAction = typeof formElement.action === "string" ? formElement.action : null; + if (submitter?.hasAttribute("formaction")) { + return submitter.getAttribute("formaction") || ""; + } else { + return formElement.getAttribute("action") || formElementAction || ""; } - url.search = searchParams.toString(); - return url; +} + +function getAction(formAction, fetchMethod) { + const action = expandURL(formAction); + if (isSafe(fetchMethod)) { + action.search = ""; + } + return action; +} + +function getMethod(formElement, submitter) { + const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || ""; + return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get; +} + +function getEnctype(formElement, submitter) { + return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype); } class Snapshot { @@ -917,11 +998,7 @@ class Snapshot { return this.element.isConnected; } get firstAutofocusableElement() { - const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])"; - for (const element of this.element.querySelectorAll("[autofocus]")) { - if (element.closest(inertDisabledOrHidden) == null) return element; else continue; - } - return null; + return queryAutofocusableElement(this.element); } get permanentElements() { return queryPermanentElementsAll(this.element); @@ -951,23 +1028,8 @@ function queryPermanentElementsAll(node) { } class FormSubmitObserver { + started=false; constructor(delegate, eventTarget) { - this.started = false; - this.submitCaptured = () => { - this.eventTarget.removeEventListener("submit", this.submitBubbled, false); - this.eventTarget.addEventListener("submit", this.submitBubbled, false); - }; - this.submitBubbled = event => { - if (!event.defaultPrevented) { - const form = event.target instanceof HTMLFormElement ? event.target : undefined; - const submitter = event.submitter || undefined; - if (form && submissionDoesNotDismissDialog(form, submitter) && submissionDoesNotTargetIFrame(form, submitter) && this.delegate.willSubmitForm(form, submitter)) { - event.preventDefault(); - event.stopImmediatePropagation(); - this.delegate.formSubmitted(form, submitter); - } - } - }; this.delegate = delegate; this.eventTarget = eventTarget; } @@ -983,16 +1045,31 @@ class FormSubmitObserver { this.started = false; } } + submitCaptured=() => { + this.eventTarget.removeEventListener("submit", this.submitBubbled, false); + this.eventTarget.addEventListener("submit", this.submitBubbled, false); + }; + submitBubbled=event => { + if (!event.defaultPrevented) { + const form = event.target instanceof HTMLFormElement ? event.target : undefined; + const submitter = event.submitter || undefined; + if (form && submissionDoesNotDismissDialog(form, submitter) && submissionDoesNotTargetIFrame(form, submitter) && this.delegate.willSubmitForm(form, submitter)) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.delegate.formSubmitted(form, submitter); + } + } + }; } function submissionDoesNotDismissDialog(form, submitter) { - const method = (submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("formmethod")) || form.getAttribute("method"); + const method = submitter?.getAttribute("formmethod") || form.getAttribute("method"); return method != "dialog"; } function submissionDoesNotTargetIFrame(form, submitter) { - if ((submitter === null || submitter === void 0 ? void 0 : submitter.hasAttribute("formtarget")) || form.hasAttribute("target")) { - const target = (submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("formtarget")) || form.target; + if (submitter?.hasAttribute("formtarget") || form.hasAttribute("target")) { + const target = submitter?.getAttribute("formtarget") || form.target; for (const element of document.getElementsByName(target)) { if (element instanceof HTMLIFrameElement) return false; } @@ -1003,9 +1080,9 @@ function submissionDoesNotTargetIFrame(form, submitter) { } class View { + #resolveRenderPromise=_value => {}; + #resolveInterceptionPromise=_value => {}; constructor(delegate, element) { - this.resolveRenderPromise = _value => {}; - this.resolveInterceptionPromise = _value => {}; this.delegate = delegate; this.element = element; } @@ -1054,23 +1131,23 @@ class View { const {isPreview: isPreview, shouldRender: shouldRender, newSnapshot: snapshot} = renderer; if (shouldRender) { try { - this.renderPromise = new Promise((resolve => this.resolveRenderPromise = resolve)); + this.renderPromise = new Promise((resolve => this.#resolveRenderPromise = resolve)); this.renderer = renderer; await this.prepareToRenderSnapshot(renderer); - const renderInterception = new Promise((resolve => this.resolveInterceptionPromise = resolve)); + const renderInterception = new Promise((resolve => this.#resolveInterceptionPromise = resolve)); const options = { - resume: this.resolveInterceptionPromise, + resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement }; - const immediateRender = this.delegate.allowsImmediateRender(snapshot, options); + const immediateRender = this.delegate.allowsImmediateRender(snapshot, isPreview, options); if (!immediateRender) await renderInterception; await this.renderSnapshot(renderer); - this.delegate.viewRenderedSnapshot(snapshot, isPreview); + this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod); this.delegate.preloadOnLoadLinksForView(this.element); this.finishRenderingSnapshot(renderer); } finally { delete this.renderer; - this.resolveRenderPromise(undefined); + this.#resolveRenderPromise(undefined); delete this.renderPromise; } } else { @@ -1110,26 +1187,6 @@ class FrameView extends View { class LinkInterceptor { constructor(delegate, element) { - this.clickBubbled = event => { - if (this.respondsToEventTarget(event.target)) { - this.clickEvent = event; - } else { - delete this.clickEvent; - } - }; - this.linkClicked = event => { - if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) { - if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) { - this.clickEvent.preventDefault(); - event.preventDefault(); - this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent); - } - } - delete this.clickEvent; - }; - this.willVisit = _event => { - delete this.clickEvent; - }; this.delegate = delegate; this.element = element; } @@ -1143,6 +1200,26 @@ class LinkInterceptor { document.removeEventListener("turbo:click", this.linkClicked); document.removeEventListener("turbo:before-visit", this.willVisit); } + clickBubbled=event => { + if (this.respondsToEventTarget(event.target)) { + this.clickEvent = event; + } else { + delete this.clickEvent; + } + }; + linkClicked=event => { + if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) { + if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) { + this.clickEvent.preventDefault(); + event.preventDefault(); + this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent); + } + } + delete this.clickEvent; + }; + willVisit=_event => { + delete this.clickEvent; + }; respondsToEventTarget(target) { const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; return element && element.closest("turbo-frame, html") == this.element; @@ -1150,25 +1227,8 @@ class LinkInterceptor { } class LinkClickObserver { + started=false; constructor(delegate, eventTarget) { - this.started = false; - this.clickCaptured = () => { - this.eventTarget.removeEventListener("click", this.clickBubbled, false); - this.eventTarget.addEventListener("click", this.clickBubbled, false); - }; - this.clickBubbled = event => { - if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { - const target = event.composedPath && event.composedPath()[0] || event.target; - const link = this.findLinkFromClickTarget(target); - if (link && doesNotTargetIFrame(link)) { - const location = this.getLocationForLink(link); - if (this.delegate.willFollowLinkToLocation(link, location, event)) { - event.preventDefault(); - this.delegate.followedLinkToLocation(link, location); - } - } - } - }; this.delegate = delegate; this.eventTarget = eventTarget; } @@ -1184,6 +1244,23 @@ class LinkClickObserver { this.started = false; } } + clickCaptured=() => { + this.eventTarget.removeEventListener("click", this.clickBubbled, false); + this.eventTarget.addEventListener("click", this.clickBubbled, false); + }; + clickBubbled=event => { + if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { + const target = event.composedPath && event.composedPath()[0] || event.target; + const link = this.findLinkFromClickTarget(target); + if (link && doesNotTargetIFrame(link)) { + const location = this.getLocationForLink(link); + if (this.delegate.willFollowLinkToLocation(link, location, event)) { + event.preventDefault(); + this.delegate.followedLinkToLocation(link, location); + } + } + } + }; clickEventIsSignificant(event) { return !(event.target && event.target.isContentEditable || event.defaultPrevented || event.which > 1 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey); } @@ -1218,7 +1295,7 @@ class FormLinkClickObserver { this.linkInterceptor.stop(); } willFollowLinkToLocation(link, location, originalEvent) { - return this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) && link.hasAttribute("data-turbo-method"); + return this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) && (link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream")); } followedLinkToLocation(link, location) { const form = document.createElement("form"); @@ -1291,7 +1368,7 @@ class Bardo { } replacePlaceholderWithPermanentElement(permanentElement) { const placeholder = this.getPlaceholderById(permanentElement.id); - placeholder === null || placeholder === void 0 ? void 0 : placeholder.replaceWith(permanentElement); + placeholder?.replaceWith(permanentElement); } getPlaceholderById(id) { return this.placeholders.find((element => element.content == id)); @@ -1309,8 +1386,8 @@ function createPlaceholderForPermanentElement(permanentElement) { } class Renderer { + #activeElement=null; constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { - this.activeElement = null; this.currentSnapshot = currentSnapshot; this.newSnapshot = newSnapshot; this.isPreview = isPreview; @@ -1330,6 +1407,7 @@ class Renderer { prepareToRender() { return; } + render() {} finishRendering() { if (this.resolvingFunctions) { this.resolvingFunctions.resolve(); @@ -1341,20 +1419,20 @@ class Renderer { } focusFirstAutofocusableElement() { const element = this.connectedSnapshot.firstAutofocusableElement; - if (elementIsFocusable(element)) { + if (element) { element.focus(); } } enteringBardo(currentPermanentElement) { - if (this.activeElement) return; + if (this.#activeElement) return; if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { - this.activeElement = this.currentSnapshot.activeElement; + this.#activeElement = this.currentSnapshot.activeElement; } } leavingBardo(currentPermanentElement) { - if (currentPermanentElement.contains(this.activeElement) && this.activeElement instanceof HTMLElement) { - this.activeElement.focus(); - this.activeElement = null; + if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) { + this.#activeElement.focus(); + this.#activeElement = null; } } get connectedSnapshot() { @@ -1369,20 +1447,18 @@ class Renderer { get permanentElementMap() { return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot); } -} - -function elementIsFocusable(element) { - return element && typeof element.focus == "function"; + get renderMethod() { + return "replace"; + } } class FrameRenderer extends Renderer { static renderElement(currentElement, newElement) { - var _a; const destinationRange = document.createRange(); destinationRange.selectNodeContents(currentElement); destinationRange.deleteContents(); const frameElement = newElement; - const sourceRange = (_a = frameElement.ownerDocument) === null || _a === void 0 ? void 0 : _a.createRange(); + const sourceRange = frameElement.ownerDocument?.createRange(); if (sourceRange) { sourceRange.selectNodeContents(frameElement); currentElement.appendChild(sourceRange.extractContents()); @@ -1453,6 +1529,7 @@ function readScrollBehavior(value, defaultValue) { } class ProgressBar { + static animationDuration=300; static get defaultCSS() { return unindent` .turbo-progress-bar { @@ -1470,13 +1547,10 @@ class ProgressBar { } `; } + hiding=false; + value=0; + visible=false; constructor() { - this.hiding = false; - this.value = 0; - this.visible = false; - this.trickle = () => { - this.setValue(this.value + Math.random() / 100); - }; this.stylesheetElement = this.createStylesheetElement(); this.progressElement = this.createProgressElement(); this.installStylesheetElement(); @@ -1531,6 +1605,9 @@ class ProgressBar { window.clearInterval(this.trickleInterval); delete this.trickleInterval; } + trickle=() => { + this.setValue(this.value + Math.random() / 100); + }; refresh() { requestAnimationFrame((() => { this.progressElement.style.width = `${10 + this.value * 90}%`; @@ -1555,25 +1632,22 @@ class ProgressBar { } } -ProgressBar.animationDuration = 300; - class HeadSnapshot extends Snapshot { - constructor() { - super(...arguments); - this.detailsByOuterHTML = this.children.filter((element => !elementIsNoscript(element))).map((element => elementWithoutNonce(element))).reduce(((result, element) => { - const {outerHTML: outerHTML} = element; - const details = outerHTML in result ? result[outerHTML] : { - type: elementType(element), - tracked: elementIsTracked(element), - elements: [] - }; - return Object.assign(Object.assign({}, result), { - [outerHTML]: Object.assign(Object.assign({}, details), { - elements: [ ...details.elements, element ] - }) - }); - }), {}); - } + detailsByOuterHTML=this.children.filter((element => !elementIsNoscript(element))).map((element => elementWithoutNonce(element))).reduce(((result, element) => { + const {outerHTML: outerHTML} = element; + const details = outerHTML in result ? result[outerHTML] : { + type: elementType(element), + tracked: elementIsTracked(element), + elements: [] + }; + return { + ...result, + [outerHTML]: { + ...details, + elements: [ ...details.elements, element ] + } + }; + }), {}); get trackedElementSignature() { return Object.keys(this.detailsByOuterHTML).filter((outerHTML => this.detailsByOuterHTML[outerHTML].tracked)).join(""); } @@ -1606,7 +1680,7 @@ class HeadSnapshot extends Snapshot { return Object.keys(this.detailsByOuterHTML).reduce(((result, outerHTML) => { const {elements: [element]} = this.detailsByOuterHTML[outerHTML]; return elementIsMetaElementWithName(element, name) ? element : result; - }), undefined); + }), undefined | undefined); } } @@ -1656,11 +1730,12 @@ class PageSnapshot extends Snapshot { static fromElement(element) { return this.fromDocument(element.ownerDocument); } - static fromDocument({head: head, body: body}) { - return new this(body, new HeadSnapshot(head)); + static fromDocument({documentElement: documentElement, body: body, head: head}) { + return new this(documentElement, body, new HeadSnapshot(head)); } - constructor(element, headSnapshot) { - super(element); + constructor(documentElement, body, headSnapshot) { + super(body); + this.documentElement = documentElement; this.headSnapshot = headSnapshot; } clone() { @@ -1675,14 +1750,19 @@ class PageSnapshot extends Snapshot { for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) { clonedPasswordInput.value = ""; } - return new PageSnapshot(clonedElement, this.headSnapshot); + return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot); + } + get lang() { + return this.documentElement.getAttribute("lang"); + } + get html() { + return `${this.headElement.outerHTML}\n\n${this.element.outerHTML}`; } get headElement() { return this.headSnapshot.element; } get rootLocation() { - var _a; - const root = (_a = this.getSetting("root")) !== null && _a !== void 0 ? _a : "/"; + const root = this.getSetting("root") ?? "/"; return expandURL(root); } get cacheControlValue() { @@ -1697,29 +1777,38 @@ class PageSnapshot extends Snapshot { get isVisitable() { return this.getSetting("visit-control") != "reload"; } + get prefersViewTransitions() { + return this.headSnapshot.getMetaValue("view-transition") === "same-origin"; + } + get shouldMorphPage() { + return this.getSetting("refresh-method") === "morph"; + } + get shouldPreserveScrollPosition() { + return this.getSetting("refresh-scroll") === "preserve"; + } getSetting(name) { return this.headSnapshot.getMetaValue(`turbo-${name}`); } } -var TimingMetric; - -(function(TimingMetric) { - TimingMetric["visitStart"] = "visitStart"; - TimingMetric["requestStart"] = "requestStart"; - TimingMetric["requestEnd"] = "requestEnd"; - TimingMetric["visitEnd"] = "visitEnd"; -})(TimingMetric || (TimingMetric = {})); - -var VisitState; - -(function(VisitState) { - VisitState["initialized"] = "initialized"; - VisitState["started"] = "started"; - VisitState["canceled"] = "canceled"; - VisitState["failed"] = "failed"; - VisitState["completed"] = "completed"; -})(VisitState || (VisitState = {})); +class ViewTransitioner { + #viewTransitionStarted=false; + #lastOperation=Promise.resolve(); + renderChange(useViewTransition, render) { + if (useViewTransition && this.viewTransitionsAvailable && !this.#viewTransitionStarted) { + this.#viewTransitionStarted = true; + this.#lastOperation = this.#lastOperation.then((async () => { + await document.startViewTransition(render).finished; + })); + } else { + this.#lastOperation = this.#lastOperation.then(render); + } + return this.#lastOperation; + } + get viewTransitionsAvailable() { + return document.startViewTransition; + } +} const defaultOptions = { action: "advance", @@ -1731,29 +1820,46 @@ const defaultOptions = { acceptsStreamResponse: false }; -var SystemStatusCode; +const TimingMetric = { + visitStart: "visitStart", + requestStart: "requestStart", + requestEnd: "requestEnd", + visitEnd: "visitEnd" +}; + +const VisitState = { + initialized: "initialized", + started: "started", + canceled: "canceled", + failed: "failed", + completed: "completed" +}; -(function(SystemStatusCode) { - SystemStatusCode[SystemStatusCode["networkFailure"] = 0] = "networkFailure"; - SystemStatusCode[SystemStatusCode["timeoutFailure"] = -1] = "timeoutFailure"; - SystemStatusCode[SystemStatusCode["contentTypeMismatch"] = -2] = "contentTypeMismatch"; -})(SystemStatusCode || (SystemStatusCode = {})); +const SystemStatusCode = { + networkFailure: 0, + timeoutFailure: -1, + contentTypeMismatch: -2 +}; class Visit { + identifier=uuid(); + timingMetrics={}; + followedRedirect=false; + historyChanged=false; + scrolled=false; + shouldCacheSnapshot=true; + acceptsStreamResponse=false; + snapshotCached=false; + state=VisitState.initialized; + viewTransitioner=new ViewTransitioner; constructor(delegate, location, restorationIdentifier, options = {}) { - this.identifier = uuid(); - this.timingMetrics = {}; - this.followedRedirect = false; - this.historyChanged = false; - this.scrolled = false; - this.shouldCacheSnapshot = true; - this.acceptsStreamResponse = false; - this.snapshotCached = false; - this.state = VisitState.initialized; this.delegate = delegate; this.location = location; this.restorationIdentifier = restorationIdentifier || uuid(); - const {action: action, historyChanged: historyChanged, referrer: referrer, snapshot: snapshot, snapshotHTML: snapshotHTML, response: response, visitCachedSnapshot: visitCachedSnapshot, willRender: willRender, updateHistory: updateHistory, shouldCacheSnapshot: shouldCacheSnapshot, acceptsStreamResponse: acceptsStreamResponse} = Object.assign(Object.assign({}, defaultOptions), options); + const {action: action, historyChanged: historyChanged, referrer: referrer, snapshot: snapshot, snapshotHTML: snapshotHTML, response: response, visitCachedSnapshot: visitCachedSnapshot, willRender: willRender, updateHistory: updateHistory, shouldCacheSnapshot: shouldCacheSnapshot, acceptsStreamResponse: acceptsStreamResponse} = { + ...defaultOptions, + ...options + }; this.action = action; this.historyChanged = historyChanged; this.referrer = referrer; @@ -1815,21 +1921,21 @@ class Visit { if (this.state == VisitState.started) { this.state = VisitState.failed; this.adapter.visitFailed(this); + this.delegate.visitCompleted(this); } } changeHistory() { - var _a; if (!this.historyChanged && this.updateHistory) { - const actionForHistory = this.location.href === ((_a = this.referrer) === null || _a === void 0 ? void 0 : _a.href) ? "replace" : this.action; + const actionForHistory = this.location.href === this.referrer?.href ? "replace" : this.action; const method = getHistoryMethodForAction(actionForHistory); this.history.update(method, this.location, this.restorationIdentifier); this.historyChanged = true; } } - issueRequest() { + async issueRequest() { if (this.hasPreloadedResponse()) { this.simulateRequest(); - } else if (this.shouldIssueRequest() && !this.request) { + } else if (!this.request && await this.shouldIssueRequest()) { this.request = new FetchRequest(this, FetchMethod.get, this.location); this.request.perform(); } @@ -1867,8 +1973,8 @@ class Visit { if (this.shouldCacheSnapshot) this.cacheSnapshot(); if (this.view.renderPromise) await this.view.renderPromise; if (isSuccessful(statusCode) && responseHTML != null) { - await this.view.renderPage(PageSnapshot.fromHTMLString(responseHTML), false, this.willRender, this); - this.performScroll(); + const snapshot = PageSnapshot.fromHTMLString(responseHTML); + await this.renderPageSnapshot(snapshot, false); this.adapter.visitRendered(this); this.complete(); } else { @@ -1879,8 +1985,8 @@ class Visit { })); } } - getCachedSnapshot() { - const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot(); + async getCachedSnapshot() { + const snapshot = await this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot(); if (snapshot && (!getAnchor(this.location) || snapshot.hasAnchor(getAnchor(this.location)))) { if (this.action == "restore" || snapshot.isPreviewable) { return snapshot; @@ -1892,21 +1998,20 @@ class Visit { return PageSnapshot.fromHTMLString(this.snapshotHTML); } } - hasCachedSnapshot() { - return this.getCachedSnapshot() != null; + async hasCachedSnapshot() { + return await this.getCachedSnapshot() != null; } - loadCachedSnapshot() { - const snapshot = this.getCachedSnapshot(); + async loadCachedSnapshot() { + const snapshot = await this.getCachedSnapshot(); if (snapshot) { - const isPreview = this.shouldIssueRequest(); + const isPreview = await this.shouldIssueRequest(); this.render((async () => { this.cacheSnapshot(); if (this.isSamePage) { this.adapter.visitRendered(this); } else { if (this.view.renderPromise) await this.view.renderPromise; - await this.view.renderPage(snapshot, isPreview, this.willRender, this); - this.performScroll(); + await this.renderPageSnapshot(snapshot, isPreview); this.adapter.visitRendered(this); if (!isPreview) { this.complete(); @@ -1916,8 +2021,7 @@ class Visit { } } followRedirect() { - var _a; - if (this.redirectedToLocation && !this.followedRedirect && ((_a = this.response) === null || _a === void 0 ? void 0 : _a.redirected)) { + if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) { this.adapter.visitProposedToLocation(this.redirectedToLocation, { action: "replace", response: this.response, @@ -1989,7 +2093,7 @@ class Visit { this.finishRequest(); } performScroll() { - if (!this.scrolled && !this.view.forceReloaded) { + if (!this.scrolled && !this.view.forceReloaded && !this.view.snapshot.shouldPreserveScrollPosition) { if (this.action == "restore") { this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop(); } else { @@ -2019,7 +2123,9 @@ class Visit { this.timingMetrics[metric] = (new Date).getTime(); } getTimingMetrics() { - return Object.assign({}, this.timingMetrics); + return { + ...this.timingMetrics + }; } getHistoryMethodForAction(action) { switch (action) { @@ -2034,11 +2140,11 @@ class Visit { hasPreloadedResponse() { return typeof this.response == "object"; } - shouldIssueRequest() { + async shouldIssueRequest() { if (this.isSamePage) { return false; - } else if (this.action == "restore") { - return !this.hasCachedSnapshot(); + } else if (this.action === "restore") { + return !await this.hasCachedSnapshot(); } else { return this.willRender; } @@ -2057,6 +2163,12 @@ class Visit { await callback(); delete this.frame; } + async renderPageSnapshot(snapshot, isPreview) { + await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), (async () => { + await this.view.renderPage(snapshot, isPreview, this.willRender, this); + this.performScroll(); + })); + } cancelRender() { if (this.frame) { cancelAnimationFrame(this.frame); @@ -2070,15 +2182,12 @@ function isSuccessful(statusCode) { } class BrowserAdapter { + progressBar=new ProgressBar; constructor(session) { - this.progressBar = new ProgressBar; - this.showProgressBar = () => { - this.progressBar.show(); - }; this.session = session; } visitProposedToLocation(location, options) { - this.navigator.startVisit(location, (options === null || options === void 0 ? void 0 : options.restorationIdentifier) || uuid(), options); + this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options); } visitStarted(visit) { this.location = visit.location; @@ -2088,11 +2197,7 @@ class BrowserAdapter { } visitRequestStarted(visit) { this.progressBar.setValue(0); - if (visit.hasCachedSnapshot() || visit.action != "restore") { - this.showVisitProgressBarAfterDelay(); - } else { - this.showProgressBar(); - } + this.showVisitProgressBarAfterDelay(); } visitRequestCompleted(visit) { visit.loadResponse(); @@ -2153,12 +2258,14 @@ class BrowserAdapter { delete this.formProgressBarTimeout; } } + showProgressBar=() => { + this.progressBar.show(); + }; reload(reason) { - var _a; dispatch("turbo:reload", { detail: reason }); - window.location.href = ((_a = this.location) === null || _a === void 0 ? void 0 : _a.toString()) || window.location.href; + window.location.href = this.location?.toString() || window.location.href; } get navigator() { return this.session.navigator; @@ -2166,16 +2273,9 @@ class BrowserAdapter { } class CacheObserver { - constructor() { - this.selector = "[data-turbo-temporary]"; - this.deprecatedSelector = "[data-turbo-cache=false]"; - this.started = false; - this.removeTemporaryElements = _event => { - for (const element of this.temporaryElements) { - element.remove(); - } - }; - } + selector="[data-turbo-temporary]"; + deprecatedSelector="[data-turbo-cache=false]"; + started=false; start() { if (!this.started) { this.started = true; @@ -2188,6 +2288,11 @@ class CacheObserver { removeEventListener("turbo:before-cache", this.removeTemporaryElements, false); } } + removeTemporaryElements=_event => { + for (const element of this.temporaryElements) { + element.remove(); + } + }; get temporaryElements() { return [ ...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation ]; } @@ -2216,41 +2321,40 @@ class FrameRedirector { this.formSubmitObserver.stop(); } shouldInterceptLinkClick(element, _location, _event) { - return this.shouldRedirect(element); + return this.#shouldRedirect(element); } linkClickIntercepted(element, url, event) { - const frame = this.findFrameElement(element); + const frame = this.#findFrameElement(element); if (frame) { frame.delegate.linkClickIntercepted(element, url, event); } } willSubmitForm(element, submitter) { - return element.closest("turbo-frame") == null && this.shouldSubmit(element, submitter) && this.shouldRedirect(element, submitter); + return element.closest("turbo-frame") == null && this.#shouldSubmit(element, submitter) && this.#shouldRedirect(element, submitter); } formSubmitted(element, submitter) { - const frame = this.findFrameElement(element, submitter); + const frame = this.#findFrameElement(element, submitter); if (frame) { frame.delegate.formSubmitted(element, submitter); } } - shouldSubmit(form, submitter) { - var _a; - const action = getAction(form, submitter); + #shouldSubmit(form, submitter) { + const action = getAction$1(form, submitter); const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); - const rootLocation = expandURL((_a = meta === null || meta === void 0 ? void 0 : meta.content) !== null && _a !== void 0 ? _a : "/"); - return this.shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation); + const rootLocation = expandURL(meta?.content ?? "/"); + return this.#shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation); } - shouldRedirect(element, submitter) { + #shouldRedirect(element, submitter) { const isNavigatable = element instanceof HTMLFormElement ? this.session.submissionIsNavigatable(element, submitter) : this.session.elementIsNavigatable(element); if (isNavigatable) { - const frame = this.findFrameElement(element, submitter); + const frame = this.#findFrameElement(element, submitter); return frame ? frame != element.closest("turbo-frame") : false; } else { return false; } } - findFrameElement(element, submitter) { - const id = (submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("data-turbo-frame")) || element.getAttribute("data-turbo-frame"); + #findFrameElement(element, submitter) { + const id = submitter?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame"); if (id && id != "_top") { const frame = this.element.querySelector(`#${id}:not([disabled])`); if (frame instanceof FrameElement) { @@ -2261,26 +2365,12 @@ class FrameRedirector { } class History { + location; + restorationIdentifier=uuid(); + restorationData={}; + started=false; + pageLoaded=false; constructor(delegate) { - this.restorationIdentifier = uuid(); - this.restorationData = {}; - this.started = false; - this.pageLoaded = false; - this.onPopState = event => { - if (this.shouldHandlePopState()) { - const {turbo: turbo} = event.state || {}; - if (turbo) { - this.location = new URL(window.location.href); - const {restorationIdentifier: restorationIdentifier} = turbo; - this.restorationIdentifier = restorationIdentifier; - this.delegate.historyPoppedToLocationWithRestorationIdentifier(this.location, restorationIdentifier); - } - } - }; - this.onPageLoad = async _event => { - await nextMicrotask(); - this.pageLoaded = true; - }; this.delegate = delegate; } start() { @@ -2320,12 +2410,14 @@ class History { updateRestorationData(additionalData) { const {restorationIdentifier: restorationIdentifier} = this; const restorationData = this.restorationData[restorationIdentifier]; - this.restorationData[restorationIdentifier] = Object.assign(Object.assign({}, restorationData), additionalData); + this.restorationData[restorationIdentifier] = { + ...restorationData, + ...additionalData + }; } assumeControlOfScrollRestoration() { - var _a; if (!this.previousScrollRestoration) { - this.previousScrollRestoration = (_a = history.scrollRestoration) !== null && _a !== void 0 ? _a : "auto"; + this.previousScrollRestoration = history.scrollRestoration ?? "auto"; history.scrollRestoration = "manual"; } } @@ -2335,6 +2427,21 @@ class History { delete this.previousScrollRestoration; } } + onPopState=event => { + if (this.shouldHandlePopState()) { + const {turbo: turbo} = event.state || {}; + if (turbo) { + this.location = new URL(window.location.href); + const {restorationIdentifier: restorationIdentifier} = turbo; + this.restorationIdentifier = restorationIdentifier; + this.delegate.historyPoppedToLocationWithRestorationIdentifier(this.location, restorationIdentifier); + } + } + }; + onPageLoad=async _event => { + await nextMicrotask(); + this.pageLoaded = true; + }; shouldHandlePopState() { return this.pageIsLoaded(); } @@ -2358,9 +2465,10 @@ class Navigator { } startVisit(locatable, restorationIdentifier, options = {}) { this.stop(); - this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, Object.assign({ - referrer: this.location - }, options)); + this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, { + referrer: this.location, + ...options + }); this.currentVisit.start(); } submitForm(form, submitter) { @@ -2462,30 +2570,17 @@ class Navigator { } } -var PageStage; - -(function(PageStage) { - PageStage[PageStage["initial"] = 0] = "initial"; - PageStage[PageStage["loading"] = 1] = "loading"; - PageStage[PageStage["interactive"] = 2] = "interactive"; - PageStage[PageStage["complete"] = 3] = "complete"; -})(PageStage || (PageStage = {})); +const PageStage = { + initial: 0, + loading: 1, + interactive: 2, + complete: 3 +}; class PageObserver { + stage=PageStage.initial; + started=false; constructor(delegate) { - this.stage = PageStage.initial; - this.started = false; - this.interpretReadyState = () => { - const {readyState: readyState} = this; - if (readyState == "interactive") { - this.pageIsInteractive(); - } else if (readyState == "complete") { - this.pageIsComplete(); - } - }; - this.pageWillUnload = () => { - this.delegate.pageWillUnload(); - }; this.delegate = delegate; } start() { @@ -2505,6 +2600,14 @@ class PageObserver { this.started = false; } } + interpretReadyState=() => { + const {readyState: readyState} = this; + if (readyState == "interactive") { + this.pageIsInteractive(); + } else if (readyState == "complete") { + this.pageIsComplete(); + } + }; pageIsInteractive() { if (this.stage == PageStage.loading) { this.stage = PageStage.interactive; @@ -2518,20 +2621,17 @@ class PageObserver { this.delegate.pageLoaded(); } } + pageWillUnload=() => { + this.delegate.pageWillUnload(); + }; get readyState() { return document.readyState; } } class ScrollObserver { + started=false; constructor(delegate) { - this.started = false; - this.onScroll = () => { - this.updatePosition({ - x: window.pageXOffset, - y: window.pageYOffset - }); - }; this.delegate = delegate; } start() { @@ -2547,6 +2647,12 @@ class ScrollObserver { this.started = false; } } + onScroll=() => { + this.updatePosition({ + x: window.pageXOffset, + y: window.pageYOffset + }); + }; updatePosition(position) { this.delegate.scrollPositionChanged(position); } @@ -2554,7 +2660,13 @@ class ScrollObserver { class StreamMessageRenderer { render({fragment: fragment}) { - Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), (() => document.documentElement.appendChild(fragment))); + Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), (() => { + withAutofocusFromFragment(fragment, (() => { + withPreservedFocus((() => { + document.documentElement.appendChild(fragment); + })); + })); + })); } enteringBardo(currentPermanentElement, newPermanentElement) { newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true)); @@ -2577,33 +2689,67 @@ function getPermanentElementMapForFragment(fragment) { return permanentElementMap; } +async function withAutofocusFromFragment(fragment, callback) { + const generatedID = `turbo-stream-autofocus-${uuid()}`; + const turboStreams = fragment.querySelectorAll("turbo-stream"); + const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams); + let willAutofocusId = null; + if (elementWithAutofocus) { + if (elementWithAutofocus.id) { + willAutofocusId = elementWithAutofocus.id; + } else { + willAutofocusId = generatedID; + } + elementWithAutofocus.id = willAutofocusId; + } + callback(); + await nextAnimationFrame(); + const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body; + if (hasNoActiveElement && willAutofocusId) { + const elementToAutofocus = document.getElementById(willAutofocusId); + if (elementIsFocusable(elementToAutofocus)) { + elementToAutofocus.focus(); + } + if (elementToAutofocus && elementToAutofocus.id == generatedID) { + elementToAutofocus.removeAttribute("id"); + } + } +} + +async function withPreservedFocus(callback) { + const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, (() => document.activeElement)); + const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id; + if (restoreFocusTo) { + const elementToFocus = document.getElementById(restoreFocusTo); + if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) { + elementToFocus.focus(); + } + } +} + +function firstAutofocusableElementInStreams(nodeListOfStreamElements) { + for (const streamElement of nodeListOfStreamElements) { + const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content); + if (elementWithAutofocus) return elementWithAutofocus; + } + return null; +} + class StreamObserver { + sources=new Set; + #started=false; constructor(delegate) { - this.sources = new Set; - this.started = false; - this.inspectFetchResponse = event => { - const response = fetchResponseFromEvent(event); - if (response && fetchResponseIsStream(response)) { - event.preventDefault(); - this.receiveMessageResponse(response); - } - }; - this.receiveMessageEvent = event => { - if (this.started && typeof event.data == "string") { - this.receiveMessageHTML(event.data); - } - }; this.delegate = delegate; } start() { - if (!this.started) { - this.started = true; + if (!this.#started) { + this.#started = true; addEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); } } stop() { - if (this.started) { - this.started = false; + if (this.#started) { + this.#started = false; removeEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); } } @@ -2622,6 +2768,18 @@ class StreamObserver { streamSourceIsConnected(source) { return this.sources.has(source); } + inspectFetchResponse=event => { + const response = fetchResponseFromEvent(event); + if (response && fetchResponseIsStream(response)) { + event.preventDefault(); + this.receiveMessageResponse(response); + } + }; + receiveMessageEvent=event => { + if (this.#started && typeof event.data == "string") { + this.receiveMessageHTML(event.data); + } + }; async receiveMessageResponse(response) { const html = await response.responseHTML; if (html) { @@ -2634,16 +2792,14 @@ class StreamObserver { } function fetchResponseFromEvent(event) { - var _a; - const fetchResponse = (_a = event.detail) === null || _a === void 0 ? void 0 : _a.fetchResponse; + const fetchResponse = event.detail?.fetchResponse; if (fetchResponse instanceof FetchResponse) { return fetchResponse; } } function fetchResponseIsStream(response) { - var _a; - const contentType = (_a = response.contentType) !== null && _a !== void 0 ? _a : ""; + const contentType = response.contentType ?? ""; return contentType.startsWith(StreamMessage.contentType); } @@ -2678,7 +2834,583 @@ class ErrorRenderer extends Renderer { } } -class PageRenderer extends Renderer { +let EMPTY_SET = new Set; + +function morph(oldNode, newContent, config = {}) { + if (oldNode instanceof Document) { + oldNode = oldNode.documentElement; + } + if (typeof newContent === "string") { + newContent = parseContent(newContent); + } + let normalizedContent = normalizeContent(newContent); + let ctx = createMorphContext(oldNode, normalizedContent, config); + return morphNormalizedContent(oldNode, normalizedContent, ctx); +} + +function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { + if (ctx.head.block) { + let oldHead = oldNode.querySelector("head"); + let newHead = normalizedNewContent.querySelector("head"); + if (oldHead && newHead) { + let promises = handleHeadElement(newHead, oldHead, ctx); + Promise.all(promises).then((function() { + morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { + head: { + block: false, + ignore: true + } + })); + })); + return; + } + } + if (ctx.morphStyle === "innerHTML") { + morphChildren(normalizedNewContent, oldNode, ctx); + return oldNode.children; + } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { + let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); + let previousSibling = bestMatch?.previousSibling; + let nextSibling = bestMatch?.nextSibling; + let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); + if (bestMatch) { + return insertSiblings(previousSibling, morphedNode, nextSibling); + } else { + return []; + } + } else { + throw "Do not understand how to morph style " + ctx.morphStyle; + } +} + +function morphOldNodeTo(oldNode, newContent, ctx) { + if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return; + oldNode.remove(); + ctx.callbacks.afterNodeRemoved(oldNode); + return null; + } else if (!isSoftMatch(oldNode, newContent)) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return; + if (ctx.callbacks.beforeNodeAdded(newContent) === false) return; + oldNode.parentElement.replaceChild(newContent, oldNode); + ctx.callbacks.afterNodeAdded(newContent); + ctx.callbacks.afterNodeRemoved(oldNode); + return newContent; + } else { + if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return; + if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { + handleHeadElement(newContent, oldNode, ctx); + } else { + syncNodeFrom(newContent, oldNode); + morphChildren(newContent, oldNode, ctx); + } + ctx.callbacks.afterNodeMorphed(oldNode, newContent); + return oldNode; + } +} + +function morphChildren(newParent, oldParent, ctx) { + let nextNewChild = newParent.firstChild; + let insertionPoint = oldParent.firstChild; + let newChild; + while (nextNewChild) { + newChild = nextNewChild; + nextNewChild = newChild.nextSibling; + if (insertionPoint == null) { + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; + oldParent.appendChild(newChild); + ctx.callbacks.afterNodeAdded(newChild); + removeIdsFromConsideration(ctx, newChild); + continue; + } + if (isIdSetMatch(newChild, insertionPoint, ctx)) { + morphOldNodeTo(insertionPoint, newChild, ctx); + insertionPoint = insertionPoint.nextSibling; + removeIdsFromConsideration(ctx, newChild); + continue; + } + let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx); + if (idSetMatch) { + insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); + morphOldNodeTo(idSetMatch, newChild, ctx); + removeIdsFromConsideration(ctx, newChild); + continue; + } + let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx); + if (softMatch) { + insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); + morphOldNodeTo(softMatch, newChild, ctx); + removeIdsFromConsideration(ctx, newChild); + continue; + } + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; + oldParent.insertBefore(newChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newChild); + removeIdsFromConsideration(ctx, newChild); + } + while (insertionPoint !== null) { + let tempNode = insertionPoint; + insertionPoint = insertionPoint.nextSibling; + removeNode(tempNode, ctx); + } +} + +function syncNodeFrom(from, to) { + let type = from.nodeType; + if (type === 1) { + const fromAttributes = from.attributes; + const toAttributes = to.attributes; + for (const fromAttribute of fromAttributes) { + if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { + to.setAttribute(fromAttribute.name, fromAttribute.value); + } + } + for (const toAttribute of toAttributes) { + if (!from.hasAttribute(toAttribute.name)) { + to.removeAttribute(toAttribute.name); + } + } + } + if (type === 8 || type === 3) { + if (to.nodeValue !== from.nodeValue) { + to.nodeValue = from.nodeValue; + } + } + if (from instanceof HTMLInputElement && to instanceof HTMLInputElement && from.type !== "file") { + to.value = from.value || ""; + syncAttribute(from, to, "value"); + syncAttribute(from, to, "checked"); + syncAttribute(from, to, "disabled"); + } else if (from instanceof HTMLOptionElement) { + syncAttribute(from, to, "selected"); + } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { + let fromValue = from.value; + let toValue = to.value; + if (fromValue !== toValue) { + to.value = fromValue; + } + if (to.firstChild && to.firstChild.nodeValue !== fromValue) { + to.firstChild.nodeValue = fromValue; + } + } +} + +function syncAttribute(from, to, attributeName) { + if (from[attributeName] !== to[attributeName]) { + if (from[attributeName]) { + to.setAttribute(attributeName, from[attributeName]); + } else { + to.removeAttribute(attributeName); + } + } +} + +function handleHeadElement(newHeadTag, currentHead, ctx) { + let added = []; + let removed = []; + let preserved = []; + let nodesToAppend = []; + let headMergeStyle = ctx.head.style; + let srcToNewHeadNodes = new Map; + for (const newHeadChild of newHeadTag.children) { + srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); + } + for (const currentHeadElt of currentHead.children) { + let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); + let isReAppended = ctx.head.shouldReAppend(currentHeadElt); + let isPreserved = ctx.head.shouldPreserve(currentHeadElt); + if (inNewContent || isPreserved) { + if (isReAppended) { + removed.push(currentHeadElt); + } else { + srcToNewHeadNodes.delete(currentHeadElt.outerHTML); + preserved.push(currentHeadElt); + } + } else { + if (headMergeStyle === "append") { + if (isReAppended) { + removed.push(currentHeadElt); + nodesToAppend.push(currentHeadElt); + } + } else { + if (ctx.head.shouldRemove(currentHeadElt) !== false) { + removed.push(currentHeadElt); + } + } + } + } + nodesToAppend.push(...srcToNewHeadNodes.values()); + let promises = []; + for (const newNode of nodesToAppend) { + let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild; + if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { + if (newElt.href || newElt.src) { + let resolve = null; + let promise = new Promise((function(_resolve) { + resolve = _resolve; + })); + newElt.addEventListener("load", (function() { + resolve(); + })); + promises.push(promise); + } + currentHead.appendChild(newElt); + ctx.callbacks.afterNodeAdded(newElt); + added.push(newElt); + } + } + for (const removedElement of removed) { + if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { + currentHead.removeChild(removedElement); + ctx.callbacks.afterNodeRemoved(removedElement); + } + } + ctx.head.afterHeadMorphed(currentHead, { + added: added, + kept: preserved, + removed: removed + }); + return promises; +} + +function noOp() {} + +function createMorphContext(oldNode, newContent, config) { + return { + target: oldNode, + newContent: newContent, + config: config, + morphStyle: config.morphStyle, + ignoreActive: config.ignoreActive, + idMap: createIdMap(oldNode, newContent), + deadIds: new Set, + callbacks: Object.assign({ + beforeNodeAdded: noOp, + afterNodeAdded: noOp, + beforeNodeMorphed: noOp, + afterNodeMorphed: noOp, + beforeNodeRemoved: noOp, + afterNodeRemoved: noOp + }, config.callbacks), + head: Object.assign({ + style: "merge", + shouldPreserve: function(elt) { + return elt.getAttribute("im-preserve") === "true"; + }, + shouldReAppend: function(elt) { + return elt.getAttribute("im-re-append") === "true"; + }, + shouldRemove: noOp, + afterHeadMorphed: noOp + }, config.head) + }; +} + +function isIdSetMatch(node1, node2, ctx) { + if (node1 == null || node2 == null) { + return false; + } + if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) { + if (node1.id !== "" && node1.id === node2.id) { + return true; + } else { + return getIdIntersectionCount(ctx, node1, node2) > 0; + } + } + return false; +} + +function isSoftMatch(node1, node2) { + if (node1 == null || node2 == null) { + return false; + } + return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName; +} + +function removeNodesBetween(startInclusive, endExclusive, ctx) { + while (startInclusive !== endExclusive) { + let tempNode = startInclusive; + startInclusive = startInclusive.nextSibling; + removeNode(tempNode, ctx); + } + removeIdsFromConsideration(ctx, endExclusive); + return endExclusive.nextSibling; +} + +function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) { + let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent); + let potentialMatch = null; + if (newChildPotentialIdCount > 0) { + let potentialMatch = insertionPoint; + let otherMatchCount = 0; + while (potentialMatch != null) { + if (isIdSetMatch(newChild, potentialMatch, ctx)) { + return potentialMatch; + } + otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent); + if (otherMatchCount > newChildPotentialIdCount) { + return null; + } + potentialMatch = potentialMatch.nextSibling; + } + } + return potentialMatch; +} + +function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { + let potentialSoftMatch = insertionPoint; + let nextSibling = newChild.nextSibling; + let siblingSoftMatchCount = 0; + while (potentialSoftMatch != null) { + if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { + return null; + } + if (isSoftMatch(newChild, potentialSoftMatch)) { + return potentialSoftMatch; + } + if (isSoftMatch(nextSibling, potentialSoftMatch)) { + siblingSoftMatchCount++; + nextSibling = nextSibling.nextSibling; + if (siblingSoftMatchCount >= 2) { + return null; + } + } + potentialSoftMatch = potentialSoftMatch.nextSibling; + } + return potentialSoftMatch; +} + +function parseContent(newContent) { + let parser = new DOMParser; + let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ""); + if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { + let content = parser.parseFromString(newContent, "text/html"); + if (contentWithSvgsRemoved.match(/<\/html>/)) { + content.generatedByIdiomorph = true; + return content; + } else { + let htmlElement = content.firstChild; + if (htmlElement) { + htmlElement.generatedByIdiomorph = true; + return htmlElement; + } else { + return null; + } + } + } else { + let responseDoc = parser.parseFromString("", "text/html"); + let content = responseDoc.body.querySelector("template").content; + content.generatedByIdiomorph = true; + return content; + } +} + +function normalizeContent(newContent) { + if (newContent == null) { + const dummyParent = document.createElement("div"); + return dummyParent; + } else if (newContent.generatedByIdiomorph) { + return newContent; + } else if (newContent instanceof Node) { + const dummyParent = document.createElement("div"); + dummyParent.append(newContent); + return dummyParent; + } else { + const dummyParent = document.createElement("div"); + for (const elt of [ ...newContent ]) { + dummyParent.append(elt); + } + return dummyParent; + } +} + +function insertSiblings(previousSibling, morphedNode, nextSibling) { + let stack = []; + let added = []; + while (previousSibling != null) { + stack.push(previousSibling); + previousSibling = previousSibling.previousSibling; + } + while (stack.length > 0) { + let node = stack.pop(); + added.push(node); + morphedNode.parentElement.insertBefore(node, morphedNode); + } + added.push(morphedNode); + while (nextSibling != null) { + stack.push(nextSibling); + added.push(nextSibling); + nextSibling = nextSibling.nextSibling; + } + while (stack.length > 0) { + morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); + } + return added; +} + +function findBestNodeMatch(newContent, oldNode, ctx) { + let currentElement; + currentElement = newContent.firstChild; + let bestElement = currentElement; + let score = 0; + while (currentElement) { + let newScore = scoreElement(currentElement, oldNode, ctx); + if (newScore > score) { + bestElement = currentElement; + score = newScore; + } + currentElement = currentElement.nextSibling; + } + return bestElement; +} + +function scoreElement(node1, node2, ctx) { + if (isSoftMatch(node1, node2)) { + return .5 + getIdIntersectionCount(ctx, node1, node2); + } + return 0; +} + +function removeNode(tempNode, ctx) { + removeIdsFromConsideration(ctx, tempNode); + if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; + tempNode.remove(); + ctx.callbacks.afterNodeRemoved(tempNode); +} + +function isIdInConsideration(ctx, id) { + return !ctx.deadIds.has(id); +} + +function idIsWithinNode(ctx, id, targetNode) { + let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; + return idSet.has(id); +} + +function removeIdsFromConsideration(ctx, node) { + let idSet = ctx.idMap.get(node) || EMPTY_SET; + for (const id of idSet) { + ctx.deadIds.add(id); + } +} + +function getIdIntersectionCount(ctx, node1, node2) { + let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; + let matchCount = 0; + for (const id of sourceSet) { + if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) { + ++matchCount; + } + } + return matchCount; +} + +function populateIdMapForNode(node, idMap) { + let nodeParent = node.parentElement; + let idElements = node.querySelectorAll("[id]"); + for (const elt of idElements) { + let current = elt; + while (current !== nodeParent && current != null) { + let idSet = idMap.get(current); + if (idSet == null) { + idSet = new Set; + idMap.set(current, idSet); + } + idSet.add(elt.id); + current = current.parentElement; + } + } +} + +function createIdMap(oldContent, newContent) { + let idMap = new Map; + populateIdMapForNode(oldContent, idMap); + populateIdMapForNode(newContent, idMap); + return idMap; +} + +var idiomorph = { + morph: morph +}; + +class MorphRenderer extends Renderer { + async render() { + if (this.willRender) await this.#morphBody(); + } + get renderMethod() { + return "morph"; + } + async #morphBody() { + this.#morphElements(this.currentElement, this.newElement); + this.#reloadRemoteFrames(); + dispatch("turbo:morph", { + detail: { + currentElement: this.currentElement, + newElement: this.newElement + } + }); + } + #morphElements(currentElement, newElement, morphStyle = "outerHTML") { + this.isMorphingTurboFrame = this.#isRemoteFrame(currentElement); + idiomorph.morph(currentElement, newElement, { + morphStyle: morphStyle, + callbacks: { + beforeNodeMorphed: this.#shouldMorphElement, + beforeNodeRemoved: this.#shouldRemoveElement, + afterNodeMorphed: this.#reloadStimulusControllers + } + }); + } + #reloadRemoteFrames() { + this.#remoteFrames().forEach((frame => { + if (this.#isRemoteFrame(frame)) { + this.#renderFrameWithMorph(frame); + frame.reload(); + } + })); + } + #renderFrameWithMorph(frame) { + frame.addEventListener("turbo:before-frame-render", (event => { + event.detail.render = this.#morphFrameUpdate; + }), { + once: true + }); + } + #morphFrameUpdate=(currentElement, newElement) => { + dispatch("turbo:before-frame-morph", { + target: currentElement, + detail: { + currentElement: currentElement, + newElement: newElement + } + }); + this.#morphElements(currentElement, newElement.children, "innerHTML"); + }; + #shouldRemoveElement=node => this.#shouldMorphElement(node); + #shouldMorphElement=node => { + if (node instanceof HTMLElement) { + return !node.hasAttribute("data-turbo-permanent") && (this.isMorphingTurboFrame || !this.#isRemoteFrame(node)); + } else { + return true; + } + }; + #reloadStimulusControllers=async node => { + if (node instanceof HTMLElement && node.hasAttribute("data-controller")) { + const originalAttribute = node.getAttribute("data-controller"); + node.removeAttribute("data-controller"); + await nextAnimationFrame(); + node.setAttribute("data-controller", originalAttribute); + } + }; + #isRemoteFrame(element) { + return element.nodeName.toLowerCase() === "turbo-frame" && element.src; + } + #remoteFrames() { + return document.querySelectorAll("turbo-frame[src]"); + } +} + +class PageRenderer extends Renderer { static renderElement(currentElement, newElement) { if (document.body && newElement instanceof HTMLBodyElement) { document.body.replaceWith(newElement); @@ -2702,6 +3434,7 @@ class PageRenderer extends Renderer { } } async prepareToRender() { + this.#setLanguage(); await this.mergeHead(); } async render() { @@ -2724,6 +3457,15 @@ class PageRenderer extends Renderer { get newElement() { return this.newSnapshot.element; } + #setLanguage() { + const {documentElement: documentElement} = this.currentSnapshot; + const {lang: lang} = this.newSnapshot; + if (lang) { + documentElement.setAttribute("lang", lang); + } else { + documentElement.removeAttribute("lang"); + } + } async mergeHead() { const mergedHeadElements = this.mergeProvisionalElements(); const newStylesheetElements = this.copyNewHeadStylesheetElements(); @@ -2822,28 +3564,80 @@ class PageRenderer extends Renderer { } } -class SnapshotCache { +class DiskStore { + _version="v1"; + constructor() { + if (typeof caches === "undefined") { + throw new Error("windows.caches is undefined. CacheStore requires a secure context."); + } + this.storage = this.openStorage(); + } + async has(location) { + const storage = await this.openStorage(); + return await storage.match(location) !== undefined; + } + async get(location) { + const storage = await this.openStorage(); + const response = await storage.match(location); + if (response && response.ok) { + const html = await response.text(); + return PageSnapshot.fromHTMLString(html); + } + } + async put(location, snapshot) { + const storage = await this.openStorage(); + const response = new Response(snapshot.html, { + status: 200, + statusText: "OK", + headers: { + "Content-Type": "text/html" + } + }); + await storage.put(location, response); + return snapshot; + } + async clear() { + const storage = await this.openStorage(); + const keys = await storage.keys(); + await Promise.all(keys.map((key => storage.delete(key)))); + } + openStorage() { + this.storage ||= caches.open(`turbo-${this.version}`); + return this.storage; + } + set version(value) { + if (value !== this._version) { + this._version = value; + this.storage ||= caches.open(`turbo-${this.version}`); + } + } + get version() { + return this._version; + } +} + +class MemoryStore { + keys=[]; + snapshots={}; constructor(size) { - this.keys = []; - this.snapshots = {}; this.size = size; } - has(location) { + async has(location) { return toCacheKey(location) in this.snapshots; } - get(location) { - if (this.has(location)) { + async get(location) { + if (await this.has(location)) { const snapshot = this.read(location); this.touch(location); return snapshot; } } - put(location, snapshot) { + async put(location, snapshot) { this.write(location, snapshot); this.touch(location); return snapshot; } - clear() { + async clear() { this.snapshots = {}; } read(location) { @@ -2866,27 +3660,62 @@ class SnapshotCache { } } +class SnapshotCache { + static currentStore=new MemoryStore(10); + static setStore(storeName) { + switch (storeName) { + case "memory": + SnapshotCache.currentStore = new MemoryStore(10); + break; + + case "disk": + SnapshotCache.currentStore = new DiskStore; + break; + + default: + throw new Error(`Invalid store name: ${storeName}`); + } + } + has(location) { + return SnapshotCache.currentStore.has(location); + } + get(location) { + return SnapshotCache.currentStore.get(location); + } + put(location, snapshot) { + return SnapshotCache.currentStore.put(location, snapshot); + } + clear() { + return SnapshotCache.currentStore.clear(); + } +} + class PageView extends View { - constructor() { - super(...arguments); - this.snapshotCache = new SnapshotCache(10); - this.lastRenderedLocation = new URL(location.href); - this.forceReloaded = false; + snapshotCache=new SnapshotCache; + lastRenderedLocation=new URL(location.href); + forceReloaded=false; + shouldTransitionTo(newSnapshot) { + return this.snapshot.prefersViewTransitions && newSnapshot.prefersViewTransitions; } renderPage(snapshot, isPreview = false, willRender = true, visit) { - const renderer = new PageRenderer(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender); + const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage; + const rendererClass = shouldMorphPage ? MorphRenderer : PageRenderer; + const renderer = new rendererClass(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender); if (!renderer.shouldRender) { this.forceReloaded = true; } else { - visit === null || visit === void 0 ? void 0 : visit.changeHistory(); + visit?.changeHistory(); } return this.render(renderer); } renderError(snapshot, visit) { - visit === null || visit === void 0 ? void 0 : visit.changeHistory(); + visit?.changeHistory(); const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false); return this.render(renderer); } + setCacheStore(cacheName) { + SnapshotCache.setStore(cacheName); + } clearSnapshotCache() { this.snapshotCache.clear(); } @@ -2903,14 +3732,17 @@ class PageView extends View { getCachedSnapshotForLocation(location) { return this.snapshotCache.get(location); } + isPageRefresh(visit) { + return visit && this.lastRenderedLocation.href === visit.location.href; + } get snapshot() { return PageSnapshot.fromElement(this.element); } } class Preloader { + selector="a[data-turbo-preload]"; constructor(delegate) { - this.selector = "a[data-turbo-preload]"; this.delegate = delegate; } get snapshotCache() { @@ -2932,13 +3764,11 @@ class Preloader { } async preloadURL(link) { const location = new URL(link.href); - if (this.snapshotCache.has(location)) { - return; - } + if (await this.snapshotCache.has(location)) return; try { const response = await fetch(location.toString(), { headers: { - "VND.PREFETCH": "true", + "Sec-Purpose": "prefetch", Accept: "text/html" } }); @@ -2949,28 +3779,71 @@ class Preloader { } } -class Session { - constructor() { - this.navigator = new Navigator(this); - this.history = new History(this); - this.preloader = new Preloader(this); - this.view = new PageView(this, document.documentElement); - this.adapter = new BrowserAdapter(this); - this.pageObserver = new PageObserver(this); - this.cacheObserver = new CacheObserver; - this.linkClickObserver = new LinkClickObserver(this, window); - this.formSubmitObserver = new FormSubmitObserver(this, document); - this.scrollObserver = new ScrollObserver(this); - this.streamObserver = new StreamObserver(this); - this.formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement); - this.frameRedirector = new FrameRedirector(this, document.documentElement); - this.streamMessageRenderer = new StreamMessageRenderer; - this.drive = true; - this.enabled = true; - this.progressBarDelay = 500; - this.started = false; - this.formMode = "on"; +class LimitedSet extends Set { + constructor(maxSize) { + super(); + this.maxSize = maxSize; + } + add(value) { + if (this.size >= this.maxSize) { + const iterator = this.values(); + const oldestValue = iterator.next().value; + this.delete(oldestValue); + } + super.add(value); + } +} + +class Cache { + clear() { + this.store.clear(); + } + resetCacheControl() { + this.#setCacheControl(""); + } + exemptPageFromCache() { + this.#setCacheControl("no-cache"); + } + exemptPageFromPreview() { + this.#setCacheControl("no-preview"); + } + set store(store) { + if (typeof store === "string") { + SnapshotCache.setStore(store); + } else { + SnapshotCache.currentStore = store; + } + } + get store() { + return SnapshotCache.currentStore; + } + #setCacheControl(value) { + setMetaContent("turbo-cache-control", value); } +} + +class Session { + navigator=new Navigator(this); + history=new History(this); + preloader=new Preloader(this); + view=new PageView(this, document.documentElement); + adapter=new BrowserAdapter(this); + pageObserver=new PageObserver(this); + cacheObserver=new CacheObserver; + linkClickObserver=new LinkClickObserver(this, window); + formSubmitObserver=new FormSubmitObserver(this, document); + scrollObserver=new ScrollObserver(this); + streamObserver=new StreamObserver(this); + formLinkClickObserver=new FormLinkClickObserver(this, document.documentElement); + frameRedirector=new FrameRedirector(this, document.documentElement); + streamMessageRenderer=new StreamMessageRenderer; + cache=new Cache(this); + recentRequests=new LimitedSet(20); + drive=true; + enabled=true; + progressBarDelay=500; + started=false; + formMode="on"; start() { if (!this.started) { this.pageObserver.start(); @@ -3016,6 +3889,15 @@ class Session { this.navigator.proposeVisit(expandURL(location), options); } } + refresh(url, requestId) { + const isRecentRequest = requestId && this.recentRequests.has(requestId); + if (!isRecentRequest) { + this.cache.exemptPageFromPreview(); + this.visit(url, { + action: "replace" + }); + } + } connectStreamSource(source) { this.streamObserver.connectStreamSource(source); } @@ -3099,7 +3981,7 @@ class Session { this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL); } willSubmitForm(form, submitter) { - const action = getAction(form, submitter); + const action = getAction$1(form, submitter); return this.submissionIsNavigatable(form, submitter) && locationIsVisitable(expandURL(action), this.snapshot.rootLocation); } formSubmitted(form, submitter) { @@ -3119,22 +4001,21 @@ class Session { this.renderStreamMessage(message); } viewWillCacheSnapshot() { - var _a; - if (!((_a = this.navigator.currentVisit) === null || _a === void 0 ? void 0 : _a.silent)) { + if (!this.navigator.currentVisit?.silent) { this.notifyApplicationBeforeCachingSnapshot(); } } - allowsImmediateRender({element: element}, options) { - const event = this.notifyApplicationBeforeRender(element, options); + allowsImmediateRender({element: element}, isPreview, options) { + const event = this.notifyApplicationBeforeRender(element, isPreview, options); const {defaultPrevented: defaultPrevented, detail: {render: render}} = event; if (this.view.renderer && render) { this.view.renderer.renderElement = render; } return !defaultPrevented; } - viewRenderedSnapshot(_snapshot, _isPreview) { + viewRenderedSnapshot(_snapshot, isPreview, renderMethod) { this.view.lastRenderedLocation = this.history.location; - this.notifyApplicationAfterRender(); + this.notifyApplicationAfterRender(isPreview, renderMethod); } preloadOnLoadLinksForView(element) { this.preloader.preloadOnLoadLinksForView(element); @@ -3185,16 +4066,23 @@ class Session { notifyApplicationBeforeCachingSnapshot() { return dispatch("turbo:before-cache"); } - notifyApplicationBeforeRender(newBody, options) { + notifyApplicationBeforeRender(newBody, isPreview, options) { return dispatch("turbo:before-render", { - detail: Object.assign({ - newBody: newBody - }, options), + detail: { + newBody: newBody, + isPreview: isPreview, + ...options + }, cancelable: true }); } - notifyApplicationAfterRender() { - return dispatch("turbo:render"); + notifyApplicationAfterRender(isPreview, renderMethod) { + return dispatch("turbo:render", { + detail: { + isPreview: isPreview, + renderMethod: renderMethod + } + }); } notifyApplicationAfterPageLoad(timing = {}) { return dispatch("turbo:load", { @@ -3273,43 +4161,16 @@ const deprecatedLocationPropertyDescriptors = { } }; -class Cache { - constructor(session) { - this.session = session; - } - clear() { - this.session.clearCache(); - } - resetCacheControl() { - this.setCacheControl(""); - } - exemptPageFromCache() { - this.setCacheControl("no-cache"); - } - exemptPageFromPreview() { - this.setCacheControl("no-preview"); - } - setCacheControl(value) { - setMetaContent("turbo-cache-control", value); - } -} - const StreamActions = { after() { - this.targetElements.forEach((e => { - var _a; - return (_a = e.parentElement) === null || _a === void 0 ? void 0 : _a.insertBefore(this.templateContent, e.nextSibling); - })); + this.targetElements.forEach((e => e.parentElement?.insertBefore(this.templateContent, e.nextSibling))); }, append() { this.removeDuplicateTargetChildren(); this.targetElements.forEach((e => e.append(this.templateContent))); }, before() { - this.targetElements.forEach((e => { - var _a; - return (_a = e.parentElement) === null || _a === void 0 ? void 0 : _a.insertBefore(this.templateContent, e); - })); + this.targetElements.forEach((e => e.parentElement?.insertBefore(this.templateContent, e))); }, prepend() { this.removeDuplicateTargetChildren(); @@ -3326,14 +4187,15 @@ const StreamActions = { targetElement.innerHTML = ""; targetElement.append(this.templateContent); })); + }, + refresh() { + session.refresh(this.baseURI, this.requestId); } }; const session = new Session; -const cache = new Cache(session); - -const {navigator: navigator$1} = session; +const {cache: cache, navigator: navigator$1} = session; function start() { session.start(); @@ -3384,6 +4246,7 @@ var Turbo = Object.freeze({ PageRenderer: PageRenderer, PageSnapshot: PageSnapshot, FrameRenderer: FrameRenderer, + fetch: fetch, start: start, registerAdapter: registerAdapter, visit: visit, @@ -3400,21 +4263,14 @@ var Turbo = Object.freeze({ class TurboFrameMissingError extends Error {} class FrameController { + fetchResponseLoaded=_fetchResponse => Promise.resolve(); + #currentFetchRequest=null; + #resolveVisitPromise=() => {}; + #connected=false; + #hasBeenLoaded=false; + #ignoredAttributes=new Set; + action=null; constructor(element) { - this.fetchResponseLoaded = _fetchResponse => {}; - this.currentFetchRequest = null; - this.resolveVisitPromise = () => {}; - this.connected = false; - this.hasBeenLoaded = false; - this.ignoredAttributes = new Set; - this.action = null; - this.visitCachedSnapshot = ({element: element}) => { - const frame = element.querySelector("#" + this.element.id); - if (frame && this.previousFrameElement) { - frame.replaceChildren(...this.previousFrameElement.children); - } - delete this.previousFrameElement; - }; this.element = element; this.view = new FrameView(this, this.element); this.appearanceObserver = new AppearanceObserver(this, this.element); @@ -3424,12 +4280,12 @@ class FrameController { this.formSubmitObserver = new FormSubmitObserver(this, this.element); } connect() { - if (!this.connected) { - this.connected = true; + if (!this.#connected) { + this.#connected = true; if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start(); } else { - this.loadSourceURL(); + this.#loadSourceURL(); } this.formLinkClickObserver.start(); this.linkInterceptor.start(); @@ -3437,8 +4293,8 @@ class FrameController { } } disconnect() { - if (this.connected) { - this.connected = false; + if (this.#connected) { + this.#connected = false; this.appearanceObserver.stop(); this.formLinkClickObserver.stop(); this.linkInterceptor.stop(); @@ -3447,21 +4303,21 @@ class FrameController { } disabledChanged() { if (this.loadingStyle == FrameLoadingStyle.eager) { - this.loadSourceURL(); + this.#loadSourceURL(); } } sourceURLChanged() { - if (this.isIgnoringChangesTo("src")) return; + if (this.#isIgnoringChangesTo("src")) return; if (this.element.isConnected) { this.complete = false; } - if (this.loadingStyle == FrameLoadingStyle.eager || this.hasBeenLoaded) { - this.loadSourceURL(); + if (this.loadingStyle == FrameLoadingStyle.eager || this.#hasBeenLoaded) { + this.#loadSourceURL(); } } sourceURLReloaded() { const {src: src} = this.element; - this.ignoringChangesToAttribute("complete", (() => { + this.#ignoringChangesToAttribute("complete", (() => { this.element.removeAttribute("complete"); })); this.element.src = null; @@ -3469,23 +4325,23 @@ class FrameController { return this.element.loaded; } completeChanged() { - if (this.isIgnoringChangesTo("complete")) return; - this.loadSourceURL(); + if (this.#isIgnoringChangesTo("complete")) return; + this.#loadSourceURL(); } loadingStyleChanged() { if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start(); } else { this.appearanceObserver.stop(); - this.loadSourceURL(); + this.#loadSourceURL(); } } - async loadSourceURL() { + async #loadSourceURL() { if (this.enabled && this.isActive && !this.complete && this.sourceURL) { - this.element.loaded = this.visit(expandURL(this.sourceURL)); + this.element.loaded = this.#visit(expandURL(this.sourceURL)); this.appearanceObserver.stop(); await this.element.loaded; - this.hasBeenLoaded = true; + this.#hasBeenLoaded = true; } } async loadResponse(fetchResponse) { @@ -3498,34 +4354,34 @@ class FrameController { const document = parseHTMLDocument(html); const pageSnapshot = PageSnapshot.fromDocument(document); if (pageSnapshot.isVisitable) { - await this.loadFrameResponse(fetchResponse, document); + await this.#loadFrameResponse(fetchResponse, document); } else { - await this.handleUnvisitableFrameResponse(fetchResponse); + await this.#handleUnvisitableFrameResponse(fetchResponse); } } } finally { - this.fetchResponseLoaded = () => {}; + this.fetchResponseLoaded = () => Promise.resolve(); } } elementAppearedInViewport(element) { this.proposeVisitIfNavigatedWithAction(element, element); - this.loadSourceURL(); + this.#loadSourceURL(); } willSubmitFormLinkToLocation(link) { - return this.shouldInterceptNavigation(link); + return this.#shouldInterceptNavigation(link); } submittedFormLinkToLocation(link, _location, form) { - const frame = this.findFrameElement(link); + const frame = this.#findFrameElement(link); if (frame) form.setAttribute("data-turbo-frame", frame.id); } shouldInterceptLinkClick(element, _location, _event) { - return this.shouldInterceptNavigation(element); + return this.#shouldInterceptNavigation(element); } linkClickIntercepted(element, location) { - this.navigateFrame(element, location); + this.#navigateFrame(element, location); } willSubmitForm(element, submitter) { - return element.closest("turbo-frame") == this.element && this.shouldInterceptNavigation(element, submitter); + return element.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(element, submitter); } formSubmitted(element, submitter) { if (this.formSubmission) { @@ -3537,9 +4393,8 @@ class FrameController { this.formSubmission.start(); } prepareRequest(request) { - var _a; request.headers["Turbo-Frame"] = this.id; - if ((_a = this.currentNavigationElement) === null || _a === void 0 ? void 0 : _a.hasAttribute("data-turbo-stream")) { + if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { request.acceptResponseType(StreamMessage.contentType); } } @@ -3547,28 +4402,28 @@ class FrameController { markAsBusy(this.element); } requestPreventedHandlingResponse(_request, _response) { - this.resolveVisitPromise(); + this.#resolveVisitPromise(); } async requestSucceededWithResponse(request, response) { await this.loadResponse(response); - this.resolveVisitPromise(); + this.#resolveVisitPromise(); } async requestFailedWithResponse(request, response) { await this.loadResponse(response); - this.resolveVisitPromise(); + this.#resolveVisitPromise(); } requestErrored(request, error) { console.error(error); - this.resolveVisitPromise(); + this.#resolveVisitPromise(); } requestFinished(_request) { clearBusyState(this.element); } formSubmissionStarted({formElement: formElement}) { - markAsBusy(formElement, this.findFrameElement(formElement)); + markAsBusy(formElement, this.#findFrameElement(formElement)); } formSubmissionSucceededWithResponse(formSubmission, response) { - const frame = this.findFrameElement(formSubmission.formElement, formSubmission.submitter); + const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter); frame.delegate.proposeVisitIfNavigatedWithAction(frame, formSubmission.formElement, formSubmission.submitter); frame.delegate.loadResponse(response); if (!formSubmission.isSafe) { @@ -3583,14 +4438,15 @@ class FrameController { console.error(error); } formSubmissionFinished({formElement: formElement}) { - clearBusyState(formElement, this.findFrameElement(formElement)); + clearBusyState(formElement, this.#findFrameElement(formElement)); } - allowsImmediateRender({element: newFrame}, options) { + allowsImmediateRender({element: newFrame}, _isPreview, options) { const event = dispatch("turbo:before-frame-render", { target: this.element, - detail: Object.assign({ - newFrame: newFrame - }, options), + detail: { + newFrame: newFrame, + ...options + }, cancelable: true }); const {defaultPrevented: defaultPrevented, detail: {render: render}} = event; @@ -3599,7 +4455,7 @@ class FrameController { } return !defaultPrevented; } - viewRenderedSnapshot(_snapshot, _isPreview) {} + viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {} preloadOnLoadLinksForView(element) { session.preloadOnLoadLinksForView(element); } @@ -3607,7 +4463,14 @@ class FrameController { willRenderFrame(currentElement, _newElement) { this.previousFrameElement = currentElement.cloneNode(true); } - async loadFrameResponse(fetchResponse, document) { + visitCachedSnapshot=({element: element}) => { + const frame = element.querySelector("#" + this.element.id); + if (frame && this.previousFrameElement) { + frame.replaceChildren(...this.previousFrameElement.children); + } + delete this.previousFrameElement; + }; + async #loadFrameResponse(fetchResponse, document) { const newFrameElement = await this.extractForeignFrameElement(document.body); if (newFrameElement) { const snapshot = new Snapshot(newFrameElement); @@ -3618,29 +4481,28 @@ class FrameController { this.complete = true; session.frameRendered(fetchResponse, this.element); session.frameLoaded(this.element); - this.fetchResponseLoaded(fetchResponse); - } else if (this.willHandleFrameMissingFromResponse(fetchResponse)) { - this.handleFrameMissingFromResponse(fetchResponse); + await this.fetchResponseLoaded(fetchResponse); + } else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) { + this.#handleFrameMissingFromResponse(fetchResponse); } } - async visit(url) { - var _a; + async #visit(url) { const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams, this.element); - (_a = this.currentFetchRequest) === null || _a === void 0 ? void 0 : _a.cancel(); - this.currentFetchRequest = request; + this.#currentFetchRequest?.cancel(); + this.#currentFetchRequest = request; return new Promise((resolve => { - this.resolveVisitPromise = () => { - this.resolveVisitPromise = () => {}; - this.currentFetchRequest = null; + this.#resolveVisitPromise = () => { + this.#resolveVisitPromise = () => {}; + this.#currentFetchRequest = null; resolve(); }; request.perform(); })); } - navigateFrame(element, url, submitter) { - const frame = this.findFrameElement(element, submitter); + #navigateFrame(element, url, submitter) { + const frame = this.#findFrameElement(element, submitter); frame.delegate.proposeVisitIfNavigatedWithAction(frame, element, submitter); - this.withCurrentNavigationElement(element, (() => { + this.#withCurrentNavigationElement(element, (() => { frame.src = url; })); } @@ -3649,10 +4511,10 @@ class FrameController { if (this.action) { const pageSnapshot = PageSnapshot.fromElement(frame).clone(); const {visitCachedSnapshot: visitCachedSnapshot} = frame.delegate; - frame.delegate.fetchResponseLoaded = fetchResponse => { + frame.delegate.fetchResponseLoaded = async fetchResponse => { if (frame.src) { const {statusCode: statusCode, redirected: redirected} = fetchResponse; - const responseHTML = frame.ownerDocument.documentElement.outerHTML; + const responseHTML = await fetchResponse.responseHTML; const response = { statusCode: statusCode, redirected: redirected, @@ -3678,16 +4540,16 @@ class FrameController { session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier); } } - async handleUnvisitableFrameResponse(fetchResponse) { + async #handleUnvisitableFrameResponse(fetchResponse) { console.warn(`The response (${fetchResponse.statusCode}) from is performing a full page visit due to turbo-visit-control.`); - await this.visitResponse(fetchResponse.response); + await this.#visitResponse(fetchResponse.response); } - willHandleFrameMissingFromResponse(fetchResponse) { + #willHandleFrameMissingFromResponse(fetchResponse) { this.element.setAttribute("complete", ""); const response = fetchResponse.response; - const visit = async (url, options = {}) => { + const visit = async (url, options) => { if (url instanceof Response) { - this.visitResponse(url); + this.#visitResponse(url); } else { session.visit(url, options); } @@ -3702,15 +4564,15 @@ class FrameController { }); return !event.defaultPrevented; } - handleFrameMissingFromResponse(fetchResponse) { + #handleFrameMissingFromResponse(fetchResponse) { this.view.missing(); - this.throwFrameMissingError(fetchResponse); + this.#throwFrameMissingError(fetchResponse); } - throwFrameMissingError(fetchResponse) { + #throwFrameMissingError(fetchResponse) { const message = `The response (${fetchResponse.statusCode}) did not contain the expected and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.`; throw new TurboFrameMissingError(message); } - async visitResponse(response) { + async #visitResponse(response) { const wrapped = new FetchResponse(response); const responseHTML = await wrapped.responseHTML; const {location: location, redirected: redirected, statusCode: statusCode} = wrapped; @@ -3722,10 +4584,9 @@ class FrameController { } }); } - findFrameElement(element, submitter) { - var _a; + #findFrameElement(element, submitter) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); - return (_a = getFrameElementById(id)) !== null && _a !== void 0 ? _a : this.element; + return getFrameElementById(id) ?? this.element; } async extractForeignFrameElement(container) { let element; @@ -3746,13 +4607,13 @@ class FrameController { } return null; } - formActionIsVisitable(form, submitter) { - const action = getAction(form, submitter); + #formActionIsVisitable(form, submitter) { + const action = getAction$1(form, submitter); return locationIsVisitable(expandURL(action), this.rootLocation); } - shouldInterceptNavigation(element, submitter) { + #shouldInterceptNavigation(element, submitter) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); - if (element instanceof HTMLFormElement && !this.formActionIsVisitable(element, submitter)) { + if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter)) { return false; } if (!this.enabled || id == "_top") { @@ -3784,21 +4645,21 @@ class FrameController { } } set sourceURL(sourceURL) { - this.ignoringChangesToAttribute("src", (() => { - this.element.src = sourceURL !== null && sourceURL !== void 0 ? sourceURL : null; + this.#ignoringChangesToAttribute("src", (() => { + this.element.src = sourceURL ?? null; })); } get loadingStyle() { return this.element.loading; } get isLoading() { - return this.formSubmission !== undefined || this.resolveVisitPromise() !== undefined; + return this.formSubmission !== undefined || this.#resolveVisitPromise() !== undefined; } get complete() { return this.element.hasAttribute("complete"); } set complete(value) { - this.ignoringChangesToAttribute("complete", (() => { + this.#ignoringChangesToAttribute("complete", (() => { if (value) { this.element.setAttribute("complete", ""); } else { @@ -3807,23 +4668,22 @@ class FrameController { })); } get isActive() { - return this.element.isActive && this.connected; + return this.element.isActive && this.#connected; } get rootLocation() { - var _a; const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); - const root = (_a = meta === null || meta === void 0 ? void 0 : meta.content) !== null && _a !== void 0 ? _a : "/"; + const root = meta?.content ?? "/"; return expandURL(root); } - isIgnoringChangesTo(attributeName) { - return this.ignoredAttributes.has(attributeName); + #isIgnoringChangesTo(attributeName) { + return this.#ignoredAttributes.has(attributeName); } - ignoringChangesToAttribute(attributeName, callback) { - this.ignoredAttributes.add(attributeName); + #ignoringChangesToAttribute(attributeName, callback) { + this.#ignoredAttributes.add(attributeName); callback(); - this.ignoredAttributes.delete(attributeName); + this.#ignoredAttributes.delete(attributeName); } - withCurrentNavigationElement(element, callback) { + #withCurrentNavigationElement(element, callback) { this.currentNavigationElement = element; callback(); delete this.currentNavigationElement; @@ -3870,8 +4730,7 @@ class StreamElement extends HTMLElement { } } async render() { - var _a; - return (_a = this.renderPromise) !== null && _a !== void 0 ? _a : this.renderPromise = (async () => { + return this.renderPromise ??= (async () => { const event = this.beforeRenderEvent; if (this.dispatchEvent(event)) { await nextAnimationFrame(); @@ -3882,15 +4741,14 @@ class StreamElement extends HTMLElement { disconnect() { try { this.remove(); - } catch (_a) {} + } catch {} } removeDuplicateTargetChildren() { this.duplicateChildren.forEach((c => c.remove())); } get duplicateChildren() { - var _a; const existingChildren = this.targetElements.flatMap((e => [ ...e.children ])).filter((c => !!c.id)); - const newChildrenIds = [ ...((_a = this.templateContent) === null || _a === void 0 ? void 0 : _a.children) || [] ].filter((c => !!c.id)).map((c => c.id)); + const newChildrenIds = [ ...this.templateContent?.children || [] ].filter((c => !!c.id)).map((c => c.id)); return existingChildren.filter((c => newChildrenIds.includes(c.id))); } get performAction() { @@ -3899,9 +4757,9 @@ class StreamElement extends HTMLElement { if (actionFunction) { return actionFunction; } - this.raise("unknown action"); + this.#raise("unknown action"); } - this.raise("action attribute is missing"); + this.#raise("action attribute is missing"); } get targetElements() { if (this.target) { @@ -3909,7 +4767,7 @@ class StreamElement extends HTMLElement { } else if (this.targets) { return this.targetElementsByQuery; } else { - this.raise("target or targets attribute is missing"); + this.#raise("target or targets attribute is missing"); } } get templateContent() { @@ -3923,7 +4781,7 @@ class StreamElement extends HTMLElement { } else if (this.firstElementChild instanceof HTMLTemplateElement) { return this.firstElementChild; } - this.raise("first child element must be a