diff --git a/package.json b/package.json index c058de50a..7488a73fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hotwired/turbo", - "version": "8.0.0-rc.2", + "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", 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/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/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/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/core/streams/actions/morph.js b/src/core/streams/actions/morph.js new file mode 100644 index 000000000..42e655c95 --- /dev/null +++ b/src/core/streams/actions/morph.js @@ -0,0 +1,65 @@ +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) => { + Idiomorph.morph(element, streamElement.templateContent, { + morphStyle: morphStyle, + callbacks: { + beforeNodeAdded, + beforeNodeMorphed, + beforeAttributeUpdated, + beforeNodeRemoved, + afterNodeMorphed + } + }) + }) +} + +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) { + 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) { + 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 064e94ca4..486dc8566 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -1,4 +1,5 @@ import { session } from "../" +import morph from "./actions/morph" export const StreamActions = { after() { @@ -36,5 +37,9 @@ export const StreamActions = { refresh() { session.refresh(this.baseURI, this.requestId) + }, + + morph() { + morph(this) } } 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/observers/link_prefetch_observer.js b/src/observers/link_prefetch_observer.js index ceedeb551..15e48a8ca 100644 --- a/src/observers/link_prefetch_observer.js +++ b/src/observers/link_prefetch_observer.js @@ -1,18 +1,16 @@ import { - doesNotTargetIFrame, + dispatch, 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" export class LinkPrefetchObserver { started = false - hoverTriggerEvent = "mouseenter" - touchTriggerEvent = "touchstart" + #prefetchedLink = null constructor(delegate, eventTarget) { this.delegate = delegate @@ -32,33 +30,35 @@ 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 } #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])") @@ -68,6 +68,8 @@ export class LinkPrefetchObserver { const location = getLocationForLink(link) if (this.delegate.canPrefetchRequestToLocation(link, location)) { + this.#prefetchedLink = link + const fetchRequest = new FetchRequest( this, FetchMethod.get, @@ -85,6 +87,15 @@ export class LinkPrefetchObserver { } } + #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()) @@ -109,10 +120,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 @@ -136,41 +143,52 @@ export class LinkPrefetchObserver { #isPrefetchable(link) { const href = link.getAttribute("href") - if (!href || href === "#" || link.getAttribute("data-turbo") === "false" || link.getAttribute("data-turbo-prefetch") === "false") { - return false - } + if (!href) return false - if (link.origin !== document.location.origin) { - 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 - if (!["http:", "https:"].includes(link.protocol)) { - return false - } + return true + } +} - if (link.pathname + link.search === document.location.pathname + document.location.search) { - return false - } +const unfetchableLink = (link) => { + return link.origin !== document.location.origin || !["http:", "https:"].includes(link.protocol) || link.hasAttribute("target") +} - const turboMethod = link.getAttribute("data-turbo-method") - if (turboMethod && turboMethod !== "get") { - return false - } +const linkToTheSamePage = (link) => { + return (link.pathname + link.search === document.location.pathname + document.location.search) || link.href.startsWith("#") +} - if (targetsIframe(link)) { - return false - } +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]") + const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]") + if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") return true - if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") { - return false - } + return false +} - return true - } +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) => { + 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/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/hover_to_prefetch.html b/src/tests/fixtures/hover_to_prefetch.html index 25e060658..93d34ec62 100644 --- a/src/tests/fixtures/hover_to_prefetch.html +++ b/src/tests/fixtures/hover_to_prefetch.html @@ -30,6 +30,12 @@ >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 Won't prefetch when hovering me + + + + Morph Stream Action + + + + + + +
+
Morph me
+
+ + diff --git a/src/tests/fixtures/page_refresh.html b/src/tests/fixtures/page_refresh.html index 0dad87edb..c0586677f 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) { @@ -82,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/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() }) diff --git a/src/tests/functional/link_prefetch_observer_tests.js b/src/tests/functional/link_prefetch_observer_tests.js index a1b60c0f2..4d814fe5f 100644 --- a/src/tests/functional/link_prefetch_observer_tests.js +++ b/src/tests/functional/link_prefetch_observer_tests.js @@ -62,6 +62,37 @@ test("it doesn't prefetch the page when link has data-turbo=false", async ({ pag await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_turbo_false" }) }) +test("allows to cancel prefetch requests with custom logic", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + + await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" }) + + await page.evaluate(() => { + document.body.addEventListener("turbo:before-prefetch", (event) => { + if (event.target.hasAttribute("data-remote")) { + event.preventDefault() + } + }) + }) + + 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" }) @@ -137,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" }) @@ -311,11 +331,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" }) @@ -335,26 +350,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 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/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") +}) 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 }) => { diff --git a/src/tests/functional/page_refresh_tests.js b/src/tests/functional/page_refresh_tests.js index 06a041787..c5c116c08 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 }) => { @@ -183,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") diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index 21a9ca8aa..0d3e04b61 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -196,3 +196,45 @@ 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("div#hello")?.textContent, "Hello Turbo") + + subject.append(element) + await nextAnimationFrame() + + assert.notOk(subject.find("div#hello")) + 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) + const target = subject.find("div#hello") + assert.equal(target?.textContent, "Hello Turbo") + element.setAttribute("children-only", true) + + 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") +}) 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"