diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5ace4600 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9556a26b..52846f1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,22 +5,24 @@ jobs: strategy: fail-fast: false matrix: - rails: [ "6.1", "7.0" ] - ruby: [ "2.7", "3.0", "3.1", "3.2" ] + rails: [ "6.1", "7.0", "7.1" ] + ruby: [ "3.0", "3.1", "3.2", "3.3" ] allow-fail: [ false ] include: - - { ruby: "2.7", rails: "main", allow-fail: true } + - { ruby: "3.3", rails: "main", allow-fail: true } - { ruby: "3.2", rails: "main", allow-fail: true } - { ruby: "head", rails: "main", allow-fail: true } env: + FERRUM_PROCESS_TIMEOUT: 25 + FERRUM_DEFAULT_TIMEOUT: 15 RAILS_VERSION: "${{ matrix.rails }}" name: ${{ format('Tests (Ruby {0}, Rails {1})', matrix.ruby, matrix.rails) }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Install Ruby uses: ruby/setup-ruby@v1 @@ -29,6 +31,9 @@ jobs: rubygems: latest bundler-cache: true + - name: Run Bug Template Tests + run: ruby bug_report_template.rb || ruby bug_report_template.rb + - name: Run tests id: test run: bundle exec rake TESTOPT=-vdc diff --git a/.gitignore b/.gitignore index bc00769d..52d3930f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ *.gem /node_modules Gemfile.lock + +# Generated by rails/rails#49349 +*.sqlite3-shm +*.sqlite3-wal \ No newline at end of file diff --git a/Gemfile b/Gemfile index 8d43e824..fe6844ae 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source 'https://rubygems.org' gemspec -rails_version = ENV.fetch("RAILS_VERSION", "6.1") +rails_version = ENV.fetch("RAILS_VERSION", "7.1") if rails_version == "main" rails_constraint = { github: "rails/rails" } @@ -29,5 +29,5 @@ group :test do gem 'capybara' gem 'rexml' gem 'cuprite', '~> 0.9', require: 'capybara/cuprite' - gem 'sqlite3' + gem 'sqlite3', '~> 1.4' end diff --git a/README.md b/README.md index c15f6a57..c8b208f3 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,11 @@ On top of accelerating web applications, Turbo was built from the ground-up to f Turbo is a language-agnostic framework written in JavaScript, but this gem builds on top of those basics to make the integration with Rails as smooth as possible. You can deliver turbo updates via model callbacks over Action Cable, respond to controller actions with native navigation or standard redirects, and render turbo frames with helpers and layout-free responses. - ## Navigate with Turbo Drive Turbo is a continuation of the ideas from the previous [Turbolinks](https://github.com/turbolinks/turbolinks) framework, and the heart of that past approach lives on as Turbo Drive. When installed, Turbo automatically intercepts all clicks on `` links to the same domain. When you click an eligible link, Turbo prevents the browser from following it. Instead, Turbo changes the browser’s URL using the History API, requests the new page using `fetch`, and then renders the HTML response. -During rendering, Turbo replaces the current `` element outright and merges the contents of the `` element. The JavaScript window and document objects, and the HTML `` element, persist from one rendering to the next. +During rendering, Turbo replaces the current `` element outright and merges the contents of the `` element. The JavaScript window and document objects, and the `` element, persist from one rendering to the next. Whereas Turbolinks previously just dealt with links, Turbo can now also process form submissions and responses. This means the entire flow in the web application is wrapped into Turbo, making all the parts fast. No more need for `data-remote=true`. @@ -51,13 +50,13 @@ For instance: <% end %> ``` -When the user will click on the `Edit this todo` link, as direct response to this direct user interaction, the turbo frame will be replaced with the one in the `edit.html.erb` page automatically. +When the user clicks on the `Edit this todo` link, as a direct response to this user interaction, the turbo frame will be automatically replaced with the one in the `edit.html.erb` page. [See documentation](https://turbo.hotwired.dev/handbook/frames). ### A note on custom layouts -In order to render turbo frame requests without the application layout, Turbo registers a custom [layout method](https://api.rubyonrails.org/classes/ActionView/Layouts/ClassMethods.html#method-i-layout). +In order to render turbo frame requests without the application layout, Turbo registers a custom [layout method](https://api.rubyonrails.org/classes/ActionView/Layouts/ClassMethods.html#method-i-layout). If your application uses custom layout resolution, you have to make sure to return `"turbo_rails/frame"` (or `false` for TurboRails < 1.4.0) for turbo frame requests: ```ruby @@ -65,7 +64,7 @@ layout :custom_layout def custom_layout return "turbo_rails/frame" if turbo_frame_request? - + # ... your custom layout logic ``` @@ -75,14 +74,14 @@ If you are using a custom, but "static" layout, layout "some_static_layout" ``` -you **have** to change it to a layout method in order to conditionally return `false` for turbo frame requests: +you **have** to change it to a layout method in order to conditionally return `"turbo_rails/frame"` for turbo frame requests: ```ruby layout :custom_layout def custom_layout return "turbo_rails/frame" if turbo_frame_request? - + "some_static_layout" ``` @@ -110,9 +109,8 @@ This gem is automatically configured for applications made with Rails 7+ (unless 1. Add the `turbo-rails` gem to your Gemfile: `gem 'turbo-rails'` 2. Run `./bin/bundle install` 3. Run `./bin/rails turbo:install` -4. Run `./bin/rails turbo:install:redis` to change the development Action Cable adapter from Async (the default one) to Redis. The Async adapter does not support Turbo Stream broadcasting. -Running `turbo:install` will install through NPM if Node.js is used in the application. Otherwise the asset pipeline version is used. To use the asset pipeline version, you must have `importmap-rails` installed first and listed higher in the Gemfile. +Running `turbo:install` will install through NPM or Bun if a JavaScript runtime is used in the application. Otherwise the asset pipeline version is used. To use the asset pipeline version, you must have `importmap-rails` installed first and listed higher in the Gemfile. If you're using node and need to use the cable consumer, you can import [`cable`](https://github.com/hotwired/turbo-rails/blob/main/app/javascript/turbo/cable.js) (`import { cable } from "@hotwired/turbo-rails"`), but ensure that your application actually *uses* the members it `import`s when using this style (see [turbo-rails#48](https://github.com/hotwired/turbo-rails/issues/48)). @@ -122,17 +120,26 @@ The `Turbo` instance is automatically assigned to `window.Turbo` upon import: import "@hotwired/turbo-rails" ``` - ## Usage You can watch [the video introduction to Hotwire](https://hotwired.dev/#screencast), which focuses extensively on demonstrating Turbo in a Rails demo. Then you should familiarize yourself with [Turbo handbook](https://turbo.hotwired.dev/handbook/introduction) to understand Drive, Frames, and Streams in-depth. Finally, dive into the code documentation by starting with [`Turbo::FramesHelper`](https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/frames_helper.rb), [`Turbo::StreamsHelper`](https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/streams_helper.rb), [`Turbo::Streams::TagBuilder`](https://github.com/hotwired/turbo-rails/blob/main/app/models/turbo/streams/tag_builder.rb), and [`Turbo::Broadcastable`](https://github.com/hotwired/turbo-rails/blob/main/app/models/concerns/turbo/broadcastable.rb). +Note that in development, the default Action Cable adapter is the single-process `async` adapter. This means that turbo updates are only broadcast within that same process. So you can't start `bin/rails console` and trigger Turbo broadcasts and expect them to show up in a browser connected to a server running in a separate `bin/dev` or `bin/rails server` process. Instead, you should use the web-console when needing to manaually trigger Turbo broadcasts inside the same process. Add "console" to any action or "<%= console %>" in any view to make the web console appear. + ### RubyDoc Documentation -For the API documentation covering this gem's classes and packages, [visit the -RubyDoc page][]. +For the API documentation covering this gem's classes and packages, [visit the RubyDoc page](https://rubydoc.info/github/hotwired/turbo-rails/main). +Note that this documentation is updated automatically from the main branch, so it may contain features that are not released yet. -[visit the RubyDoc page](https://rubydoc.info/github/hotwired/turbo-rails/main) +- [Turbo Drive Helpers](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/DriveHelper) +- [Turbo Frames Helpers](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/FramesHelper) +- [Turbo Streams View Helpers](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/StreamsHelper) +- [Turbo Streams Broadcast Methods](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/Broadcastable) +- [Turbo Streams Channel](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/StreamsChannel) +- [Turbo Native Navigation](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/Native/Navigation) +- [Turbo Test Assertions](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/TestAssertions) +- [Turbo Integration Test Assertions](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/TestAssertions/IntegrationTestAssertions) +- [Turbo Broadcastable Test Helper](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/Broadcastable/TestHelper) ## Compatibility with Rails UJS @@ -140,17 +147,61 @@ Turbo can coexist with Rails UJS, but you need to take a series of upgrade steps ## Testing - -The [`Turbo::TestAssertions`](./lib/turbo/test_assertions.rb) concern provides Turbo Stream test helpers that assert the presence or absence of `` elements in a rendered fragment of HTML. `Turbo::TestAssertions` are automatically included in [`ActiveSupport::TestCase`](https://edgeapi.rubyonrails.org/classes/ActiveSupport/TestCase.html) and depend on the presence of [`rails-dom-testing`](https://github.com/rails/rails-dom-testing/) assertions. +The [`Turbo::TestAssertions`](./lib/turbo/test_assertions.rb) concern provides Turbo Stream test helpers that assert the presence or absence ofs s `` elements in a rendered fragment of HTML. `Turbo::TestAssertions` are automatically included in [`ActiveSupport::TestCase`](https://edgeapi.rubyonrails.org/classes/ActiveSupport/TestCase.html) and depend on the presence of [`rails-dom-testing`](https://github.com/rails/rails-dom-testing/) assertions. The [`Turbo::TestAssertions::IntegrationTestAssertions`](./lib/turbo/test_assertions/integration_test_assertions.rb) are built on top of `Turbo::TestAssertions`, and add support for passing a `status:` keyword. They are automatically included in [`ActionDispatch::IntegrationTest`](https://edgeguides.rubyonrails.org/testing.html#integration-testing). -The [`Turbo::Broadcastable::TestHelper`](./lib/turbo/broadcastable/test_helper.rb) concern provides Action Cable-aware test helpers that assert that `` elements were or were not broadcast over Action Cable. They are not automatically included. To use them in your tests, make sure to `include Turbo::Broadcastable::TestHelper`. +The [`Turbo::Broadcastable::TestHelper`](./lib/turbo/broadcastable/test_helper.rb) concern provides Action Cable-aware test helpers that assert that `` elements were or were not broadcast over Action Cable. `Turbo::Broadcastable::TestHelper` is automatically included in [`ActiveSupport::TestCase`](https://edgeapi.rubyonrails.org/classes/ActiveSupport/TestCase.html). + +### Rendering Outside of a Request + +Turbo utilizes [ActionController::Renderer][] to render templates and partials +outside the context of the request-response cycle. If you need to render a +Turbo-aware template, partial, or component, use [ActionController::Renderer][]: + +```ruby +ApplicationController.renderer.render template: "posts/show", assigns: { post: Post.first } # => "…" +PostsController.renderer.render :show, assigns: { post: Post.first } # => "…" +``` + +As a shortcut, you can also call render directly on the controller class itself: + +```ruby +ApplicationController.render template: "posts/show", assigns: { post: Post.first } # => "…" +PostsController.render :show, assigns: { post: Post.first } # => "…" +``` + +[ActionController::Renderer]: https://api.rubyonrails.org/classes/ActionController/Renderer.html ## Development Run the tests with `./bin/test`. +### Using local Turbo version + +Often you might want to test changes made locally to [Turbo lib](https://github.com/hotwired/turbo) itself. To package your local development version of Turbo you can use [yarn link](https://classic.yarnpkg.com/lang/en/docs/cli/link/) feature: + +```sh +cd +yarn link + +cd +yarn link @hotwired/turbo + +# Build the JS distribution files... +yarn build +# ...and commit the changes +``` + +Now you can reference your version of turbo-rails in your Rails projects packaged with your local version of Turbo. + +## Contributing + +Having a way to reproduce your issue will help people confirm, investigate, and ultimately fix your issue. You can do this by providing an executable test case. To make this process easier, we have prepared an [executable bug report Rails application](./bug_report_template.rb) for you to use as a starting point. + +This template includes the boilerplate code to set up a System Test case. Copy the content of the template into a `.rb` file and make the necessary changes to demonstrate the issue. You can execute it by running `ruby the_file.rb` in your terminal. If all goes well, you should see your test case failing. + +You can then share your executable test case as a gist or paste the content into the issue description. ## License diff --git a/app/assets/javascripts/turbo.js b/app/assets/javascripts/turbo.js index 971cfac7..3affd580 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 8.0.6 +Copyright © 2024 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" ]; + return [ "disabled", "loading", "src" ]; } constructor() { super(); - this.loaded = Promise.resolve(); this.delegate = new FrameElement.delegateConstructor(this); } connectedCallback() { @@ -100,11 +90,9 @@ class FrameElement extends HTMLElement { attributeChangedCallback(name) { if (name == "loading") { this.delegate.loadingStyleChanged(); - } else if (name == "complete") { - this.delegate.completeChanged(); } else if (name == "src") { this.delegate.sourceURLChanged(); - } else { + } else if (name == "disabled") { this.delegate.disabledChanged(); } } @@ -118,6 +106,19 @@ 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 shouldReloadWithMorph() { + return this.src && this.refresh === "morph"; + } get loading() { return frameLoadingStyleFromString(this.getAttribute("loading") || ""); } @@ -155,8 +156,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"); } } @@ -170,115 +170,11 @@ function frameLoadingStyleFromString(style) { } } -function expandURL(locatable) { - return new URL(locatable.toString(), document.baseURI); -} - -function getAnchor(url) { - let anchorMatch; - if (url.hash) { - return url.hash.slice(1); - } else if (anchorMatch = url.href.match(/#(.*)$/)) { - return anchorMatch[1]; - } -} - -function getAction(form, submitter) { - const action = (submitter === null || submitter === void 0 ? void 0 : submitter.getAttribute("formaction")) || form.getAttribute("action") || form.action; - return expandURL(action); -} - -function getExtension(url) { - return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || ""; -} - -function isHTML(url) { - return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/); -} - -function isPrefixedBy(baseURL, url) { - const prefix = getPrefix(url); - return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix); -} - -function locationIsVisitable(location, rootLocation) { - return isPrefixedBy(location, rootLocation) && isHTML(location); -} - -function getRequestURL(url) { - const anchor = getAnchor(url); - return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href; -} - -function toCacheKey(url) { - return getRequestURL(url); -} - -function urlsAreEqual(left, right) { - return expandURL(left).href == expandURL(right).href; -} - -function getPathComponents(url) { - return url.pathname.split("/").slice(1); -} - -function getLastPathComponent(url) { - return getPathComponents(url).slice(-1)[0]; -} - -function getPrefix(url) { - return addTrailingSlash(url.origin + url.pathname); -} - -function addTrailingSlash(value) { - return value.endsWith("/") ? value : value + "/"; -} - -class FetchResponse { - constructor(response) { - this.response = response; - } - get succeeded() { - return this.response.ok; - } - get failed() { - return !this.succeeded; - } - get clientError() { - return this.statusCode >= 400 && this.statusCode <= 499; - } - get serverError() { - return this.statusCode >= 500 && this.statusCode <= 599; - } - get redirected() { - return this.response.redirected; - } - get location() { - return expandURL(this.response.url); - } - get isHTML() { - return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/); - } - get statusCode() { - return this.response.status; - } - get contentType() { - return this.header("Content-Type"); - } - get responseText() { - return this.response.clone().text(); - } - get responseHTML() { - if (this.isHTML) { - return this.response.clone().text(); - } else { - return Promise.resolve(undefined); - } - } - header(name) { - return this.response.headers.get(name); - } -} +const drive = { + enabled: true, + progressBarDelay: 500, + unvisitableExtensions: new Set([ ".7z", ".aac", ".apk", ".avi", ".bmp", ".bz2", ".css", ".csv", ".deb", ".dmg", ".doc", ".docx", ".exe", ".gif", ".gz", ".heic", ".heif", ".ico", ".iso", ".jpeg", ".jpg", ".js", ".json", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".msi", ".ogg", ".ogv", ".pdf", ".pkg", ".png", ".ppt", ".pptx", ".rar", ".rtf", ".svg", ".tar", ".tif", ".tiff", ".txt", ".wav", ".webm", ".webp", ".wma", ".wmv", ".xls", ".xlsx", ".xml", ".zip" ]) +}; function activateScriptElement(element) { if (element.getAttribute("data-turbo-eval") == "false") { @@ -323,6 +219,19 @@ function dispatch(eventName, {target: target, cancelable: cancelable, detail: de return event; } +function cancelEvent(event) { + event.preventDefault(); + event.stopImmediatePropagation(); +} + +function nextRepaint() { + if (document.visibilityState === "hidden") { + return nextEventLoopTick(); + } else { + return nextAnimationFrame(); + } +} + function nextAnimationFrame() { return new Promise((resolve => requestAnimationFrame((() => resolve())))); } @@ -370,7 +279,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 +365,233 @@ 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 doesNotTargetIFrame(name) { + if (name === "_blank") { + return false; + } else if (name) { + for (const element of document.getElementsByName(name)) { + if (element instanceof HTMLIFrameElement) return false; + } + return true; + } else { + return true; + } +} -(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 findLinkFromClickTarget(target) { + return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])"); +} + +function getLocationForLink(link) { + return expandURL(link.getAttribute("href") || ""); +} + +function debounce(fn, delay) { + let timeoutId = null; + return (...args) => { + const callback = () => fn.apply(this, args); + clearTimeout(timeoutId); + timeoutId = setTimeout(callback, delay); + }; +} + +const submitter = { + "aria-disabled": { + beforeSubmit: submitter => { + submitter.setAttribute("aria-disabled", "true"); + submitter.addEventListener("click", cancelEvent); + }, + afterSubmit: submitter => { + submitter.removeAttribute("aria-disabled"); + submitter.removeEventListener("click", cancelEvent); + } + }, + disabled: { + beforeSubmit: submitter => submitter.disabled = true, + afterSubmit: submitter => submitter.disabled = false + } +}; + +class Config { + #submitter=null; + constructor(config) { + Object.assign(this, config); + } + get submitter() { + return this.#submitter; + } + set submitter(value) { + this.#submitter = submitter[value] || value; + } +} + +const forms = new Config({ + mode: "on", + submitter: "disabled" +}); + +const config = { + drive: drive, + forms: forms +}; + +function expandURL(locatable) { + return new URL(locatable.toString(), document.baseURI); +} + +function getAnchor(url) { + let anchorMatch; + if (url.hash) { + return url.hash.slice(1); + } else if (anchorMatch = url.href.match(/#(.*)$/)) { + return anchorMatch[1]; + } +} + +function getAction$1(form, submitter) { + const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action; + return expandURL(action); +} + +function getExtension(url) { + return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || ""; +} + +function isPrefixedBy(baseURL, url) { + const prefix = getPrefix(url); + return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix); +} + +function locationIsVisitable(location, rootLocation) { + return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location)); +} + +function getRequestURL(url) { + const anchor = getAnchor(url); + return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href; +} + +function toCacheKey(url) { + return getRequestURL(url); +} + +function urlsAreEqual(left, right) { + return expandURL(left).href == expandURL(right).href; +} + +function getPathComponents(url) { + return url.pathname.split("/").slice(1); +} + +function getLastPathComponent(url) { + return getPathComponents(url).slice(-1)[0]; +} + +function getPrefix(url) { + return addTrailingSlash(url.origin + url.pathname); +} + +function addTrailingSlash(value) { + return value.endsWith("/") ? value : value + "/"; +} + +class FetchResponse { + constructor(response) { + this.response = response; + } + get succeeded() { + return this.response.ok; + } + get failed() { + return !this.succeeded; + } + get clientError() { + return this.statusCode >= 400 && this.statusCode <= 499; + } + get serverError() { + return this.statusCode >= 500 && this.statusCode <= 599; + } + get redirected() { + return this.response.redirected; + } + get location() { + return expandURL(this.response.url); + } + get isHTML() { + return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/); + } + get statusCode() { + return this.response.status; + } + get contentType() { + return this.header("Content-Type"); + } + get responseText() { + return this.response.clone().text(); + } + get responseHTML() { + if (this.isHTML) { + return this.response.clone().text(); + } else { + return Promise.resolve(undefined); + } + } + header(name) { + return this.response.headers.get(name); + } +} + +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); + } +} + +const recentRequests = new LimitedSet(20); + +const nativeFetch = window.fetch; + +function fetchWithTurboHeaders(url, options = {}) { + const modifiedHeaders = new Headers(options.headers || {}); + const requestUID = uuid(); + recentRequests.add(requestUID); + modifiedHeaders.append("X-Turbo-Request-Id", requestUID); + return nativeFetch(url, { + ...options, + headers: modifiedHeaders + }); +} function fetchMethodFromString(method) { switch (method.toLowerCase()) { @@ -491,16 +612,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.toUpperCase(), + 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.toUpperCase(); + } + 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 +703,19 @@ class FetchRequest { async perform() { const {fetchOptions: fetchOptions} = this; this.delegate.prepareRequest(this); - await this.allowRequestToBeIntercepted(fetchOptions); + const event = await this.#allowRequestToBeIntercepted(fetchOptions); try { this.delegate.requestStarted(this); - const response = await fetch(this.url.href, fetchOptions); + if (event.detail.fetchRequest) { + this.response = event.detail.fetchRequest.response; + } else { + this.response = fetchWithTurboHeaders(this.url.href, fetchOptions); + } + const response = await this.response; 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 +742,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 +756,22 @@ 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; + return event; } - willDelegateErrorHandling(error) { + #willDelegateErrorHandling(error) { const event = dispatch("turbo:fetch-request-error", { target: this.target, cancelable: true, @@ -603,15 +784,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 { - 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); - } - }; + started=false; + constructor(delegate, element) { this.delegate = delegate; this.element = element; this.intersectionObserver = new IntersectionObserver(this.intersect); @@ -628,9 +832,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 +854,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,91 +865,96 @@ function importStreamElements(fragment) { return fragment; } -var FormSubmissionState; +const PREFETCH_DELAY = 100; -(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 = {})); +class PrefetchCache { + #prefetchTimeout=null; + #prefetched=null; + get(url) { + if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) { + return this.#prefetched.request; + } + } + setLater(url, request, ttl) { + this.clear(); + this.#prefetchTimeout = setTimeout((() => { + request.perform(); + this.set(url, request, ttl); + this.#prefetchTimeout = null; + }), PREFETCH_DELAY); + } + set(url, request, ttl) { + this.#prefetched = { + url: url, + request: request, + expire: new Date((new Date).getTime() + ttl) + }; + } + clear() { + if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout); + this.#prefetched = null; + } +} -function formEnctypeFromString(encoding) { - switch (encoding.toLowerCase()) { - case FormEnctype.multipart: - return FormEnctype.multipart; +const cacheTtl = 10 * 1e3; - case FormEnctype.plain: - return FormEnctype.plain; +const prefetchCache = new PrefetchCache; - default: - return FormEnctype.urlEncoded; - } -} +const FormSubmissionState = { + initialized: "initialized", + requesting: "requesting", + waiting: "waiting", + receiving: "receiving", + stopping: "stopping", + stopped: "stopped" +}; class FormSubmission { - static confirmMethod(message, _element, _submitter) { + state=FormSubmissionState.initialized; + static confirmMethod(message) { 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; const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement); if (typeof confirmationMessage === "string") { - const answer = await FormSubmission.confirmMethod(confirmationMessage, this.formElement, this.submitter); + const confirmMethod = typeof config.forms.confirm === "function" ? config.forms.confirm : FormSubmission.confirmMethod; + const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter); if (!answer) { return; } @@ -770,10 +984,10 @@ class FormSubmission { } } requestStarted(_request) { - var _a; this.state = FormSubmissionState.waiting; - (_a = this.submitter) === null || _a === void 0 ? void 0 : _a.setAttribute("disabled", ""); + if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter); this.setSubmitsWith(); + markAsBusy(this.formElement); dispatch("turbo:submit-start", { target: this.formElement, detail: { @@ -783,6 +997,7 @@ class FormSubmission { this.delegate.formSubmissionStarted(this); } requestPreventedHandlingResponse(request, response) { + prefetchCache.clear(); this.result = { success: response.succeeded, fetchResponse: response @@ -791,7 +1006,10 @@ class FormSubmission { requestSucceededWithResponse(request, response) { if (response.clientError || response.serverError) { this.delegate.formSubmissionFailedWithResponse(this, response); - } else if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { + return; + } + prefetchCache.clear(); + if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { const error = new Error("Form responses must redirect to another location"); this.delegate.formSubmissionErrored(this, error); } else { @@ -818,15 +1036,16 @@ 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"); + if (this.submitter) config.forms.submitter.afterSubmit(this.submitter); this.resetSubmitterText(); + clearBusyState(this.formElement); 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 +1076,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 +1105,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 +1151,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 +1181,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,29 +1198,37 @@ 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; - for (const element of document.getElementsByName(target)) { - if (element instanceof HTMLIFrameElement) return false; - } - return true; - } else { - return true; - } + const target = submitter?.getAttribute("formtarget") || form.getAttribute("target"); + return doesNotTargetIFrame(target); } class View { + #resolveRenderPromise=_value => {}; + #resolveInterceptionPromise=_value => {}; constructor(delegate, element) { - this.resolveRenderPromise = _value => {}; - this.resolveInterceptionPromise = _value => {}; this.delegate = delegate; this.element = element; } @@ -1051,29 +1274,31 @@ class View { return window; } async render(renderer) { - const {isPreview: isPreview, shouldRender: shouldRender, newSnapshot: snapshot} = renderer; + const {isPreview: isPreview, shouldRender: shouldRender, willRender: willRender, newSnapshot: snapshot} = renderer; + const shouldInvalidate = willRender; 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, - render: this.renderer.renderElement + resume: this.#resolveInterceptionPromise, + render: this.renderer.renderElement, + renderMethod: this.renderer.renderMethod }; const immediateRender = this.delegate.allowsImmediateRender(snapshot, 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 { + } else if (shouldInvalidate) { this.invalidate(renderer.reloadReason); } } @@ -1091,6 +1316,12 @@ class View { this.element.removeAttribute("data-turbo-preview"); } } + markVisitDirection(direction) { + this.element.setAttribute("data-turbo-visit-direction", direction); + } + unmarkVisitDirection() { + this.element.removeAttribute("data-turbo-visit-direction"); + } async renderSnapshot(renderer) { await renderer.render(); } @@ -1110,26 +1341,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,32 +1354,36 @@ class LinkInterceptor { document.removeEventListener("turbo:click", this.linkClicked); document.removeEventListener("turbo:before-visit", this.willVisit); } - respondsToEventTarget(target) { - const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; - return element && element.closest("turbo-frame, html") == this.element; + clickBubbled=event => { + if (this.clickEventIsSignificant(event)) { + this.clickEvent = event; + } else { + delete this.clickEvent; + } + }; + linkClicked=event => { + if (this.clickEvent && this.clickEventIsSignificant(event)) { + 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; + }; + clickEventIsSignificant(event) { + const target = event.composed ? event.target?.parentElement : event.target; + const element = findLinkFromClickTarget(target) || target; + return element instanceof Element && element.closest("turbo-frame, html") == this.element; } } 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,26 +1399,26 @@ 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 = findLinkFromClickTarget(target); + if (link && doesNotTargetIFrame(link.target)) { + const location = 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); } - findLinkFromClickTarget(target) { - return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])"); - } - getLocationForLink(link) { - return expandURL(link.getAttribute("href") || ""); - } -} - -function doesNotTargetIFrame(anchor) { - if (anchor.hasAttribute("target")) { - for (const element of document.getElementsByName(anchor.target)) { - if (element instanceof HTMLIFrameElement) return false; - } - return true; - } else { - return true; - } } class FormLinkClickObserver { @@ -1217,8 +1432,14 @@ class FormLinkClickObserver { stop() { this.linkInterceptor.stop(); } + canPrefetchRequestToLocation(link, location) { + return false; + } + prefetchAndCacheRequestToLocation(link, location) { + return; + } 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 +1512,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 +1530,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; @@ -1324,12 +1545,16 @@ class Renderer { get shouldRender() { return true; } + get shouldAutofocus() { + return true; + } get reloadReason() { return; } prepareToRender() { return; } + render() {} finishRendering() { if (this.resolvingFunctions) { this.resolvingFunctions.resolve(); @@ -1340,119 +1565,734 @@ class Renderer { await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback); } focusFirstAutofocusableElement() { - const element = this.connectedSnapshot.firstAutofocusableElement; - if (elementIsFocusable(element)) { - element.focus(); + if (this.shouldAutofocus) { + const element = this.connectedSnapshot.firstAutofocusableElement; + if (element) { + element.focus(); + } + } + } + enteringBardo(currentPermanentElement) { + if (this.#activeElement) return; + if (currentPermanentElement.contains(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; + } + } + get connectedSnapshot() { + return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot; + } + get currentElement() { + return this.currentSnapshot.element; + } + get newElement() { + return this.newSnapshot.element; + } + get permanentElementMap() { + return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot); + } + get renderMethod() { + return "replace"; + } +} + +class FrameRenderer extends Renderer { + static renderElement(currentElement, newElement) { + const destinationRange = document.createRange(); + destinationRange.selectNodeContents(currentElement); + destinationRange.deleteContents(); + const frameElement = newElement; + const sourceRange = frameElement.ownerDocument?.createRange(); + if (sourceRange) { + sourceRange.selectNodeContents(frameElement); + currentElement.appendChild(sourceRange.extractContents()); + } + } + constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { + super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender); + this.delegate = delegate; + } + get shouldRender() { + return true; + } + async render() { + await nextRepaint(); + this.preservingPermanentElements((() => { + this.loadFrameElement(); + })); + this.scrollFrameIntoView(); + await nextRepaint(); + this.focusFirstAutofocusableElement(); + await nextRepaint(); + this.activateScriptElements(); + } + loadFrameElement() { + this.delegate.willRenderFrame(this.currentElement, this.newElement); + this.renderElement(this.currentElement, this.newElement); + } + scrollFrameIntoView() { + if (this.currentElement.autoscroll || this.newElement.autoscroll) { + const element = this.currentElement.firstElementChild; + const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end"); + const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto"); + if (element) { + element.scrollIntoView({ + block: block, + behavior: behavior + }); + return true; + } + } + return false; + } + activateScriptElements() { + for (const inertScriptElement of this.newScriptElements) { + const activatedScriptElement = activateScriptElement(inertScriptElement); + inertScriptElement.replaceWith(activatedScriptElement); + } + } + get newScriptElements() { + return this.currentElement.querySelectorAll("script"); + } +} + +function readScrollLogicalPosition(value, defaultValue) { + if (value == "end" || value == "start" || value == "center" || value == "nearest") { + return value; + } else { + return defaultValue; + } +} + +function readScrollBehavior(value, defaultValue) { + if (value == "auto" || value == "smooth") { + return value; + } else { + return defaultValue; + } +} + +var Idiomorph = function() { + let EMPTY_SET = new Set; + let defaults = { + morphStyle: "outerHTML", + callbacks: { + beforeNodeAdded: noOp, + afterNodeAdded: noOp, + beforeNodeMorphed: noOp, + afterNodeMorphed: noOp, + beforeNodeRemoved: noOp, + afterNodeRemoved: noOp, + beforeAttributeUpdated: noOp + }, + head: { + 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 + } + }; + 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 ignoreValueOfActiveElement(possibleActiveElement, ctx) { + return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body; + } + function morphOldNodeTo(oldNode, newContent, ctx) { + if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; + oldNode.remove(); + ctx.callbacks.afterNodeRemoved(oldNode); + return null; + } else if (!isSoftMatch(oldNode, newContent)) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; + if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode; + oldNode.parentElement.replaceChild(newContent, oldNode); + ctx.callbacks.afterNodeAdded(newContent); + ctx.callbacks.afterNodeRemoved(oldNode); + return newContent; + } else { + if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode; + if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { + handleHeadElement(newContent, oldNode, ctx); + } else { + syncNodeFrom(newContent, oldNode, ctx); + if (!ignoreValueOfActiveElement(oldNode, ctx)) { + 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 ignoreAttribute(attr, to, updateType, ctx) { + if (attr === "value" && ctx.ignoreActiveValue && to === document.activeElement) { + return true; + } + return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false; + } + function syncNodeFrom(from, to, ctx) { + let type = from.nodeType; + if (type === 1) { + const fromAttributes = from.attributes; + const toAttributes = to.attributes; + for (const fromAttribute of fromAttributes) { + if (ignoreAttribute(fromAttribute.name, to, "update", ctx)) { + continue; + } + if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { + to.setAttribute(fromAttribute.name, fromAttribute.value); + } + } + for (let i = toAttributes.length - 1; 0 <= i; i--) { + const toAttribute = toAttributes[i]; + if (ignoreAttribute(toAttribute.name, to, "remove", ctx)) { + continue; + } + if (!from.hasAttribute(toAttribute.name)) { + to.removeAttribute(toAttribute.name); + } + } + } + if (type === 8 || type === 3) { + if (to.nodeValue !== from.nodeValue) { + to.nodeValue = from.nodeValue; + } + } + if (!ignoreValueOfActiveElement(to, ctx)) { + syncInputValue(from, to, ctx); + } + } + function syncBooleanAttribute(from, to, attributeName, ctx) { + if (from[attributeName] !== to[attributeName]) { + let ignoreUpdate = ignoreAttribute(attributeName, to, "update", ctx); + if (!ignoreUpdate) { + to[attributeName] = from[attributeName]; + } + if (from[attributeName]) { + if (!ignoreUpdate) { + to.setAttribute(attributeName, from[attributeName]); + } + } else { + if (!ignoreAttribute(attributeName, to, "remove", ctx)) { + to.removeAttribute(attributeName); + } + } } } - enteringBardo(currentPermanentElement) { - if (this.activeElement) return; - if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { - this.activeElement = this.currentSnapshot.activeElement; + function syncInputValue(from, to, ctx) { + if (from instanceof HTMLInputElement && to instanceof HTMLInputElement && from.type !== "file") { + let fromValue = from.value; + let toValue = to.value; + syncBooleanAttribute(from, to, "checked", ctx); + syncBooleanAttribute(from, to, "disabled", ctx); + if (!from.hasAttribute("value")) { + if (!ignoreAttribute("value", to, "remove", ctx)) { + to.value = ""; + to.removeAttribute("value"); + } + } else if (fromValue !== toValue) { + if (!ignoreAttribute("value", to, "update", ctx)) { + to.setAttribute("value", fromValue); + to.value = fromValue; + } + } + } else if (from instanceof HTMLOptionElement) { + syncBooleanAttribute(from, to, "selected", ctx); + } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { + let fromValue = from.value; + let toValue = to.value; + if (ignoreAttribute("value", to, "update", ctx)) { + return; + } + if (fromValue !== toValue) { + to.value = fromValue; + } + if (to.firstChild && to.firstChild.nodeValue !== fromValue) { + to.firstChild.nodeValue = fromValue; + } } } - leavingBardo(currentPermanentElement) { - if (currentPermanentElement.contains(this.activeElement) && this.activeElement instanceof HTMLElement) { - this.activeElement.focus(); - this.activeElement = null; + 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 mergeDefaults(config) { + let finalConfig = {}; + Object.assign(finalConfig, defaults); + Object.assign(finalConfig, config); + finalConfig.callbacks = {}; + Object.assign(finalConfig.callbacks, defaults.callbacks); + Object.assign(finalConfig.callbacks, config.callbacks); + finalConfig.head = {}; + Object.assign(finalConfig.head, defaults.head); + Object.assign(finalConfig.head, config.head); + return finalConfig; + } + function createMorphContext(oldNode, newContent, config) { + config = mergeDefaults(config); + return { + target: oldNode, + newContent: newContent, + config: config, + morphStyle: config.morphStyle, + ignoreActive: config.ignoreActive, + ignoreActiveValue: config.ignoreActiveValue, + idMap: createIdMap(oldNode, newContent), + deadIds: new Set, + callbacks: config.callbacks, + head: config.head + }; } - get connectedSnapshot() { - return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot; - } - get currentElement() { - return this.currentSnapshot.element; + 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; } - get newElement() { - return this.newSnapshot.element; + 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; } - get permanentElementMap() { - return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot); + 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 elementIsFocusable(element) { - return element && typeof element.focus == "function"; -} - -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(); - if (sourceRange) { - sourceRange.selectNodeContents(frameElement); - currentElement.appendChild(sourceRange.extractContents()); + function scoreElement(node1, node2, ctx) { + if (isSoftMatch(node1, node2)) { + return .5 + getIdIntersectionCount(ctx, node1, node2); } + return 0; } - constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { - super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender); - this.delegate = delegate; + function removeNode(tempNode, ctx) { + removeIdsFromConsideration(ctx, tempNode); + if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; + tempNode.remove(); + ctx.callbacks.afterNodeRemoved(tempNode); } - get shouldRender() { - return true; + function isIdInConsideration(ctx, id) { + return !ctx.deadIds.has(id); } - async render() { - await nextAnimationFrame(); - this.preservingPermanentElements((() => { - this.loadFrameElement(); - })); - this.scrollFrameIntoView(); - await nextAnimationFrame(); - this.focusFirstAutofocusableElement(); - await nextAnimationFrame(); - this.activateScriptElements(); + function idIsWithinNode(ctx, id, targetNode) { + let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; + return idSet.has(id); } - loadFrameElement() { - this.delegate.willRenderFrame(this.currentElement, this.newElement); - this.renderElement(this.currentElement, this.newElement); + function removeIdsFromConsideration(ctx, node) { + let idSet = ctx.idMap.get(node) || EMPTY_SET; + for (const id of idSet) { + ctx.deadIds.add(id); + } } - scrollFrameIntoView() { - if (this.currentElement.autoscroll || this.newElement.autoscroll) { - const element = this.currentElement.firstElementChild; - const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end"); - const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto"); - if (element) { - element.scrollIntoView({ - block: block, - behavior: behavior - }); - return true; + 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 false; - } - activateScriptElements() { - for (const inertScriptElement of this.newScriptElements) { - const activatedScriptElement = activateScriptElement(inertScriptElement); - inertScriptElement.replaceWith(activatedScriptElement); + 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; + } } } - get newScriptElements() { - return this.currentElement.querySelectorAll("script"); + function createIdMap(oldContent, newContent) { + let idMap = new Map; + populateIdMapForNode(oldContent, idMap); + populateIdMapForNode(newContent, idMap); + return idMap; } + return { + morph: morph, + defaults: defaults + }; +}(); + +function morphElements(currentElement, newElement, {callbacks: callbacks, ...options} = {}) { + Idiomorph.morph(currentElement, newElement, { + ...options, + callbacks: new DefaultIdiomorphCallbacks(callbacks) + }); } -function readScrollLogicalPosition(value, defaultValue) { - if (value == "end" || value == "start" || value == "center" || value == "nearest") { - return value; - } else { - return defaultValue; - } +function morphChildren(currentElement, newElement) { + morphElements(currentElement, newElement.children, { + morphStyle: "innerHTML" + }); } -function readScrollBehavior(value, defaultValue) { - if (value == "auto" || value == "smooth") { - return value; - } else { - return defaultValue; +class DefaultIdiomorphCallbacks { + #beforeNodeMorphed; + constructor({beforeNodeMorphed: beforeNodeMorphed} = {}) { + this.#beforeNodeMorphed = beforeNodeMorphed || (() => true); + } + beforeNodeAdded=node => !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)); + beforeNodeMorphed=(currentElement, newElement) => { + if (currentElement instanceof Element) { + if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + target: currentElement, + detail: { + currentElement: currentElement, + newElement: newElement + } + }); + return !event.defaultPrevented; + } else { + return false; + } + } + }; + beforeAttributeUpdated=(attributeName, target, mutationType) => { + const event = dispatch("turbo:before-morph-attribute", { + cancelable: true, + target: target, + detail: { + attributeName: attributeName, + mutationType: mutationType + } + }); + return !event.defaultPrevented; + }; + beforeNodeRemoved=node => this.beforeNodeMorphed(node); + afterNodeMorphed=(currentElement, newElement) => { + if (currentElement instanceof Element) { + dispatch("turbo:morph-element", { + target: currentElement, + detail: { + currentElement: currentElement, + newElement: newElement + } + }); + } + }; +} + +class MorphingFrameRenderer extends FrameRenderer { + static renderElement(currentElement, newElement) { + dispatch("turbo:before-frame-morph", { + target: currentElement, + detail: { + currentElement: currentElement, + newElement: newElement + } + }); + morphChildren(currentElement, newElement); } } class ProgressBar { + static animationDuration=300; static get defaultCSS() { return unindent` .turbo-progress-bar { @@ -1470,13 +2310,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 +2368,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 +2395,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 +2443,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 +2493,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 +2513,16 @@ 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 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 +2537,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 +2580,52 @@ 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" +}; + +const SystemStatusCode = { + networkFailure: 0, + timeoutFailure: -1, + contentTypeMismatch: -2 +}; -(function(SystemStatusCode) { - SystemStatusCode[SystemStatusCode["networkFailure"] = 0] = "networkFailure"; - SystemStatusCode[SystemStatusCode["timeoutFailure"] = -1] = "timeoutFailure"; - SystemStatusCode[SystemStatusCode["contentTypeMismatch"] = -2] = "contentTypeMismatch"; -})(SystemStatusCode || (SystemStatusCode = {})); +const Direction = { + advance: "forward", + restore: "back", + replace: "none" +}; 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, direction: direction} = { + ...defaultOptions, + ...options + }; this.action = action; this.historyChanged = historyChanged; this.referrer = referrer; @@ -1761,12 +2633,14 @@ class Visit { this.snapshotHTML = snapshotHTML; this.response = response; this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action); + this.isPageRefresh = this.view.isPageRefresh(this); this.visitCachedSnapshot = visitCachedSnapshot; this.willRender = willRender; this.updateHistory = updateHistory; this.scrolled = !willRender; this.shouldCacheSnapshot = shouldCacheSnapshot; this.acceptsStreamResponse = acceptsStreamResponse; + this.direction = direction || Direction[action]; } get adapter() { return this.delegate.adapter; @@ -1803,10 +2677,10 @@ class Visit { complete() { if (this.state == VisitState.started) { this.recordTimingMetric(TimingMetric.visitEnd); + this.adapter.visitCompleted(this); this.state = VisitState.completed; this.followRedirect(); if (!this.followedRedirect) { - this.adapter.visitCompleted(this); this.delegate.visitCompleted(this); } } @@ -1815,12 +2689,12 @@ 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; @@ -1867,8 +2741,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 { @@ -1901,12 +2775,11 @@ class Visit { const isPreview = this.shouldIssueRequest(); this.render((async () => { this.cacheSnapshot(); - if (this.isSamePage) { + if (this.isSamePage || this.isPageRefresh) { 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 +2789,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 +2861,7 @@ class Visit { this.finishRequest(); } performScroll() { - if (!this.scrolled && !this.view.forceReloaded) { + if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) { if (this.action == "restore") { this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop(); } else { @@ -2019,7 +2891,9 @@ class Visit { this.timingMetrics[metric] = (new Date).getTime(); } getTimingMetrics() { - return Object.assign({}, this.timingMetrics); + return { + ...this.timingMetrics + }; } getHistoryMethodForAction(action) { switch (action) { @@ -2052,11 +2926,17 @@ class Visit { async render(callback) { this.cancelRender(); await new Promise((resolve => { - this.frame = requestAnimationFrame((() => resolve())); + this.frame = document.visibilityState === "hidden" ? setTimeout((() => resolve()), 0) : requestAnimationFrame((() => resolve())); })); 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 +2950,16 @@ 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); + if (locationIsVisitable(location, this.navigator.rootLocation)) { + this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options); + } else { + window.location.href = location.toString(); + } } visitStarted(visit) { this.location = visit.location; @@ -2113,15 +2994,18 @@ class BrowserAdapter { return visit.loadResponse(); } } - visitRequestFinished(_visit) { + visitRequestFinished(_visit) {} + visitCompleted(_visit) { this.progressBar.setValue(1); this.hideVisitProgressBar(); } - visitCompleted(_visit) {} pageInvalidated(reason) { this.reload(reason); } - visitFailed(_visit) {} + visitFailed(_visit) { + this.progressBar.setValue(1); + this.hideVisitProgressBar(); + } visitRendered(_visit) {} formSubmissionStarted(_formSubmission) { this.progressBar.setValue(0); @@ -2153,12 +3037,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 +3052,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 +3067,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 +3100,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,32 +3144,20 @@ class FrameRedirector { } class History { + location; + restorationIdentifier=uuid(); + restorationData={}; + started=false; + pageLoaded=false; + currentIndex=0; 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() { if (!this.started) { addEventListener("popstate", this.onPopState, false); addEventListener("load", this.onPageLoad, false); + this.currentIndex = history.state?.turbo?.restorationIndex || 0; this.started = true; this.replace(new URL(window.location.href)); } @@ -2305,9 +3176,11 @@ class History { this.update(history.replaceState, location, restorationIdentifier); } update(method, location, restorationIdentifier = uuid()) { + if (method === history.pushState) ++this.currentIndex; const state = { turbo: { - restorationIdentifier: restorationIdentifier + restorationIdentifier: restorationIdentifier, + restorationIndex: this.currentIndex } }; method.call(history, state, "", location.href); @@ -2320,12 +3193,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 +3210,23 @@ 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, restorationIndex: restorationIndex} = turbo; + this.restorationIdentifier = restorationIdentifier; + const direction = restorationIndex > this.currentIndex ? "forward" : "back"; + this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction); + this.currentIndex = restorationIndex; + } + } + }; + onPageLoad=async _event => { + await nextMicrotask(); + this.pageLoaded = true; + }; shouldHandlePopState() { return this.pageIsLoaded(); } @@ -2343,24 +3235,154 @@ class History { } } +class LinkPrefetchObserver { + started=false; + #prefetchedLink=null; + constructor(delegate, eventTarget) { + this.delegate = delegate; + this.eventTarget = eventTarget; + } + start() { + if (this.started) return; + if (this.eventTarget.readyState === "loading") { + this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { + once: true + }); + } else { + this.#enable(); + } + } + stop() { + if (!this.started) return; + this.eventTarget.removeEventListener("mouseenter", this.#tryToPrefetchRequest, { + capture: true, + passive: true + }); + this.eventTarget.removeEventListener("mouseleave", this.#cancelRequestIfObsolete, { + capture: true, + passive: true + }); + this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); + this.started = false; + } + #enable=() => { + this.eventTarget.addEventListener("mouseenter", this.#tryToPrefetchRequest, { + capture: true, + passive: true + }); + this.eventTarget.addEventListener("mouseleave", this.#cancelRequestIfObsolete, { + capture: true, + passive: true + }); + this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); + this.started = true; + }; + #tryToPrefetchRequest=event => { + if (getMetaContent("turbo-prefetch") === "false") return; + const target = event.target; + const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])"); + if (isLink && this.#isPrefetchable(target)) { + const link = target; + const location = getLocationForLink(link); + if (this.delegate.canPrefetchRequestToLocation(link, location)) { + this.#prefetchedLink = link; + const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams, target); + prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl); + } + } + }; + #cancelRequestIfObsolete=event => { + if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest(); + }; + #cancelPrefetchRequest=() => { + prefetchCache.clear(); + this.#prefetchedLink = null; + }; + #tryToUsePrefetchedRequest=event => { + if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "GET") { + const cached = prefetchCache.get(event.detail.url.toString()); + if (cached) { + event.detail.fetchRequest = cached; + } + prefetchCache.clear(); + } + }; + prepareRequest(request) { + const link = request.target; + request.headers["X-Sec-Purpose"] = "prefetch"; + const turboFrame = link.closest("turbo-frame"); + const turboFrameTarget = link.getAttribute("data-turbo-frame") || turboFrame?.getAttribute("target") || turboFrame?.id; + if (turboFrameTarget && turboFrameTarget !== "_top") { + request.headers["Turbo-Frame"] = turboFrameTarget; + } + } + requestSucceededWithResponse() {} + requestStarted(fetchRequest) {} + requestErrored(fetchRequest) {} + requestFinished(fetchRequest) {} + requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} + requestFailedWithResponse(fetchRequest, fetchResponse) {} + get #cacheTtl() { + return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl; + } + #isPrefetchable(link) { + const href = link.getAttribute("href"); + if (!href) return false; + if (unfetchableLink(link)) return false; + if (linkToTheSamePage(link)) return false; + if (linkOptsOut(link)) return false; + if (nonSafeLink(link)) return false; + if (eventPrevented(link)) return false; + return true; + } +} + +const unfetchableLink = link => link.origin !== document.location.origin || ![ "http:", "https:" ].includes(link.protocol) || link.hasAttribute("target"); + +const linkToTheSamePage = link => link.pathname + link.search === document.location.pathname + document.location.search || link.href.startsWith("#"); + +const linkOptsOut = link => { + if (link.getAttribute("data-turbo-prefetch") === "false") return true; + if (link.getAttribute("data-turbo") === "false") return true; + const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]"); + if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") return true; + return false; +}; + +const nonSafeLink = link => { + const turboMethod = link.getAttribute("data-turbo-method"); + if (turboMethod && turboMethod.toLowerCase() !== "get") return true; + if (isUJS(link)) return true; + if (link.hasAttribute("data-turbo-confirm")) return true; + if (link.hasAttribute("data-turbo-stream")) return true; + return false; +}; + +const isUJS = link => link.hasAttribute("data-remote") || link.hasAttribute("data-behavior") || link.hasAttribute("data-confirm") || link.hasAttribute("data-method"); + +const eventPrevented = link => { + const event = dispatch("turbo:before-prefetch", { + target: link, + cancelable: true + }); + return event.defaultPrevented; +}; + class Navigator { constructor(delegate) { this.delegate = delegate; } proposeVisit(location, options = {}) { if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) { - if (locationIsVisitable(location, this.view.snapshot.rootLocation)) { - this.delegate.visitProposedToLocation(location, options); - } else { - window.location.href = location.toString(); - } + this.delegate.visitProposedToLocation(location, options); } } 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) { @@ -2384,6 +3406,9 @@ class Navigator { get view() { return this.delegate.view; } + get rootLocation() { + return this.view.snapshot.rootLocation; + } get history() { return this.delegate.history; } @@ -2401,7 +3426,7 @@ class Navigator { this.view.clearSnapshotCache(); } const {statusCode: statusCode, redirected: redirected} = fetchResponse; - const action = this.getActionForFormSubmission(formSubmission); + const action = this.#getActionForFormSubmission(formSubmission, fetchResponse); const visitOptions = { action: action, shouldCacheSnapshot: shouldCacheSnapshot, @@ -2424,7 +3449,9 @@ class Navigator { } else { await this.view.renderPage(snapshot, false, true, this.currentVisit); } - this.view.scrollToTop(); + if (!snapshot.shouldPreserveScrollPosition) { + this.view.scrollToTop(); + } this.view.clearSnapshotCache(); } } @@ -2441,6 +3468,7 @@ class Navigator { } visitCompleted(visit) { this.delegate.visitCompleted(visit); + delete this.currentVisit; } locationWithActionIsSamePage(location, action) { const anchor = getAnchor(location); @@ -2457,35 +3485,27 @@ class Navigator { get restorationIdentifier() { return this.history.restorationIdentifier; } - getActionForFormSubmission({submitter: submitter, formElement: formElement}) { - return getVisitAction(submitter, formElement) || "advance"; + #getActionForFormSubmission(formSubmission, fetchResponse) { + const {submitter: submitter, formElement: formElement} = formSubmission; + return getVisitAction(submitter, formElement) || this.#getDefaultAction(fetchResponse); + } + #getDefaultAction(fetchResponse) { + const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href; + return sameLocationRedirect ? "replace" : "advance"; } } -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 +3525,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 +3546,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 +3572,12 @@ class ScrollObserver { this.started = false; } } + onScroll=() => { + this.updatePosition({ + x: window.pageXOffset, + y: window.pageYOffset + }); + }; updatePosition(position) { this.delegate.scrollPositionChanged(position); } @@ -2554,7 +3585,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 +3614,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 nextRepaint(); + 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 +3693,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 +3717,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); } @@ -2702,6 +3783,7 @@ class PageRenderer extends Renderer { } } async prepareToRender() { + this.#setLanguage(); await this.mergeHead(); } async render() { @@ -2724,12 +3806,24 @@ 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(); this.copyNewHeadScriptElements(); await mergedHeadElements; await newStylesheetElements; + if (this.willRender) { + this.removeUnusedDynamicStylesheetElements(); + } } async replaceBody() { await this.preservingPermanentElements((async () => { @@ -2753,6 +3847,11 @@ class PageRenderer extends Renderer { document.head.appendChild(activateScriptElement(element)); } } + removeUnusedDynamicStylesheetElements() { + for (const element of this.unusedDynamicStylesheetElements) { + document.head.removeChild(element); + } + } async mergeProvisionalElements() { const newHeadElements = [ ...this.newHeadProvisionalElements ]; for (const element of this.currentHeadProvisionalElements) { @@ -2805,6 +3904,12 @@ class PageRenderer extends Renderer { async assignNewBody() { await this.renderElement(this.currentElement, this.newElement); } + get unusedDynamicStylesheetElements() { + return this.oldHeadStylesheetElements.filter((element => element.getAttribute("data-turbo-track") === "dynamic")); + } + get oldHeadStylesheetElements() { + return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot); + } get newHeadStylesheetElements() { return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot); } @@ -2822,10 +3927,42 @@ class PageRenderer extends Renderer { } } +class MorphingPageRenderer extends PageRenderer { + static renderElement(currentElement, newElement) { + morphElements(currentElement, newElement, { + callbacks: { + beforeNodeMorphed: element => !canRefreshFrame(element) + } + }); + for (const frame of currentElement.querySelectorAll("turbo-frame")) { + if (canRefreshFrame(frame)) frame.reload(); + } + dispatch("turbo:morph", { + detail: { + currentElement: currentElement, + newElement: newElement + } + }); + } + async preservingPermanentElements(callback) { + return await callback(); + } + get renderMethod() { + return "morph"; + } + get shouldAutofocus() { + return false; + } +} + +function canRefreshFrame(frame) { + return frame instanceof FrameElement && frame.src && frame.refresh === "morph" && !frame.closest("[data-turbo-permanent]"); +} + class SnapshotCache { + keys=[]; + snapshots={}; constructor(size) { - this.keys = []; - this.snapshots = {}; this.size = size; } has(location) { @@ -2867,23 +4004,25 @@ class SnapshotCache { } class PageView extends View { - constructor() { - super(...arguments); - this.snapshotCache = new SnapshotCache(10); - this.lastRenderedLocation = new URL(location.href); - this.forceReloaded = false; + snapshotCache=new SnapshotCache(10); + 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 ? MorphingPageRenderer : PageRenderer; + const renderer = new rendererClass(this.snapshot, snapshot, rendererClass.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); } @@ -2903,31 +4042,38 @@ class PageView extends View { getCachedSnapshotForLocation(location) { return this.snapshotCache.get(location); } + isPageRefresh(visit) { + return !visit || this.lastRenderedLocation.pathname === visit.location.pathname && visit.action === "replace"; + } + shouldPreserveScrollPosition(visit) { + return this.isPageRefresh(visit) && this.snapshot.shouldPreserveScrollPosition; + } get snapshot() { return PageSnapshot.fromElement(this.element); } } class Preloader { - constructor(delegate) { - this.selector = "a[data-turbo-preload]"; + selector="a[data-turbo-preload]"; + constructor(delegate, snapshotCache) { this.delegate = delegate; - } - get snapshotCache() { - return this.delegate.navigator.view.snapshotCache; + this.snapshotCache = snapshotCache; } start() { if (document.readyState === "loading") { - return document.addEventListener("DOMContentLoaded", (() => { - this.preloadOnLoadLinksForView(document.body); - })); + document.addEventListener("DOMContentLoaded", this.#preloadAll); } else { this.preloadOnLoadLinksForView(document.body); } } + stop() { + document.removeEventListener("DOMContentLoaded", this.#preloadAll); + } preloadOnLoadLinksForView(element) { for (const link of element.querySelectorAll(this.selector)) { - this.preloadURL(link); + if (this.delegate.shouldPreloadLink(link)) { + this.preloadURL(link); + } } } async preloadURL(link) { @@ -2935,46 +4081,80 @@ class Preloader { if (this.snapshotCache.has(location)) { return; } + const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams, link); + await fetchRequest.perform(); + } + prepareRequest(fetchRequest) { + fetchRequest.headers["X-Sec-Purpose"] = "prefetch"; + } + async requestSucceededWithResponse(fetchRequest, fetchResponse) { try { - const response = await fetch(location.toString(), { - headers: { - "VND.PREFETCH": "true", - Accept: "text/html" - } - }); - const responseText = await response.text(); - const snapshot = PageSnapshot.fromHTMLString(responseText); - this.snapshotCache.put(location, snapshot); + const responseHTML = await fetchResponse.responseHTML; + const snapshot = PageSnapshot.fromHTMLString(responseHTML); + this.snapshotCache.put(fetchRequest.url, snapshot); } catch (_) {} } + requestStarted(fetchRequest) {} + requestErrored(fetchRequest) {} + requestFinished(fetchRequest) {} + requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} + requestFailedWithResponse(fetchRequest, fetchResponse) {} + #preloadAll=() => { + this.preloadOnLoadLinksForView(document.body); + }; +} + +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); + } } 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"; + navigator=new Navigator(this); + history=new History(this); + view=new PageView(this, document.documentElement); + adapter=new BrowserAdapter(this); + pageObserver=new PageObserver(this); + cacheObserver=new CacheObserver; + linkPrefetchObserver=new LinkPrefetchObserver(this, document); + 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); + enabled=true; + started=false; + #pageRefreshDebouncePeriod=150; + constructor(recentRequests) { + this.recentRequests = recentRequests; + this.preloader = new Preloader(this, this.view.snapshotCache); + this.debouncedRefresh = this.refresh; + this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod; } start() { if (!this.started) { this.pageObserver.start(); this.cacheObserver.start(); + this.linkPrefetchObserver.start(); this.formLinkClickObserver.start(); this.linkClickObserver.start(); this.formSubmitObserver.start(); @@ -2994,6 +4174,7 @@ class Session { if (this.started) { this.pageObserver.stop(); this.cacheObserver.stop(); + this.linkPrefetchObserver.stop(); this.formLinkClickObserver.stop(); this.linkClickObserver.stop(); this.formSubmitObserver.stop(); @@ -3001,6 +4182,7 @@ class Session { this.streamObserver.stop(); this.frameRedirector.stop(); this.history.stop(); + this.preloader.stop(); this.started = false; } } @@ -3010,12 +4192,22 @@ class Session { visit(location, options = {}) { const frameElement = options.frame ? document.getElementById(options.frame) : null; if (frameElement instanceof FrameElement) { + const action = options.action || getVisitAction(frameElement); + frameElement.delegate.proposeVisitIfNavigatedWithAction(frameElement, action); frameElement.src = location.toString(); - frameElement.loaded; } else { this.navigator.proposeVisit(expandURL(location), options); } } + refresh(url, requestId) { + const isRecentRequest = requestId && this.recentRequests.has(requestId); + if (!isRecentRequest && !this.navigator.currentVisit) { + this.visit(url, { + action: "replace", + shouldCacheSnapshot: false + }); + } + } connectStreamSource(source) { this.streamObserver.connectStreamSource(source); } @@ -3029,10 +4221,26 @@ class Session { this.view.clearSnapshotCache(); } setProgressBarDelay(delay) { + console.warn("Please replace `session.setProgressBarDelay(delay)` with `session.progressBarDelay = delay`. The function is deprecated and will be removed in a future version of Turbo.`"); this.progressBarDelay = delay; } - setFormMode(mode) { - this.formMode = mode; + set progressBarDelay(delay) { + config.drive.progressBarDelay = delay; + } + get progressBarDelay() { + return config.drive.progressBarDelay; + } + set drive(value) { + config.drive.enabled = value; + } + get drive() { + return config.drive.enabled; + } + set formMode(value) { + config.forms.mode = value; + } + get formMode() { + return config.forms.mode; } get location() { return this.history.location; @@ -3040,11 +4248,31 @@ class Session { get restorationIdentifier() { return this.history.restorationIdentifier; } - historyPoppedToLocationWithRestorationIdentifier(location, restorationIdentifier) { + get pageRefreshDebouncePeriod() { + return this.#pageRefreshDebouncePeriod; + } + set pageRefreshDebouncePeriod(value) { + this.refresh = debounce(this.debouncedRefresh.bind(this), value); + this.#pageRefreshDebouncePeriod = value; + } + shouldPreloadLink(element) { + const isUnsafe = element.hasAttribute("data-turbo-method"); + const isStream = element.hasAttribute("data-turbo-stream"); + const frameTarget = element.getAttribute("data-turbo-frame"); + const frame = frameTarget == "_top" ? null : document.getElementById(frameTarget) || findClosestRecursively(element, "turbo-frame:not([disabled])"); + if (isUnsafe || isStream || frame instanceof FrameElement) { + return false; + } else { + const location = new URL(element.href); + return this.elementIsNavigatable(element) && locationIsVisitable(location, this.snapshot.rootLocation); + } + } + historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) { if (this.enabled) { this.navigator.startVisit(location, restorationIdentifier, { action: "restore", - historyChanged: true + historyChanged: true, + direction: direction }); } else { this.adapter.pageInvalidated({ @@ -3061,6 +4289,9 @@ class Session { return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation); } submittedFormLinkToLocation() {} + canPrefetchRequestToLocation(link, location) { + return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation); + } willFollowLinkToLocation(link, location, event) { return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) && this.applicationAllowsFollowingLinkToLocation(link, location, event); } @@ -3082,6 +4313,7 @@ class Session { visitStarted(visit) { if (!visit.acceptsStreamResponse) { markAsBusy(document.documentElement); + this.view.markVisitDirection(visit.direction); } extendURLWithDeprecatedProperties(visit.location); if (!visit.silent) { @@ -3089,6 +4321,7 @@ class Session { } } visitCompleted(visit) { + this.view.unmarkVisitDirection(); clearBusyState(document.documentElement); this.notifyApplicationAfterPageLoad(visit.getTimingMetrics()); } @@ -3099,7 +4332,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,8 +4352,7 @@ 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(); } } @@ -3132,9 +4364,9 @@ class Session { } return !defaultPrevented; } - viewRenderedSnapshot(_snapshot, _isPreview) { + viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) { this.view.lastRenderedLocation = this.history.location; - this.notifyApplicationAfterRender(); + this.notifyApplicationAfterRender(renderMethod); } preloadOnLoadLinksForView(element) { this.preloader.preloadOnLoadLinksForView(element); @@ -3187,14 +4419,19 @@ class Session { } notifyApplicationBeforeRender(newBody, options) { return dispatch("turbo:before-render", { - detail: Object.assign({ - newBody: newBody - }, options), + detail: { + newBody: newBody, + ...options + }, cancelable: true }); } - notifyApplicationAfterRender() { - return dispatch("turbo:render"); + notifyApplicationAfterRender(renderMethod) { + return dispatch("turbo:render", { + detail: { + renderMethod: renderMethod + } + }); } notifyApplicationAfterPageLoad(timing = {}) { return dispatch("turbo:load", { @@ -3225,11 +4462,11 @@ class Session { }); } submissionIsNavigatable(form, submitter) { - if (this.formMode == "off") { + if (config.forms.mode == "off") { return false; } else { const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true; - if (this.formMode == "optin") { + if (config.forms.mode == "optin") { return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null; } else { return submitterIsNavigatable && this.elementIsNavigatable(form); @@ -3239,7 +4476,7 @@ class Session { elementIsNavigatable(element) { const container = findClosestRecursively(element, "[data-turbo]"); const withinFrame = findClosestRecursively(element, "turbo-frame"); - if (this.drive || withinFrame) { + if (config.drive.enabled || withinFrame) { if (container) { return container.getAttribute("data-turbo") != "false"; } else { @@ -3273,67 +4510,9 @@ 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); - })); - }, - 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); - })); - }, - prepend() { - this.removeDuplicateTargetChildren(); - this.targetElements.forEach((e => e.prepend(this.templateContent))); - }, - remove() { - this.targetElements.forEach((e => e.remove())); - }, - replace() { - this.targetElements.forEach((e => e.replaceWith(this.templateContent))); - }, - update() { - this.targetElements.forEach((targetElement => { - targetElement.innerHTML = ""; - targetElement.append(this.templateContent); - })); - } -}; - -const session = new Session; - -const cache = new Cache(session); +const session = new Session(recentRequests); -const {navigator: navigator$1} = session; +const {cache: cache, navigator: navigator$1} = session; function start() { session.start(); @@ -3365,15 +4544,18 @@ function clearCache() { } function setProgressBarDelay(delay) { - session.setProgressBarDelay(delay); + console.warn("Please replace `Turbo.setProgressBarDelay(delay)` with `Turbo.config.drive.progressBarDelay = delay`. The top-level function is deprecated and will be removed in a future version of Turbo.`"); + config.drive.progressBarDelay = delay; } function setConfirmMethod(confirmMethod) { - FormSubmission.confirmMethod = confirmMethod; + console.warn("Please replace `Turbo.setConfirmMethod(confirmMethod)` with `Turbo.config.forms.confirm = confirmMethod`. The top-level function is deprecated and will be removed in a future version of Turbo.`"); + config.forms.confirm = confirmMethod; } function setFormMode(mode) { - session.setFormMode(mode); + console.warn("Please replace `Turbo.setFormMode(mode)` with `Turbo.config.forms.mode = mode`. The top-level function is deprecated and will be removed in a future version of Turbo.`"); + config.forms.mode = mode; } var Turbo = Object.freeze({ @@ -3384,6 +4566,8 @@ var Turbo = Object.freeze({ PageRenderer: PageRenderer, PageSnapshot: PageSnapshot, FrameRenderer: FrameRenderer, + fetch: fetchWithTurboHeaders, + config: config, start: start, registerAdapter: registerAdapter, visit: visit, @@ -3393,28 +4577,20 @@ var Turbo = Object.freeze({ clearCache: clearCache, setProgressBarDelay: setProgressBarDelay, setConfirmMethod: setConfirmMethod, - setFormMode: setFormMode, - StreamActions: StreamActions + setFormMode: setFormMode }); 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 +4600,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 +4613,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,45 +4623,46 @@ 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() { + if (this.element.shouldReloadWithMorph) { + this.element.addEventListener("turbo:before-frame-render", (({detail: detail}) => { + detail.render = MorphingFrameRenderer.renderElement; + }), { + once: true + }); + } const {src: src} = this.element; - this.ignoringChangesToAttribute("complete", (() => { - this.element.removeAttribute("complete"); - })); + this.element.removeAttribute("complete"); this.element.src = null; this.element.src = src; return this.element.loaded; } - completeChanged() { - 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 +4675,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.proposeVisitIfNavigatedWithAction(element, getVisitAction(element)); + 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 +4714,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,29 +4723,29 @@ 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); - frame.delegate.proposeVisitIfNavigatedWithAction(frame, formSubmission.formElement, formSubmission.submitter); + const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter); + frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(formSubmission.submitter, formSubmission.formElement, frame)); frame.delegate.loadResponse(response); if (!formSubmission.isSafe) { session.clearCache(); @@ -3583,14 +4759,15 @@ class FrameController { console.error(error); } formSubmissionFinished({formElement: formElement}) { - clearBusyState(formElement, this.findFrameElement(formElement)); + clearBusyState(formElement, this.#findFrameElement(formElement)); } allowsImmediateRender({element: newFrame}, 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 +4776,7 @@ class FrameController { } return !defaultPrevented; } - viewRenderedSnapshot(_snapshot, _isPreview) {} + viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {} preloadOnLoadLinksForView(element) { session.preloadOnLoadLinksForView(element); } @@ -3607,7 +4784,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,41 +4802,40 @@ 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); - frame.delegate.proposeVisitIfNavigatedWithAction(frame, element, submitter); - this.withCurrentNavigationElement(element, (() => { + #navigateFrame(element, url, submitter) { + const frame = this.#findFrameElement(element, submitter); + frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(submitter, element, frame)); + this.#withCurrentNavigationElement(element, (() => { frame.src = url; })); } - proposeVisitIfNavigatedWithAction(frame, element, submitter) { - this.action = getVisitAction(submitter, element, frame); + proposeVisitIfNavigatedWithAction(frame, action = null) { + this.action = action; 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 +4861,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 +4885,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 +4905,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 +4928,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,46 +4966,43 @@ 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", (() => { - if (value) { - this.element.setAttribute("complete", ""); - } else { - this.element.removeAttribute("complete"); - } - })); + if (value) { + this.element.setAttribute("complete", ""); + } else { + this.element.removeAttribute("complete"); + } } 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; @@ -3856,6 +5035,50 @@ function activateElement(element, currentURL) { } } +const StreamActions = { + after() { + 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 => e.parentElement?.insertBefore(this.templateContent, e))); + }, + prepend() { + this.removeDuplicateTargetChildren(); + this.targetElements.forEach((e => e.prepend(this.templateContent))); + }, + remove() { + this.targetElements.forEach((e => e.remove())); + }, + replace() { + const method = this.getAttribute("method"); + this.targetElements.forEach((targetElement => { + if (method === "morph") { + morphElements(targetElement, this.templateContent); + } else { + targetElement.replaceWith(this.templateContent); + } + })); + }, + update() { + const method = this.getAttribute("method"); + this.targetElements.forEach((targetElement => { + if (method === "morph") { + morphChildren(targetElement, this.templateContent); + } else { + targetElement.innerHTML = ""; + targetElement.append(this.templateContent); + } + })); + }, + refresh() { + session.refresh(this.baseURI, this.requestId); + } +}; + class StreamElement extends HTMLElement { static async renderElement(newElement) { await newElement.performAction(); @@ -3870,11 +5093,10 @@ 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(); + await nextRepaint(); await event.detail.render(this); } })(); @@ -3882,15 +5104,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 +5120,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 +5130,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 +5144,7 @@ class StreamElement extends HTMLElement { } else if (this.firstElementChild instanceof HTMLTemplateElement) { return this.firstElementChild; } - this.raise("first child element must be a