From cdf158f304e5db0bca684429a1a67be365bf9b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Tue, 6 Feb 2024 09:54:39 +0000 Subject: [PATCH 01/22] Enable InstaClick by default (#1162) * Enable InstaClick by default It will be the new default for Turbo 8. You can always opt out by setting `` in the head of your HTML. * Don't prefetch any anchor links * Don't prefetch UJS links For compatibility with older apps that use UJS, we should not prefetch links that have `data-remote`, `data-behavior`, `data-method`, or `data-confirm` attributes. All of these behaviors are now usually implemented as buttons, but there are still some apps that use them on links. * Allow to customize check to opt out of prefetched links * Tweak documentation * Introduce `turbo:before-prefetch` event To allow for more fine-grained control over when Turbo should prefetch links. This change introduces a new event that can be used to cancel prefetch requests based on custom logic. For example, if you want to prevent Turbo from prefetching links that include UJS attributes, you can do so by adding an event listener for the `turbo:before-prefetch` event and calling `preventDefault` on the event object when the link should not be prefetched. ```javascript document.body.addEventListener("turbo:before-prefetch", (event) => { if (isUJSLink(event.target)) event.preventDefault() }) function isUJSLink(link) { return link.hasAttribute("data-remote") || link.hasAttribute("data-behavior") || link.hasAttribute("data-method") || link.hasAttribute("data-confirm") } ``` --- src/observers/link_prefetch_observer.js | 14 ++++++++++++-- src/tests/fixtures/hover_to_prefetch.html | 2 ++ .../functional/link_prefetch_observer_tests.js | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/observers/link_prefetch_observer.js b/src/observers/link_prefetch_observer.js index 165569dbd..a63fe6382 100644 --- a/src/observers/link_prefetch_observer.js +++ b/src/observers/link_prefetch_observer.js @@ -1,4 +1,5 @@ import { + dispatch, doesNotTargetIFrame, getLocationForLink, getMetaContent, @@ -58,7 +59,7 @@ export class LinkPrefetchObserver { } #tryToPrefetchRequest = (event) => { - if (getMetaContent("turbo-prefetch") !== "true") return + if (getMetaContent("turbo-prefetch") === "false") return const target = event.target const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])") @@ -134,7 +135,16 @@ export class LinkPrefetchObserver { #isPrefetchable(link) { const href = link.getAttribute("href") - if (!href || href === "#" || link.getAttribute("data-turbo") === "false" || link.getAttribute("data-turbo-prefetch") === "false") { + if (!href || href.startsWith("#") || link.getAttribute("data-turbo") === "false" || link.getAttribute("data-turbo-prefetch") === "false") { + return false + } + + const event = dispatch("turbo:before-prefetch", { + target: link, + cancelable: true + }) + + if (event.defaultPrevented) { return false } diff --git a/src/tests/fixtures/hover_to_prefetch.html b/src/tests/fixtures/hover_to_prefetch.html index 89b94f1bb..5ee33684d 100644 --- a/src/tests/fixtures/hover_to_prefetch.html +++ b/src/tests/fixtures/hover_to_prefetch.html @@ -27,6 +27,8 @@ >Won't prefetch when hovering me Won't prefetch when hovering me + Won't prefetch when hovering me Won't prefetch when hovering me { + await goTo({ page, path: "/hover_to_prefetch.html" }) + + await assertPrefetchedOnHover({ page, selector: "#anchor_with_remote_true" }) + + await page.evaluate(() => { + document.body.addEventListener("turbo:before-prefetch", (event) => { + if (event.target.hasAttribute("data-remote")) { + event.preventDefault() + } + }) + }) + + await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_remote_true" }) +}) + test("it doesn't prefetch the page when link has the same location", async ({ page }) => { await goTo({ page, path: "/hover_to_prefetch.html" }) await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_same_location" }) From 3c3eeb8471cf7ac6be3240dc5ee36722137f844f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Tue, 6 Feb 2024 10:35:07 +0000 Subject: [PATCH 02/22] Turbo v8.0.0-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c058de50a..ea072314c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hotwired/turbo", - "version": "8.0.0-rc.2", + "version": "8.0.0-rc.3", "description": "The speed of a single-page web application without having to write any JavaScript", "module": "dist/turbo.es2017-esm.js", "main": "dist/turbo.es2017-umd.js", From 52c8533c83bf02b428ddab6140ddfb663208cb06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Tue, 6 Feb 2024 20:17:34 +0000 Subject: [PATCH 03/22] Disable InstantClick for touch events (#1167) * Check if prefetch request is still valid on mouseleave and touchend events If it's not and the prefetch delay is not over, we cancel the prefetch request. * Disable InstantClick on touch devices After testing this on iPhone, it seems it can lead to duplicated requests on touch devices. The culprit seems a `mouseenter` that Safari fires after a `touchend` event for compatibility reasons. According to ChatGPT: > Some browsers may synthesize mouse events (including mouseenter) after > touch events to ensure compatibility with web content not designed for > touch interfaces. This means that a mouseenter event might be fired on > a touch device, usually after a touchend event, as part of the sequence > to simulate mouse interaction. This behavior can vary between browsers > and might not always be consistent. Co-Authored-By: Sean Doyle * Remove obsolete tests * Rename method --------- Co-authored-by: Sean Doyle --- src/observers/link_prefetch_observer.js | 26 +++++++++++++------ .../link_prefetch_observer_tests.js | 25 ------------------ 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/src/observers/link_prefetch_observer.js b/src/observers/link_prefetch_observer.js index a63fe6382..957898644 100644 --- a/src/observers/link_prefetch_observer.js +++ b/src/observers/link_prefetch_observer.js @@ -12,8 +12,7 @@ import { prefetchCache, cacheTtl } from "../core/drive/prefetch_cache" export class LinkPrefetchObserver { started = false - hoverTriggerEvent = "mouseenter" - touchTriggerEvent = "touchstart" + #prefetchedLink = null constructor(delegate, eventTarget) { this.delegate = delegate @@ -33,27 +32,29 @@ export class LinkPrefetchObserver { stop() { if (!this.started) return - this.eventTarget.removeEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, { + this.eventTarget.removeEventListener("mouseenter", this.#tryToPrefetchRequest, { capture: true, passive: true }) - this.eventTarget.removeEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, { + 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(this.hoverTriggerEvent, this.#tryToPrefetchRequest, { + this.eventTarget.addEventListener("mouseenter", this.#tryToPrefetchRequest, { capture: true, passive: true }) - this.eventTarget.addEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, { + this.eventTarget.addEventListener("mouseleave", this.#cancelRequestIfObsolete, { capture: true, passive: true }) + this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true) this.started = true } @@ -69,6 +70,8 @@ export class LinkPrefetchObserver { const location = getLocationForLink(link) if (this.delegate.canPrefetchRequestToLocation(link, location)) { + this.#prefetchedLink = link + const fetchRequest = new FetchRequest( this, FetchMethod.get, @@ -78,12 +81,19 @@ export class LinkPrefetchObserver { ) prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl) - - link.addEventListener("mouseleave", () => prefetchCache.clear(), { once: true }) } } } + #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()) diff --git a/src/tests/functional/link_prefetch_observer_tests.js b/src/tests/functional/link_prefetch_observer_tests.js index 56fe77d89..1505c08a5 100644 --- a/src/tests/functional/link_prefetch_observer_tests.js +++ b/src/tests/functional/link_prefetch_observer_tests.js @@ -240,11 +240,6 @@ test("it resets the cache when a link is hovered", async ({ page }) => { assert.equal(requestCount, 2) }) -test("it prefetches page on touchstart", async ({ page }) => { - await goTo({ page, path: "/hover_to_prefetch.html" }) - await assertPrefetchedOnTouchstart({ page, selector: "#anchor_for_prefetch" }) -}) - test("it does not make a network request when clicking on a link that has been prefetched", async ({ page }) => { await goTo({ page, path: "/hover_to_prefetch.html" }) await hoverSelector({ page, selector: "#anchor_for_prefetch" }) @@ -264,26 +259,6 @@ test("it follows the link using the cached response when clicking on a link that assert.equal(await page.title(), "Prefetched Page") }) -const assertPrefetchedOnTouchstart = async ({ page, selector, callback }) => { - let requestMade = false - - page.on("request", (request) => { - callback && callback(request) - requestMade = true - }) - - const selectorXY = await page.$eval(selector, (el) => { - const { x, y } = el.getBoundingClientRect() - return { x, y } - }) - - await page.touchscreen.tap(selectorXY.x, selectorXY.y) - - await sleep(100) - - assertRequestMade(requestMade) -} - const assertPrefetchedOnHover = async ({ page, selector, callback }) => { let requestMade = false From aef1abd63b9d5b044614dd9cfd9b1c713d28c834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Wed, 7 Feb 2024 10:02:26 +0000 Subject: [PATCH 04/22] =?UTF-8?q?Turbo=20v8.0.0=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ea072314c..6af366d57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hotwired/turbo", - "version": "8.0.0-rc.3", + "version": "8.0.0", "description": "The speed of a single-page web application without having to write any JavaScript", "module": "dist/turbo.es2017-esm.js", "main": "dist/turbo.es2017-umd.js", From b815594ee9b072478d9fbf25ca056f2d5c5a0774 Mon Sep 17 00:00:00 2001 From: Adrien S Date: Wed, 7 Feb 2024 15:44:58 +0100 Subject: [PATCH 05/22] Fix progress bar persisting when following a redirect (#1168) --- src/core/drive/visit.js | 2 +- src/tests/functional/navigation_tests.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/drive/visit.js b/src/core/drive/visit.js index 3adad253c..ec7565979 100644 --- a/src/core/drive/visit.js +++ b/src/core/drive/visit.js @@ -136,11 +136,11 @@ export 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) } } diff --git a/src/tests/functional/navigation_tests.js b/src/tests/functional/navigation_tests.js index cdbc90e22..e1aa7e757 100644 --- a/src/tests/functional/navigation_tests.js +++ b/src/tests/functional/navigation_tests.js @@ -379,6 +379,7 @@ test("following a redirection", async ({ page }) => { await nextBody(page) assert.equal(pathname(page.url()), "/src/tests/fixtures/one.html") assert.equal(await visitAction(page), "replace") + await waitUntilNoSelector(page, ".turbo-progress-bar") }) test("clicking the back button after redirection", async ({ page }) => { From e2e5782776e565e80358c5ecd34e05c3b3bcf28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Thu, 8 Feb 2024 10:11:39 +0000 Subject: [PATCH 06/22] Turbo v8.0.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6af366d57..a6275c4ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hotwired/turbo", - "version": "8.0.0", + "version": "8.0.1", "description": "The speed of a single-page web application without having to write any JavaScript", "module": "dist/turbo.es2017-esm.js", "main": "dist/turbo.es2017-umd.js", From 38e7bd27f26b50377faecf218eab862c35efffb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Fri, 9 Feb 2024 09:28:08 +0000 Subject: [PATCH 07/22] Add more prefetch conditions (#1178) * Clean up method to decide if a link is prefetchable * Add more conditions where a link is not considered safe to prefetch Exclude links with data-turbo-stream or associated with UJS behavior. * We no longer preload data-turbo-stream links * Update test * Add some more test cases --- src/observers/link_prefetch_observer.js | 76 +++++++++---------- src/tests/fixtures/hover_to_prefetch.html | 4 + .../link_prefetch_observer_tests.js | 28 ++++--- 3 files changed, 56 insertions(+), 52 deletions(-) diff --git a/src/observers/link_prefetch_observer.js b/src/observers/link_prefetch_observer.js index 957898644..6265f075a 100644 --- a/src/observers/link_prefetch_observer.js +++ b/src/observers/link_prefetch_observer.js @@ -1,12 +1,10 @@ import { dispatch, - doesNotTargetIFrame, getLocationForLink, getMetaContent, findClosestRecursively } from "../util" -import { StreamMessage } from "../core/streams/stream_message" import { FetchMethod, FetchRequest } from "../http/fetch_request" import { prefetchCache, cacheTtl } from "../core/drive/prefetch_cache" @@ -118,10 +116,6 @@ export class LinkPrefetchObserver { if (turboFrameTarget && turboFrameTarget !== "_top") { request.headers["Turbo-Frame"] = turboFrameTarget } - - if (link.hasAttribute("data-turbo-stream")) { - request.acceptResponseType(StreamMessage.contentType) - } } // Fetch request interface @@ -145,50 +139,52 @@ export class LinkPrefetchObserver { #isPrefetchable(link) { const href = link.getAttribute("href") - if (!href || href.startsWith("#") || link.getAttribute("data-turbo") === "false" || link.getAttribute("data-turbo-prefetch") === "false") { - return false - } + if (!href) return false - const event = dispatch("turbo:before-prefetch", { - target: link, - cancelable: true - }) + 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 - if (event.defaultPrevented) { - return false - } + return true + } +} - if (link.origin !== document.location.origin) { - return false - } +const unfetchableLink = (link) => { + return link.origin !== document.location.origin || !["http:", "https:"].includes(link.protocol) || link.hasAttribute("target") +} - if (!["http:", "https:"].includes(link.protocol)) { - return false - } +const linkToTheSamePage = (link) => { + return (link.pathname + link.search === document.location.pathname + document.location.search) || link.href.startsWith("#") +} - if (link.pathname + link.search === document.location.pathname + document.location.search) { - return false - } +const linkOptsOut = (link) => { + if (link.getAttribute("data-turbo-prefetch") === "false") return true + if (link.getAttribute("data-turbo") === "false") return true - const turboMethod = link.getAttribute("data-turbo-method") - if (turboMethod && turboMethod !== "get") { - return false - } + const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]") + if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") return true - if (targetsIframe(link)) { - return false - } + return false +} - const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]") +const nonSafeLink = (link) => { + const turboMethod = link.getAttribute("data-turbo-method") + if (turboMethod && turboMethod.toLowerCase() !== "get") return true - if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") { - return false - } + if (isUJS(link)) return true + if (link.hasAttribute("data-turbo-confirm")) return true + if (link.hasAttribute("data-turbo-stream")) return true - return true - } + return false +} + +const isUJS = (link) => { + return link.hasAttribute("data-remote") || link.hasAttribute("data-behavior") || link.hasAttribute("data-confirm") || link.hasAttribute("data-method") } -const targetsIframe = (link) => { - return !doesNotTargetIFrame(link) +const eventPrevented = (link) => { + const event = dispatch("turbo:before-prefetch", { target: link, cancelable: true }) + return event.defaultPrevented } diff --git a/src/tests/fixtures/hover_to_prefetch.html b/src/tests/fixtures/hover_to_prefetch.html index 5ee33684d..d47be49db 100644 --- a/src/tests/fixtures/hover_to_prefetch.html +++ b/src/tests/fixtures/hover_to_prefetch.html @@ -29,6 +29,10 @@ >Won't prefetch when hovering me Won't prefetch when hovering me + Won't prefetch when hovering me + Won't prefetch when hovering me Won't prefetch when hovering me { await goTo({ page, path: "/hover_to_prefetch.html" }) - await assertPrefetchedOnHover({ page, selector: "#anchor_with_remote_true" }) + await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) await page.evaluate(() => { document.body.addEventListener("turbo:before-prefetch", (event) => { @@ -75,9 +75,24 @@ test("allows to cancel prefetch requests with custom logic", async ({ page }) => }) }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) +}) + +test("it doesn't prefetch UJS links", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_remote_true" }) }) +test("it doesn't prefetch data-turbo-stream links", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_turbo_stream" }) +}) + +test("it doesn't prefetch data-turbo-confirm links", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_turbo_confirm" }) +}) + test("it doesn't prefetch the page when link has the same location", async ({ page }) => { await goTo({ page, path: "/hover_to_prefetch.html" }) await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_same_location" }) @@ -153,17 +168,6 @@ test("it caches the request for 1 millisecond when turbo-prefetch-cache-time is await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) }) -test("it adds text/vnd.turbo-stream.html header to the Accept header when link has data-turbo-stream", async ({ - page -}) => { - await goTo({ page, path: "/hover_to_prefetch.html" }) - await assertPrefetchedOnHover({ page, selector: "#anchor_with_turbo_stream", callback: (request) => { - const headers = request.headers()["accept"].split(",").map((header) => header.trim()) - - assert.includeMembers(headers, ["text/vnd.turbo-stream.html", "text/html", "application/xhtml+xml"]) - }}) -}) - test("it prefetches links with inner elements", async ({ page }) => { await goTo({ page, path: "/hover_to_prefetch.html" }) await assertPrefetchedOnHover({ page, selector: "#anchor_with_inner_elements" }) From fccb3a4e5b2a47e63b50efe8333ec7a3265006c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Fri, 9 Feb 2024 09:37:56 +0000 Subject: [PATCH 08/22] Turbo v8.0.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a6275c4ed..dd4c10852 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hotwired/turbo", - "version": "8.0.1", + "version": "8.0.2", "description": "The speed of a single-page web application without having to write any JavaScript", "module": "dist/turbo.es2017-esm.js", "main": "dist/turbo.es2017-umd.js", From 37d2dd6265e811c17f1a0d60fbd3538ca214a578 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Wed, 21 Feb 2024 09:42:02 -0500 Subject: [PATCH 09/22] Omit `ignoreActiveValue: true` Morph option Closes [#1194][] Rolls back [#1141][] Don't pass the `ignoreActiveValue: true` option when Morphing. To restore that behavior, applications can set `[data-turbo-permanent]` when form control receives focus (through a `focusin` event listener), then remove it if necessary when the form control loses focus (through a `focusout` event listener). [#1141]: https://github.com/hotwired/turbo/pull/1141/ [#1194]: https://github.com/hotwired/turbo/issues/1194 --- src/core/drive/morph_renderer.js | 1 - src/tests/fixtures/page_refresh.html | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js index 70ac6b585..2c5d14874 100644 --- a/src/core/drive/morph_renderer.js +++ b/src/core/drive/morph_renderer.js @@ -29,7 +29,6 @@ export class MorphRenderer extends PageRenderer { this.isMorphingTurboFrame = this.#isFrameReloadedWithMorph(currentElement) Idiomorph.morph(currentElement, newElement, { - ignoreActiveValue: true, morphStyle: morphStyle, callbacks: { beforeNodeAdded: this.#shouldAddElement, diff --git a/src/tests/fixtures/page_refresh.html b/src/tests/fixtures/page_refresh.html index 0dad87edb..35cb44354 100644 --- a/src/tests/fixtures/page_refresh.html +++ b/src/tests/fixtures/page_refresh.html @@ -14,6 +14,14 @@ const application = Application.start() + addEventListener("focusin", ({ target }) => { + if (target instanceof HTMLInputElement && !target.hasAttribute("data-turbo-permanent")) { + target.toggleAttribute("data-turbo-permanent", true) + + target.addEventListener("focusout", () => target.toggleAttribute("data-turbo-permanent", false), { once: true }) + } + }) + addEventListener("turbo:morph-element", ({ target }) => { for (const { element, context } of application.controllers) { if (element === target) { From cdd30795402b47d3772b3dc2315b57f73007a4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Wed, 21 Feb 2024 16:12:12 +0000 Subject: [PATCH 10/22] Ensure that page refreshes do not trigger a snapshot cache (#1196) Fixes a bug introduced in https://github.com/hotwired/turbo/pull/1146 `exemptPageFromPreview()` adds a `` tag to the `` setting `turbo-cache-control` to `no-preview`. However, since the MorphRenderer now inherits from the PageRenderer, it can update meta tags in the head and remove the `turbo-cache-control` tag. This means that the snapshot cache will be used for the next visit, which is not what we want. Specifying `shouldCacheSnapshot: false` in the `visit` options ensures that the snapshot cache is not used for the refresh visit. --- src/core/session.js | 3 +-- src/tests/functional/page_refresh_tests.js | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/session.js b/src/core/session.js index eb0c9880c..cdb978348 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -110,8 +110,7 @@ export class Session { refresh(url, requestId) { const isRecentRequest = requestId && this.recentRequests.has(requestId) if (!isRecentRequest) { - this.cache.exemptPageFromPreview() - this.visit(url, { action: "replace" }) + this.visit(url, { action: "replace", shouldCacheSnapshot: false }) } } diff --git a/src/tests/functional/page_refresh_tests.js b/src/tests/functional/page_refresh_tests.js index 06a041787..6619ae58e 100644 --- a/src/tests/functional/page_refresh_tests.js +++ b/src/tests/functional/page_refresh_tests.js @@ -33,6 +33,7 @@ test("async page refresh with turbo-stream", async ({ page }) => { await expect(page.locator("#title")).not.toHaveText("Updated") await expect(page.locator("#title")).toHaveText("Page to be refreshed") + expect(await noNextEventNamed(page, "turbo:before-cache")).toBeTruthy() }) test("dispatches a turbo:before-morph-element and turbo:morph-element event for each morphed element", async ({ page }) => { From 861fcca4eed3b2d97240284e99eafeb43d55fca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Wed, 21 Feb 2024 16:16:12 +0000 Subject: [PATCH 11/22] Turbo v8.0.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dd4c10852..5e7692f99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hotwired/turbo", - "version": "8.0.2", + "version": "8.0.3", "description": "The speed of a single-page web application without having to write any JavaScript", "module": "dist/turbo.es2017-esm.js", "main": "dist/turbo.es2017-umd.js", From 00527e50ed1a4298ce52f968f2a007f6cfd30776 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Feb 2024 10:22:19 +0000 Subject: [PATCH 12/22] Bump ip from 1.1.8 to 1.1.9 (#1197) Bumps [ip](https://github.com/indutny/node-ip) from 1.1.8 to 1.1.9. - [Commits](https://github.com/indutny/node-ip/compare/v1.1.8...v1.1.9) --- updated-dependencies: - dependency-name: ip dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 48b78418d..5b147bcc4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1916,9 +1916,9 @@ inherits@2.0.3: integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== ip@^1.1.5: - version "1.1.8" - resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48" - integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg== + version "1.1.9" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.9.tgz#8dfbcc99a754d07f425310b86a99546b1151e396" + integrity sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ== ipaddr.js@1.9.1: version "1.9.1" From a235658ab7fa3eff733bdefd3c114f4bc9c35f54 Mon Sep 17 00:00:00 2001 From: Adrien Siami Date: Thu, 8 Feb 2024 16:08:24 +0100 Subject: [PATCH 13/22] Stop reloading turbo frames when complete attribute changes --- src/core/frames/frame_controller.js | 22 ++++----------- src/elements/frame_element.js | 6 ++-- .../frame_refresh_after_navigation.html | 3 ++ src/tests/fixtures/page_refresh.html | 5 ++++ src/tests/functional/loading_tests.js | 11 -------- src/tests/functional/page_refresh_tests.js | 28 +++++++++++++++++++ 6 files changed, 44 insertions(+), 31 deletions(-) create mode 100644 src/tests/fixtures/frame_refresh_after_navigation.html diff --git a/src/core/frames/frame_controller.js b/src/core/frames/frame_controller.js index e82e097f4..07764fc86 100644 --- a/src/core/frames/frame_controller.js +++ b/src/core/frames/frame_controller.js @@ -90,20 +90,12 @@ export class FrameController { sourceURLReloaded() { const { 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() @@ -528,13 +520,11 @@ export class FrameController { } 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() { diff --git a/src/elements/frame_element.js b/src/elements/frame_element.js index 4feb36713..8dc2890f3 100644 --- a/src/elements/frame_element.js +++ b/src/elements/frame_element.js @@ -25,7 +25,7 @@ export class FrameElement extends HTMLElement { loaded = Promise.resolve() static get observedAttributes() { - return ["disabled", "complete", "loading", "src"] + return ["disabled", "loading", "src"] } constructor() { @@ -48,11 +48,9 @@ export 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() } } diff --git a/src/tests/fixtures/frame_refresh_after_navigation.html b/src/tests/fixtures/frame_refresh_after_navigation.html new file mode 100644 index 000000000..4b8f79d03 --- /dev/null +++ b/src/tests/fixtures/frame_refresh_after_navigation.html @@ -0,0 +1,3 @@ + +

Frame has been navigated

+
diff --git a/src/tests/fixtures/page_refresh.html b/src/tests/fixtures/page_refresh.html index 35cb44354..c0586677f 100644 --- a/src/tests/fixtures/page_refresh.html +++ b/src/tests/fixtures/page_refresh.html @@ -90,6 +90,11 @@

Frame to be morphed

Frame to be reloaded

+ +

Frame to be navigated then reset to its initial state after reload

+
Navigate + +
Preserve me! diff --git a/src/tests/functional/loading_tests.js b/src/tests/functional/loading_tests.js index f9c28f361..4390dfd57 100644 --- a/src/tests/functional/loading_tests.js +++ b/src/tests/functional/loading_tests.js @@ -119,17 +119,6 @@ test("navigating away from a page does not reload its frames", async ({ page }) assert.equal(requestLogs.length, 1) }) -test("removing the [complete] attribute of an eager frame reloads the content", async ({ page }) => { - await nextEventOnTarget(page, "frame", "turbo:frame-load") - await page.evaluate(() => document.querySelector("#loading-eager turbo-frame")?.removeAttribute("complete")) - await nextEventOnTarget(page, "frame", "turbo:frame-load") - - assert.ok( - await hasSelector(page, "#loading-eager turbo-frame[complete]"), - "sets the [complete] attribute after re-loading" - ) -}) - test("changing [src] attribute on a [complete] frame with loading=lazy defers navigation", async ({ page }) => { await page.click("#loading-lazy summary") await nextEventOnTarget(page, "hello", "turbo:frame-load") diff --git a/src/tests/functional/page_refresh_tests.js b/src/tests/functional/page_refresh_tests.js index 6619ae58e..c5c116c08 100644 --- a/src/tests/functional/page_refresh_tests.js +++ b/src/tests/functional/page_refresh_tests.js @@ -184,6 +184,34 @@ test("frames marked with refresh='morph' are excluded from full page morphing", await expect(page.locator("#refresh-morph")).toHaveText("Loaded morphed frame") }) +test("navigated frames without refresh attribute are reset after morphing", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.click("#refresh-after-navigation-link") + + await nextBeat() + + assert.ok( + await hasSelector(page, "#refresh-after-navigation-content"), + "navigates theframe" + ) + + await page.click("#form-submit") + + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + await nextBeat() + + assert.ok( + await hasSelector(page, "#refresh-after-navigation-link"), + "resets the frame" + ) + + assert.notOk( + await hasSelector(page, "#refresh-after-navigation-content"), + "does not reload the frame" + ) +}) + test("it preserves the scroll position when the turbo-refresh-scroll meta tag is 'preserve'", async ({ page }) => { await page.goto("/src/tests/fixtures/page_refresh.html") From 8b2228e2d45416f3964296f5495ba37a1eb279a5 Mon Sep 17 00:00:00 2001 From: omarluq Date: Wed, 14 Feb 2024 22:23:39 -0600 Subject: [PATCH 14/22] Add Turbo stream morph action --- src/core/streams/stream_actions.js | 37 ++++++++++++++++++++++++++ src/tests/unit/stream_element_tests.js | 13 +++++++++ 2 files changed, 50 insertions(+) diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js index 064e94ca4..a343e4333 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -1,4 +1,7 @@ import { session } from "../" +import { morph } from "idiomorph" +import { dispatch } from "../../util" + export const StreamActions = { after() { @@ -37,4 +40,38 @@ export const StreamActions = { refresh() { session.refresh(this.baseURI, this.requestId) } + + morph() { + this.targetElements.forEach((targetElement) => { + try { + const morphStyle = this.getAttribute("data-turbo-morph-style") || "outerHTML" + const ignoreActive = this.getAttribute("data-turbo-morph-ignore-active") || true + const ignoreActiveValue = this.getAttribute("data-turbo-morph-ignore-active-value") || true + const head = this.getAttribute("data-turbo-morph-head") || 'merge' + morph(targetElement, this.templateContent, { + morphStyle: morphStyle, + ignoreActive: ignoreActive, + ignoreActiveValue: ignoreActiveValue, + head: head, + callbacks: { + beforeNodeAdded: (node) => { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) + }, + afterNodeMorphed: (oldNode, newNode) => { + if (newNode instanceof HTMLElement) { + dispatch("turbo:morph-element", { + target: oldNode, + detail: { + newElement: newNode + } + }) + } + } + } + }) + } catch (error) { + console.error(error) + } + }) + } } diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index 21a9ca8aa..c084c8e9f 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -196,3 +196,16 @@ test("test action=refresh discarded when matching request id", async () => { assert.ok(document.body.hasAttribute("data-modified")) }) + +test("action=morph", async () => { + const templateElement = createTemplateElement(`
Hello Turbo Morphed
`) + const element = createStreamElement("morph", "hello", templateElement) + + assert.equal(subject.find("#hello")?.textContent, "Hello Turbo") + + subject.append(element) + await nextAnimationFrame() + + assert.notOk(subject.find("#hello")?.textContent, "Hello Turbo") + assert.equal(subject.find("#hello")?.textContent, "Hello Turbo Morphed") +}) From 268dfbcef5c8046007771d93d27418ad4fa72fc8 Mon Sep 17 00:00:00 2001 From: omarluq Date: Thu, 15 Feb 2024 11:15:41 -0600 Subject: [PATCH 15/22] limit morph action data atrributes to morphStyle only and add morph lifecycle events --- src/core/streams/stream_actions.js | 84 ++++++++++++++++++-------- src/tests/unit/stream_element_tests.js | 27 +++++++-- 2 files changed, 82 insertions(+), 29 deletions(-) diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js index a343e4333..8dcac5803 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -1,8 +1,7 @@ import { session } from "../" -import { morph } from "idiomorph" +import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js" import { dispatch } from "../../util" - export const StreamActions = { after() { this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)) @@ -39,34 +38,28 @@ export const StreamActions = { refresh() { session.refresh(this.baseURI, this.requestId) - } + }, morph() { this.targetElements.forEach((targetElement) => { try { - const morphStyle = this.getAttribute("data-turbo-morph-style") || "outerHTML" - const ignoreActive = this.getAttribute("data-turbo-morph-ignore-active") || true - const ignoreActiveValue = this.getAttribute("data-turbo-morph-ignore-active-value") || true - const head = this.getAttribute("data-turbo-morph-head") || 'merge' - morph(targetElement, this.templateContent, { + const morphStyle = targetElement.getAttribute("data-turbo-morph-style") || "outerHTML" + Idiomorph.morph(targetElement, this.templateContent, { morphStyle: morphStyle, - ignoreActive: ignoreActive, - ignoreActiveValue: ignoreActiveValue, - head: head, + ignoreActiveValue: true, callbacks: { - beforeNodeAdded: (node) => { - return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) - }, - afterNodeMorphed: (oldNode, newNode) => { - if (newNode instanceof HTMLElement) { - dispatch("turbo:morph-element", { - target: oldNode, - detail: { - newElement: newNode - } - }) - } - } + beforeNodeAdded, + beforeNodeMorphed, + beforeAttributeUpdated, + beforeNodeRemoved, + afterNodeMorphed + } + }) + + dispatch("turbo:morph", { + detail: { + currentElement: targetElement, + newElement: this.templateContent } }) } catch (error) { @@ -75,3 +68,46 @@ export const StreamActions = { }) } } + +const beforeNodeAdded = (node) => { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) +} + +const beforeNodeMorphed = (target, newElement) => { + if (target instanceof HTMLElement && !target.hasAttribute("data-turbo-permanent")) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + detail: { + target, + newElement + } + }) + return !event.defaultPrevented + } + return false +} + +const beforeAttributeUpdated = (attributeName, target, mutationType) => { + const event = dispatch("turbo:before-morph-attribute", { + cancelable: true, + target, + detail: { + attributeName, + mutationType + } + }) + return !event.defaultPrevented +} + +const beforeNodeRemoved = beforeNodeMorphed + +const afterNodeMorphed = (target, newElement) => { + if (newElement instanceof HTMLElement) { + dispatch("turbo:morph-element", { + target, + detail: { + newElement + } + }) + } +} diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index c084c8e9f..f717c02c6 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -198,14 +198,31 @@ test("test action=refresh discarded when matching request id", async () => { }) test("action=morph", async () => { - const templateElement = createTemplateElement(`
Hello Turbo Morphed
`) + const templateElement = createTemplateElement(`

Hello Turbo Morphed

`) const element = createStreamElement("morph", "hello", templateElement) - - assert.equal(subject.find("#hello")?.textContent, "Hello Turbo") + + assert.equal(subject.find("div#hello")?.textContent, "Hello Turbo") subject.append(element) await nextAnimationFrame() - assert.notOk(subject.find("#hello")?.textContent, "Hello Turbo") - assert.equal(subject.find("#hello")?.textContent, "Hello Turbo Morphed") + assert.notOk(subject.find("div#hello")) + assert.equal(subject.find("h1#hello")?.textContent, "Hello Turbo Morphed") }) + +test("action=morph with data-turbo-morph-style='innerHTML'", async () => { + const templateElement = createTemplateElement(`

Hello Turbo Morphed

`) + const element = createStreamElement("morph", "hello", templateElement) + const target = subject.find("div#hello") + assert.equal(target?.textContent, "Hello Turbo") + target.setAttribute("data-turbo-morph-style", "innerHTML") + + subject.append(element) + + await nextAnimationFrame() + + assert.ok(subject.find("div#hello")) + assert.ok(subject.find("div#hello > h1#hello-child-element")) + assert.equal(subject.find("div#hello > h1#hello-child-element").textContent, "Hello Turbo Morphed") +}) + From 8c668612b3715cdae0b291abd2ce61baf5813c71 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Tue, 5 Mar 2024 05:39:17 -0500 Subject: [PATCH 16/22] Use Playwright assertions for autofocus (#1219) Closes [#1154][] Replace bespoke CSS selector-based assertions with Playwright's built-in [toBeFocused](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-focused) assertion. Like other assertions, `toBeFocused` will wait and retry for a period of time, which makes `nextBeat` calls unnecessary. [#1154]: https://github.com/hotwired/turbo/issues/1154 --- src/tests/functional/autofocus_tests.js | 114 +++--------------------- 1 file changed, 12 insertions(+), 102 deletions(-) diff --git a/src/tests/functional/autofocus_tests.js b/src/tests/functional/autofocus_tests.js index 011562ceb..9cc49afe3 100644 --- a/src/tests/functional/autofocus_tests.js +++ b/src/tests/functional/autofocus_tests.js @@ -1,150 +1,65 @@ -import { test } from "@playwright/test" -import { assert } from "chai" -import { hasSelector, nextBeat } from "../helpers/page" +import { expect, test } from "@playwright/test" test.beforeEach(async ({ page }) => { await page.goto("/src/tests/fixtures/autofocus.html") }) test("autofocus first autofocus element on load", async ({ page }) => { - await nextBeat() - assert.ok( - await hasSelector(page, "#first-autofocus-element:focus"), - "focuses the first [autofocus] element on the page" - ) - assert.notOk( - await hasSelector(page, "#second-autofocus-element:focus"), - "focuses the first [autofocus] element on the page" - ) + await expect(page.locator("#first-autofocus-element")).toBeFocused() }) test("autofocus first [autofocus] element on visit", async ({ page }) => { await page.goto("/src/tests/fixtures/navigation.html") await page.click("#autofocus-link") - await nextBeat() - - assert.ok( - await hasSelector(page, "#first-autofocus-element:focus"), - "focuses the first [autofocus] element on the page" - ) - assert.notOk( - await hasSelector(page, "#second-autofocus-element:focus"), - "focuses the first [autofocus] element on the page" - ) + await expect(page.locator("#first-autofocus-element")).toBeFocused() }) test("navigating a frame with a descendant link autofocuses [autofocus]:first-of-type", async ({ page }) => { await page.click("#frame-inner-link") - await nextBeat() - - assert.ok( - await hasSelector(page, "#frames-form-first-autofocus-element:focus"), - "focuses the first [autofocus] element in frame" - ) - assert.notOk( - await hasSelector(page, "#frames-form-second-autofocus-element:focus"), - "focuses the first [autofocus] element in frame" - ) + await expect(page.locator("#frames-form-first-autofocus-element")).toBeFocused() }) test("autofocus visible [autofocus] element on visit with inert elements", async ({ page }) => { await page.click("#autofocus-inert-link") - await nextBeat() - - assert.notOk( - await hasSelector(page, "#dialog-autofocus-element:focus"), - "autofocus element is ignored in a closed dialog" - ) - assert.notOk( - await hasSelector(page, "#details-autofocus-element:focus"), - "autofocus element is ignored in a closed details" - ) - assert.notOk( - await hasSelector(page, "#hidden-autofocus-element:focus"), - "autofocus element is ignored in a hidden div" - ) - assert.notOk( - await hasSelector(page, "#inert-autofocus-element:focus"), - "autofocus element is ignored in an inert div" - ) - assert.notOk( - await hasSelector(page, "#disabled-autofocus-element:focus"), - "autofocus element is ignored when disabled" - ) - assert.ok( - await hasSelector(page, "#visible-autofocus-element:focus"), - "focuses the visible [autofocus] element on the page" - ) + await expect(page.locator("#visible-autofocus-element")).toBeFocused() }) test("navigating a frame with a link targeting the frame autofocuses [autofocus]:first-of-type", async ({ page }) => { await page.click("#frame-outer-link") - await nextBeat() - - assert.ok( - await hasSelector(page, "#frames-form-first-autofocus-element:focus"), - "focuses the first [autofocus] element in frame" - ) - assert.notOk( - await hasSelector(page, "#frames-form-second-autofocus-element:focus"), - "focuses the first [autofocus] element in frame" - ) + await expect(page.locator("#frames-form-first-autofocus-element")).toBeFocused() }) test("navigating a frame with a turbo-frame targeting the frame autofocuses [autofocus]:first-of-type", async ({ page }) => { await page.click("#drives-frame-target-link") - await nextBeat() - - assert.ok( - await hasSelector(page, "#frames-form-first-autofocus-element:focus"), - "focuses the first [autofocus] element in frame" - ) - assert.notOk( - await hasSelector(page, "#frames-form-second-autofocus-element:focus"), - "focuses the first [autofocus] element in frame" - ) + await expect(page.locator("#frames-form-first-autofocus-element")).toBeFocused() }) test("receiving a Turbo Stream message with an [autofocus] element when the activeElement is the document", async ({ page }) => { await page.evaluate(() => { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur() - } + document.activeElement.blur() window.Turbo.renderStreamMessage(` `) }) - await nextBeat() - - assert.ok( - await hasSelector(page, "#autofocus-from-stream:focus"), - "focuses the [autofocus] element in from the turbo-stream" - ) + await expect(page.locator("#autofocus-from-stream")).toBeFocused() }) test("autofocus from a Turbo Stream message does not leak a placeholder [id]", async ({ page }) => { await page.evaluate(() => { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur() - } + document.activeElement.blur() window.Turbo.renderStreamMessage(` `) }) - await nextBeat() - - assert.ok( - await hasSelector(page, "#container-from-stream input:focus"), - "focuses the [autofocus] element in from the turbo-stream" - ) + await expect(page.locator("#container-from-stream input")).toBeFocused() }) test("receiving a Turbo Stream message with an [autofocus] element when an element within the document has focus", async ({ page }) => { @@ -155,10 +70,5 @@ test("receiving a Turbo Stream message with an [autofocus] element when an eleme `) }) - await nextBeat() - - assert.ok( - await hasSelector(page, "#first-autofocus-element:focus"), - "focuses the first [autofocus] element on the page" - ) + await expect(page.locator("#first-autofocus-element")).toBeFocused() }) From 1cd62711847511c7d1cf3c66ea0f6fc142466247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Fri, 8 Mar 2024 14:29:28 +0000 Subject: [PATCH 17/22] Turbo v8.0.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e7692f99..7488a73fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hotwired/turbo", - "version": "8.0.3", + "version": "8.0.4", "description": "The speed of a single-page web application without having to write any JavaScript", "module": "dist/turbo.es2017-esm.js", "main": "dist/turbo.es2017-umd.js", From 276ee38e7cf2c0d3edc6a090bb3d919dc03cbebe Mon Sep 17 00:00:00 2001 From: omarluq Date: Tue, 20 Feb 2024 07:48:15 -0600 Subject: [PATCH 18/22] extract morph action and add children-only option to stream-element --- src/core/streams/actions/morph.js | 68 +++++++++++++++++++++++ src/core/streams/stream_actions.js | 74 ++------------------------ src/tests/unit/stream_element_tests.js | 5 +- 3 files changed, 73 insertions(+), 74 deletions(-) create mode 100644 src/core/streams/actions/morph.js diff --git a/src/core/streams/actions/morph.js b/src/core/streams/actions/morph.js new file mode 100644 index 000000000..ed92291e7 --- /dev/null +++ b/src/core/streams/actions/morph.js @@ -0,0 +1,68 @@ +import { Idiomorph } from "idiomorph/dist/idiomorph.esm" +import { dispatch } from "../../../util" + +export default function morph(streamElement) { + const morphStyle = streamElement.hasAttribute("children-only") ? "innerHTML" : "outerHTML" + streamElement.targetElements.forEach((element) => { + try { + Idiomorph.morph(element, streamElement.templateContent, { + morphStyle: morphStyle, + ignoreActiveValue: true, + callbacks: { + beforeNodeAdded, + beforeNodeMorphed, + beforeAttributeUpdated, + beforeNodeRemoved, + afterNodeMorphed, + }, + }) + } catch (e) { + console.error(e) + } + }) +} + +function beforeNodeAdded(node) { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) +} + +function beforeNodeRemoved(node) { + return beforeNodeAdded(node) +} + +function beforeNodeMorphed(target, newElement) { + if (target instanceof HTMLElement && !target.hasAttribute("data-turbo-permanent")) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + detail: { + target, + newElement, + }, + }) + return !event.defaultPrevented + } + return false +} + +function beforeAttributeUpdated(attributeName, target, mutationType) { + const event = dispatch("turbo:before-morph-attribute", { + cancelable: true, + target, + detail: { + attributeName, + mutationType, + }, + }) + return !event.defaultPrevented +} + +function afterNodeMorphed(target, newElement) { + if (newElement instanceof HTMLElement) { + dispatch("turbo:morph-element", { + target, + detail: { + newElement, + }, + }) + } +} diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js index 8dcac5803..e7fc68836 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -1,6 +1,5 @@ import { session } from "../" -import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js" -import { dispatch } from "../../util" +import morph from "./actions/morph" export const StreamActions = { after() { @@ -41,73 +40,6 @@ export const StreamActions = { }, morph() { - this.targetElements.forEach((targetElement) => { - try { - const morphStyle = targetElement.getAttribute("data-turbo-morph-style") || "outerHTML" - Idiomorph.morph(targetElement, this.templateContent, { - morphStyle: morphStyle, - ignoreActiveValue: true, - callbacks: { - beforeNodeAdded, - beforeNodeMorphed, - beforeAttributeUpdated, - beforeNodeRemoved, - afterNodeMorphed - } - }) - - dispatch("turbo:morph", { - detail: { - currentElement: targetElement, - newElement: this.templateContent - } - }) - } catch (error) { - console.error(error) - } - }) - } -} - -const beforeNodeAdded = (node) => { - return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) -} - -const beforeNodeMorphed = (target, newElement) => { - if (target instanceof HTMLElement && !target.hasAttribute("data-turbo-permanent")) { - const event = dispatch("turbo:before-morph-element", { - cancelable: true, - detail: { - target, - newElement - } - }) - return !event.defaultPrevented - } - return false -} - -const beforeAttributeUpdated = (attributeName, target, mutationType) => { - const event = dispatch("turbo:before-morph-attribute", { - cancelable: true, - target, - detail: { - attributeName, - mutationType - } - }) - return !event.defaultPrevented -} - -const beforeNodeRemoved = beforeNodeMorphed - -const afterNodeMorphed = (target, newElement) => { - if (newElement instanceof HTMLElement) { - dispatch("turbo:morph-element", { - target, - detail: { - newElement - } - }) - } + morph(this) + }, } diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index f717c02c6..1e3b99f92 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -210,12 +210,12 @@ test("action=morph", async () => { assert.equal(subject.find("h1#hello")?.textContent, "Hello Turbo Morphed") }) -test("action=morph with data-turbo-morph-style='innerHTML'", async () => { +test("action=morph children-only", async () => { const templateElement = createTemplateElement(`

Hello Turbo Morphed

`) const element = createStreamElement("morph", "hello", templateElement) const target = subject.find("div#hello") assert.equal(target?.textContent, "Hello Turbo") - target.setAttribute("data-turbo-morph-style", "innerHTML") + element.setAttribute("children-only", true) subject.append(element) @@ -225,4 +225,3 @@ test("action=morph with data-turbo-morph-style='innerHTML'", async () => { assert.ok(subject.find("div#hello > h1#hello-child-element")) assert.equal(subject.find("div#hello > h1#hello-child-element").textContent, "Hello Turbo Morphed") }) - From f02bfb2e82a2bfb34c1c2e232cd2e6efe1390c40 Mon Sep 17 00:00:00 2001 From: omarluq Date: Tue, 12 Mar 2024 21:58:31 -0500 Subject: [PATCH 19/22] test morph stream action events --- src/core/streams/actions/morph.js | 39 +++++++-------- src/core/streams/stream_actions.js | 2 +- src/tests/fixtures/morph_stream_action.html | 16 +++++++ .../functional/morph_stream_action_tests.js | 48 +++++++++++++++++++ 4 files changed, 82 insertions(+), 23 deletions(-) create mode 100644 src/tests/fixtures/morph_stream_action.html create mode 100644 src/tests/functional/morph_stream_action_tests.js diff --git a/src/core/streams/actions/morph.js b/src/core/streams/actions/morph.js index ed92291e7..d177bfd01 100644 --- a/src/core/streams/actions/morph.js +++ b/src/core/streams/actions/morph.js @@ -4,21 +4,16 @@ import { dispatch } from "../../../util" export default function morph(streamElement) { const morphStyle = streamElement.hasAttribute("children-only") ? "innerHTML" : "outerHTML" streamElement.targetElements.forEach((element) => { - try { - Idiomorph.morph(element, streamElement.templateContent, { - morphStyle: morphStyle, - ignoreActiveValue: true, - callbacks: { - beforeNodeAdded, - beforeNodeMorphed, - beforeAttributeUpdated, - beforeNodeRemoved, - afterNodeMorphed, - }, - }) - } catch (e) { - console.error(e) - } + Idiomorph.morph(element, streamElement.templateContent, { + morphStyle: morphStyle, + callbacks: { + beforeNodeAdded, + beforeNodeMorphed, + beforeAttributeUpdated, + beforeNodeRemoved, + afterNodeMorphed + } + }) }) } @@ -34,10 +29,10 @@ function beforeNodeMorphed(target, newElement) { if (target instanceof HTMLElement && !target.hasAttribute("data-turbo-permanent")) { const event = dispatch("turbo:before-morph-element", { cancelable: true, + target, detail: { - target, - newElement, - }, + newElement + } }) return !event.defaultPrevented } @@ -50,8 +45,8 @@ function beforeAttributeUpdated(attributeName, target, mutationType) { target, detail: { attributeName, - mutationType, - }, + mutationType + } }) return !event.defaultPrevented } @@ -61,8 +56,8 @@ function afterNodeMorphed(target, newElement) { dispatch("turbo:morph-element", { target, detail: { - newElement, - }, + newElement + } }) } } diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js index e7fc68836..486dc8566 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -41,5 +41,5 @@ export const StreamActions = { morph() { morph(this) - }, + } } diff --git a/src/tests/fixtures/morph_stream_action.html b/src/tests/fixtures/morph_stream_action.html new file mode 100644 index 000000000..df91274f5 --- /dev/null +++ b/src/tests/fixtures/morph_stream_action.html @@ -0,0 +1,16 @@ + + + + + Morph Stream Action + + + + + + +
+
Morph me
+
+ + diff --git a/src/tests/functional/morph_stream_action_tests.js b/src/tests/functional/morph_stream_action_tests.js new file mode 100644 index 000000000..b4f04c9d7 --- /dev/null +++ b/src/tests/functional/morph_stream_action_tests.js @@ -0,0 +1,48 @@ +import { test, expect } from "@playwright/test" +import { nextEventOnTarget, noNextEventOnTarget } from "../helpers/page" + +test("dispatches a turbo:before-morph-element & turbo:morph-element for each morph stream action", async ({ page }) => { + await page.goto("/src/tests/fixtures/morph_stream_action.html") + + await page.evaluate(() => { + window.Turbo.renderStreamMessage(` + + + + `) + }) + + await nextEventOnTarget(page, "message_1", "turbo:before-morph-element") + await nextEventOnTarget(page, "message_1", "turbo:morph-element") + await expect(page.locator("#message_1")).toHaveText("Morphed") +}) + +test("preventing a turbo:before-morph-element prevents the morph", async ({ page }) => { + await page.goto("/src/tests/fixtures/morph_stream_action.html") + + await page.evaluate(() => { + addEventListener("turbo:before-morph-element", (event) => { + event.preventDefault() + }) + }) + + await page.evaluate(() => { + window.Turbo.renderStreamMessage(` + + + + `) + }) + + await nextEventOnTarget(page, "message_1", "turbo:before-morph-element") + await noNextEventOnTarget(page, "message_1", "turbo:morph-element") + await expect(page.locator("#message_1")).toHaveText("Morph me") +}) From 290d6cc16439874a2c4cee5987a18535d7d442dc Mon Sep 17 00:00:00 2001 From: Helen Date: Mon, 18 Mar 2024 10:18:04 -0700 Subject: [PATCH 20/22] fix bug in beforeNodeMorphed --- src/core/streams/actions/morph.js | 22 ++++++++++++---------- src/tests/unit/stream_element_tests.js | 13 +++++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/core/streams/actions/morph.js b/src/core/streams/actions/morph.js index d177bfd01..42e655c95 100644 --- a/src/core/streams/actions/morph.js +++ b/src/core/streams/actions/morph.js @@ -26,17 +26,19 @@ function beforeNodeRemoved(node) { } function beforeNodeMorphed(target, newElement) { - if (target instanceof HTMLElement && !target.hasAttribute("data-turbo-permanent")) { - const event = dispatch("turbo:before-morph-element", { - cancelable: true, - target, - detail: { - newElement - } - }) - return !event.defaultPrevented + if (target instanceof HTMLElement) { + if (!target.hasAttribute("data-turbo-permanent")) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + target, + detail: { + newElement + } + }) + return !event.defaultPrevented + } + return false } - return false } function beforeAttributeUpdated(attributeName, target, mutationType) { diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index 1e3b99f92..0d3e04b61 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -210,6 +210,19 @@ test("action=morph", async () => { assert.equal(subject.find("h1#hello")?.textContent, "Hello Turbo Morphed") }) +test("action=morph with text content change", async () => { + const templateElement = createTemplateElement(`
Hello Turbo Morphed
`) + const element = createStreamElement("morph", "hello", templateElement) + + assert.equal(subject.find("div#hello")?.textContent, "Hello Turbo") + + subject.append(element) + await nextAnimationFrame() + + assert.ok(subject.find("div#hello")) + assert.equal(subject.find("div#hello")?.textContent, "Hello Turbo Morphed") +}) + test("action=morph children-only", async () => { const templateElement = createTemplateElement(`

Hello Turbo Morphed

`) const element = createStreamElement("morph", "hello", templateElement) From 0ce534b3a244084a5a05a4ef344c6b2f571cecfa Mon Sep 17 00:00:00 2001 From: Helen Date: Mon, 18 Mar 2024 10:19:38 -0700 Subject: [PATCH 21/22] improve readability --- src/core/streams/actions/morph.js | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/core/streams/actions/morph.js b/src/core/streams/actions/morph.js index 42e655c95..e0813e12d 100644 --- a/src/core/streams/actions/morph.js +++ b/src/core/streams/actions/morph.js @@ -26,19 +26,20 @@ function beforeNodeRemoved(node) { } function beforeNodeMorphed(target, newElement) { - if (target instanceof HTMLElement) { - if (!target.hasAttribute("data-turbo-permanent")) { - const event = dispatch("turbo:before-morph-element", { - cancelable: true, - target, - detail: { - newElement - } - }) - return !event.defaultPrevented - } - return false + if (!(target instanceof HTMLElement)) { + return + } + if (!target.hasAttribute("data-turbo-permanent")) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + target, + detail: { + newElement + } + }) + return !event.defaultPrevented } + return false } function beforeAttributeUpdated(attributeName, target, mutationType) { From 9c2bd18ff21d49bd9fc3d6df98822689d49c4b49 Mon Sep 17 00:00:00 2001 From: Helen Date: Mon, 25 Mar 2024 08:57:37 -0700 Subject: [PATCH 22/22] revert style change --- src/core/streams/actions/morph.js | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/core/streams/actions/morph.js b/src/core/streams/actions/morph.js index e0813e12d..42e655c95 100644 --- a/src/core/streams/actions/morph.js +++ b/src/core/streams/actions/morph.js @@ -26,20 +26,19 @@ function beforeNodeRemoved(node) { } function beforeNodeMorphed(target, newElement) { - if (!(target instanceof HTMLElement)) { - return - } - if (!target.hasAttribute("data-turbo-permanent")) { - const event = dispatch("turbo:before-morph-element", { - cancelable: true, - target, - detail: { - newElement - } - }) - return !event.defaultPrevented + if (target instanceof HTMLElement) { + if (!target.hasAttribute("data-turbo-permanent")) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + target, + detail: { + newElement + } + }) + return !event.defaultPrevented + } + return false } - return false } function beforeAttributeUpdated(attributeName, target, mutationType) {