From f5edb5e14eb8b4f715321843d904baa073737307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Wed, 4 Oct 2023 22:37:39 +0200 Subject: [PATCH 1/5] Page refreshes This commit introduces the concept of page refresh. A page refresh happens when Turbo renders the current page again. We will offer two new options to control behavior when a page refresh happens: - The method used to update the page: with a new option to use morphing (Turbo currently replaces the body). - The scroll strategy: with a new option to keep it (Turbo currently resets scroll to the top-left). The combination of morphing and scroll-keeping results in smoother updates that keep the screen state. For example, this will keep both horizontal and vertical scroll, the focus, the text selection, CSS transition states, etc. We will also introduce a new turbo stream action that, when broadcasted, will request a page refresh. This will offer a simplified alternative to fine-grained broadcasted turbo-stream actions. Co-Authored-By: Jorge Manrubia --- package.json | 3 + src/core/drive/limited_set.js | 15 +++ src/core/drive/morph_renderer.js | 88 ++++++++++++++ src/core/drive/page_snapshot.js | 8 ++ src/core/drive/page_view.js | 10 +- src/core/drive/visit.js | 18 +-- src/core/frames/frame_controller.js | 2 +- src/core/index.js | 4 +- src/core/native/browser_adapter.js | 6 +- src/core/renderer.js | 4 + src/core/session.js | 8 +- src/core/streams/stream_actions.js | 9 ++ src/core/view.js | 2 +- src/http/recent_fetch_requests.js | 15 +++ src/index.js | 1 + src/tests/fixtures/frame_refresh_morph.html | 3 + src/tests/fixtures/frame_refresh_reload.html | 3 + src/tests/fixtures/page_refresh.html | 52 ++++++++ src/tests/fixtures/page_refresh_replace.html | 45 +++++++ .../fixtures/page_refresh_scroll_reset.html | 44 +++++++ .../fixtures/page_refresh_stream_action.html | 19 +++ src/tests/fixtures/page_refreshed.html | 12 ++ src/tests/fixtures/test.js | 1 + src/tests/functional/navigation_tests.js | 6 + .../page_refresh_stream_action_tests.js | 55 +++++++++ src/tests/functional/page_refresh_tests.js | 115 ++++++++++++++++++ src/tests/helpers/page.js | 14 ++- src/tests/server.mjs | 35 +++++- src/tests/unit/limited_set_tests.js | 17 +++ src/tests/unit/stream_element_tests.js | 29 +++++ yarn.lock | 4 + 31 files changed, 619 insertions(+), 28 deletions(-) create mode 100644 src/core/drive/limited_set.js create mode 100644 src/core/drive/morph_renderer.js create mode 100644 src/http/recent_fetch_requests.js create mode 100644 src/tests/fixtures/frame_refresh_morph.html create mode 100644 src/tests/fixtures/frame_refresh_reload.html create mode 100644 src/tests/fixtures/page_refresh.html create mode 100644 src/tests/fixtures/page_refresh_replace.html create mode 100644 src/tests/fixtures/page_refresh_scroll_reset.html create mode 100644 src/tests/fixtures/page_refresh_stream_action.html create mode 100644 src/tests/fixtures/page_refreshed.html create mode 100644 src/tests/functional/page_refresh_stream_action_tests.js create mode 100644 src/tests/functional/page_refresh_tests.js create mode 100644 src/tests/unit/limited_set_tests.js diff --git a/package.json b/package.json index 03b07fa02..214ddee8e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "publishConfig": { "access": "public" }, + "dependencies": { + "idiomorph": "https://github.com/basecamp/idiomorph#rollout-build" + }, "devDependencies": { "@open-wc/testing": "^3.1.7", "@playwright/test": "^1.28.0", diff --git a/src/core/drive/limited_set.js b/src/core/drive/limited_set.js new file mode 100644 index 000000000..a9b5643e7 --- /dev/null +++ b/src/core/drive/limited_set.js @@ -0,0 +1,15 @@ +export 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) + } +} diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js new file mode 100644 index 000000000..4cc10a6e8 --- /dev/null +++ b/src/core/drive/morph_renderer.js @@ -0,0 +1,88 @@ +import Idiomorph from "idiomorph" +import { nextAnimationFrame } from "../../util" +import { Renderer } from "../renderer" + +export class MorphRenderer extends Renderer { + async render() { + if (this.willRender) await this.#morphBody() + } + + get renderMethod() { + return "morph" + } + + // Private + + async #morphBody() { + this.#morphElements(this.currentElement, this.newElement) + this.#reloadRemoteFrames() + + this.#dispatchEvent("turbo:morph", { currentElement: this.currentElement, newElement: this.newElement }) + } + + #morphElements(currentElement, newElement, morphStyle = "outerHTML") { + Idiomorph.morph(currentElement, newElement, { + morphStyle: morphStyle, + callbacks: { + beforeNodeMorphed: this.#shouldMorphElement, + beforeNodeRemoved: this.#shouldRemoveElement, + afterNodeMorphed: this.#reloadStimulusControllers + } + }) + } + + #reloadRemoteFrames() { + this.#remoteFrames().forEach((frame) => { + if (this.#isFrameReloadedWithMorph(frame)) { + this.#renderFrameWithMorph(frame) + } + frame.reload() + }) + } + + #renderFrameWithMorph(frame) { + frame.addEventListener("turbo:before-frame-render", (event) => { + event.detail.render = this.#morphFrameUpdate + }, { once: true }) + } + + #morphFrameUpdate = (currentElement, newElement) => { + this.#dispatchEvent("turbo:before-frame-morph", { currentElement, newElement }, currentElement) + this.#morphElements(currentElement, newElement, "innerHTML") + } + + #shouldRemoveElement = (node) => { + return this.#shouldMorphElement(node) + } + + #shouldMorphElement = (node) => { + if (node instanceof HTMLElement) { + return !node.hasAttribute("data-turbo-permanent") + } else { + return true + } + } + + #reloadStimulusControllers = async (node) => { + if (node instanceof HTMLElement && node.hasAttribute("data-controller")) { + const originalAttribute = node.getAttribute("data-controller") + node.removeAttribute("data-controller") + await nextAnimationFrame() + node.setAttribute("data-controller", originalAttribute) + } + } + + #isFrameReloadedWithMorph(element) { + return element.getAttribute("src") && element.getAttribute("refresh") === "morph" + } + + #remoteFrames() { + return document.querySelectorAll("turbo-frame[src]") + } + + #dispatchEvent(name, detail, target = document.documentElement) { + const event = new CustomEvent(name, { bubbles: true, cancelable: true, detail }) + target.dispatchEvent(event) + return event + } +} diff --git a/src/core/drive/page_snapshot.js b/src/core/drive/page_snapshot.js index 47488d785..b7fcb711e 100644 --- a/src/core/drive/page_snapshot.js +++ b/src/core/drive/page_snapshot.js @@ -73,6 +73,14 @@ export class PageSnapshot extends Snapshot { return this.headSnapshot.getMetaValue("view-transition") === "same-origin" } + get shouldMorphPage() { + return this.getSetting("refresh-method") === "morph" + } + + get shouldPreserveScrollPosition() { + return this.getSetting("refresh-scroll") === "preserve" + } + // Private getSetting(name) { diff --git a/src/core/drive/page_view.js b/src/core/drive/page_view.js index 09a7299c6..6cf25dfd5 100644 --- a/src/core/drive/page_view.js +++ b/src/core/drive/page_view.js @@ -1,6 +1,7 @@ import { nextEventLoopTick } from "../../util" import { View } from "../view" import { ErrorRenderer } from "./error_renderer" +import { MorphRenderer } from "./morph_renderer" import { PageRenderer } from "./page_renderer" import { PageSnapshot } from "./page_snapshot" import { SnapshotCache } from "./snapshot_cache" @@ -15,7 +16,10 @@ export class PageView extends View { } renderPage(snapshot, isPreview = false, willRender = true, visit) { - const renderer = new PageRenderer(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender) + const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage + const rendererClass = shouldMorphPage ? MorphRenderer : PageRenderer + + const renderer = new rendererClass(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender) if (!renderer.shouldRender) { this.forceReloaded = true @@ -55,6 +59,10 @@ export class PageView extends View { return this.snapshotCache.get(location) } + isPageRefresh(visit) { + return visit && this.lastRenderedLocation.href === visit.location.href + } + get snapshot() { return PageSnapshot.fromElement(this.element) } diff --git a/src/core/drive/visit.js b/src/core/drive/visit.js index d1929b03e..4fa57642d 100644 --- a/src/core/drive/visit.js +++ b/src/core/drive/visit.js @@ -154,10 +154,10 @@ export class Visit { } } - issueRequest() { + async issueRequest() { if (this.hasPreloadedResponse()) { this.simulateRequest() - } else if (this.shouldIssueRequest() && !this.request) { + } else if (!this.request && await this.shouldIssueRequest()) { this.request = new FetchRequest(this, FetchMethod.get, this.location) this.request.perform() } @@ -231,14 +231,14 @@ export class Visit { } } - hasCachedSnapshot() { - return this.getCachedSnapshot() != null + async hasCachedSnapshot() { + return (await this.getCachedSnapshot()) != null } async loadCachedSnapshot() { const snapshot = await this.getCachedSnapshot() if (snapshot) { - const isPreview = this.shouldIssueRequest() + const isPreview = await this.shouldIssueRequest() this.render(async () => { this.cacheSnapshot() if (this.isSamePage) { @@ -335,7 +335,7 @@ export class Visit { // Scrolling performScroll() { - if (!this.scrolled && !this.view.forceReloaded) { + if (!this.scrolled && !this.view.forceReloaded && !this.view.snapshot.shouldPreserveScrollPosition) { if (this.action == "restore") { this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop() } else { @@ -391,11 +391,11 @@ export class Visit { return typeof this.response == "object" } - shouldIssueRequest() { + async shouldIssueRequest() { if (this.isSamePage) { return false - } else if (this.action == "restore") { - return !this.hasCachedSnapshot() + } else if (this.action === "restore") { + return !(await this.hasCachedSnapshot()) } else { return this.willRender } diff --git a/src/core/frames/frame_controller.js b/src/core/frames/frame_controller.js index 1cab2902e..5940ba761 100644 --- a/src/core/frames/frame_controller.js +++ b/src/core/frames/frame_controller.js @@ -276,7 +276,7 @@ export class FrameController { return !defaultPrevented } - viewRenderedSnapshot(_snapshot, _isPreview) {} + viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {} preloadOnLoadLinksForView(element) { session.preloadOnLoadLinksForView(element) diff --git a/src/core/index.js b/src/core/index.js index 2a3dd8fe5..743380d7a 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -4,11 +4,13 @@ import { PageRenderer } from "./drive/page_renderer" import { PageSnapshot } from "./drive/page_snapshot" import { FrameRenderer } from "./frames/frame_renderer" import { FormSubmission } from "./drive/form_submission" +import { LimitedSet } from "./drive/limited_set" const session = new Session() const cache = new Cache(session) +const recentRequests = new LimitedSet(20) const { navigator } = session -export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer } +export { navigator, session, cache, recentRequests, PageRenderer, PageSnapshot, FrameRenderer } export { StreamActions } from "./streams/stream_actions" diff --git a/src/core/native/browser_adapter.js b/src/core/native/browser_adapter.js index 7505d7f8f..c3f1086de 100644 --- a/src/core/native/browser_adapter.js +++ b/src/core/native/browser_adapter.js @@ -22,11 +22,7 @@ export class BrowserAdapter { visitRequestStarted(visit) { this.progressBar.setValue(0) - if (visit.hasCachedSnapshot() || visit.action != "restore") { - this.showVisitProgressBarAfterDelay() - } else { - this.showProgressBar() - } + this.showVisitProgressBarAfterDelay() } visitRequestCompleted(visit) { diff --git a/src/core/renderer.js b/src/core/renderer.js index 694a2004c..56e73983e 100644 --- a/src/core/renderer.js +++ b/src/core/renderer.js @@ -79,4 +79,8 @@ export class Renderer { get permanentElementMap() { return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot) } + + get renderMethod() { + return "replace" + } } diff --git a/src/core/session.js b/src/core/session.js index 44edc7856..6ffcea244 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -263,9 +263,9 @@ export class Session { return !defaultPrevented } - viewRenderedSnapshot(_snapshot, isPreview) { + viewRenderedSnapshot(_snapshot, isPreview, renderMethod) { this.view.lastRenderedLocation = this.history.location - this.notifyApplicationAfterRender(isPreview) + this.notifyApplicationAfterRender(isPreview, renderMethod) } preloadOnLoadLinksForView(element) { @@ -328,8 +328,8 @@ export class Session { }) } - notifyApplicationAfterRender(isPreview) { - return dispatch("turbo:render", { detail: { isPreview } }) + notifyApplicationAfterRender(isPreview, renderMethod) { + return dispatch("turbo:render", { detail: { isPreview, renderMethod } }) } notifyApplicationAfterPageLoad(timing = {}) { diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js index 7b06f5b84..62801fd69 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -30,5 +30,14 @@ export const StreamActions = { targetElement.innerHTML = "" targetElement.append(this.templateContent) }) + }, + + refresh() { + const requestId = this.getAttribute("request-id") + const isRecentRequest = requestId && window.Turbo.recentRequests.has(requestId) + if (!isRecentRequest) { + window.Turbo.cache.exemptPageFromPreview() + window.Turbo.visit(window.location.href, { action: "replace" }) + } } } diff --git a/src/core/view.js b/src/core/view.js index ca81e8bdb..62ec136da 100644 --- a/src/core/view.js +++ b/src/core/view.js @@ -69,7 +69,7 @@ export class View { 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 { diff --git a/src/http/recent_fetch_requests.js b/src/http/recent_fetch_requests.js new file mode 100644 index 000000000..620afd0a5 --- /dev/null +++ b/src/http/recent_fetch_requests.js @@ -0,0 +1,15 @@ +import { uuid } from "../util" + +const originalFetch = window.fetch + +window.fetch = async function(url, options = {}) { + const modifiedHeaders = new Headers(options.headers || {}) + const requestUID = uuid() + window.Turbo.recentRequests.add(requestUID) + modifiedHeaders.append("X-Turbo-Request-Id", requestUID) + + return originalFetch(url, { + ...options, + headers: modifiedHeaders + }) +} diff --git a/src/index.js b/src/index.js index 07e3e097d..b76e23871 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ import "./polyfills" import "./elements" import "./script_warning" +import "./http/recent_fetch_requests" import * as Turbo from "./core" diff --git a/src/tests/fixtures/frame_refresh_morph.html b/src/tests/fixtures/frame_refresh_morph.html new file mode 100644 index 000000000..bdfeed9fc --- /dev/null +++ b/src/tests/fixtures/frame_refresh_morph.html @@ -0,0 +1,3 @@ + +

Loaded morphed frame

+
diff --git a/src/tests/fixtures/frame_refresh_reload.html b/src/tests/fixtures/frame_refresh_reload.html new file mode 100644 index 000000000..7dd5e83ec --- /dev/null +++ b/src/tests/fixtures/frame_refresh_reload.html @@ -0,0 +1,3 @@ + +

Loaded reloadable frame

+
diff --git a/src/tests/fixtures/page_refresh.html b/src/tests/fixtures/page_refresh.html new file mode 100644 index 000000000..a54d99f18 --- /dev/null +++ b/src/tests/fixtures/page_refresh.html @@ -0,0 +1,52 @@ + + + + + + + + Turbo + + + + + + + +

Page to be refreshed

+ + +

Frame to be morphed

+
+ + +

Frame to be reloaded

+
+ +
+ Preserve me! +
+ +
+

Element with Stimulus controller

+
+ +

Link to another page

+ +
+ + + + +
+ + diff --git a/src/tests/fixtures/page_refresh_replace.html b/src/tests/fixtures/page_refresh_replace.html new file mode 100644 index 000000000..df4be0cfb --- /dev/null +++ b/src/tests/fixtures/page_refresh_replace.html @@ -0,0 +1,45 @@ + + + + + + + Turbo + + + + + + + +

Page to be refreshed

+ + +

Frame to be morphed

+
+ + +

Frame to be reloaded

+
+ +
+ Preserve me! +
+ +
+ + + + +
+ + diff --git a/src/tests/fixtures/page_refresh_scroll_reset.html b/src/tests/fixtures/page_refresh_scroll_reset.html new file mode 100644 index 000000000..2c0ed1e32 --- /dev/null +++ b/src/tests/fixtures/page_refresh_scroll_reset.html @@ -0,0 +1,44 @@ + + + + + + + + Turbo + + + + + + + +

Page to be refreshed

+ + +

Frame to be refreshed

+
+ +
+ + + + +
+ + diff --git a/src/tests/fixtures/page_refresh_stream_action.html b/src/tests/fixtures/page_refresh_stream_action.html new file mode 100644 index 000000000..4fb80d1c8 --- /dev/null +++ b/src/tests/fixtures/page_refresh_stream_action.html @@ -0,0 +1,19 @@ + + + + + Turbo Streams + + + + +
+ + +
+ +
+ Hello +
+ + diff --git a/src/tests/fixtures/page_refreshed.html b/src/tests/fixtures/page_refreshed.html new file mode 100644 index 000000000..e8c97f923 --- /dev/null +++ b/src/tests/fixtures/page_refreshed.html @@ -0,0 +1,12 @@ + + + + + Turbo + + + + +

Refreshed page

+ + diff --git a/src/tests/fixtures/test.js b/src/tests/fixtures/test.js index 9f71b536e..b461c971f 100644 --- a/src/tests/fixtures/test.js +++ b/src/tests/fixtures/test.js @@ -83,6 +83,7 @@ "turbo:frame-load", "turbo:frame-render", "turbo:frame-missing", + "turbo:before-frame-morph", "turbo:reload" ]) diff --git a/src/tests/functional/navigation_tests.js b/src/tests/functional/navigation_tests.js index f18a7ea1e..c4263d0f3 100644 --- a/src/tests/functional/navigation_tests.js +++ b/src/tests/functional/navigation_tests.js @@ -195,7 +195,13 @@ test("test following a POST form clears cache", async ({ page }) => { await page.click("#form-post-submit") await nextBeat() // 301 redirect response await nextBeat() // 200 response + + assert.equal(await page.textContent("h1"), "One") + await page.goBack() + await nextBeat() + + assert.equal(await page.textContent("h1"), "Navigation") assert.notOk(await hasSelector(page, "some-cached-element")) }) diff --git a/src/tests/functional/page_refresh_stream_action_tests.js b/src/tests/functional/page_refresh_stream_action_tests.js new file mode 100644 index 000000000..22cf17e8d --- /dev/null +++ b/src/tests/functional/page_refresh_stream_action_tests.js @@ -0,0 +1,55 @@ +import { test } from "@playwright/test" +import { assert } from "chai" +import { nextBeat } from "../helpers/page" + +test.beforeEach(async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh_stream_action.html") +}) + +test("test refreshing the page", async ({ page }) => { + assert.match(await textContent(page), /Hello/) + + await page.locator("#content").evaluate((content)=>content.innerHTML = "") + assert.notMatch(await textContent(page), /Hello/) + + await page.click("#refresh button") + await nextBeat() + + assert.match(await textContent(page), /Hello/) +}) + +test("don't refresh the page on self-originated request ids", async ({ page }) => { + assert.match(await textContent(page), /Hello/) + + await page.locator("#content").evaluate((content)=>content.innerHTML = "") + page.evaluate(()=> { window.Turbo.recentRequests.add("123") }) + + await page.locator("#request-id").evaluate((input)=>input.value = "123") + await page.click("#refresh button") + await nextBeat() + + assert.notMatch(await textContent(page), /Hello/) +}) + +test("fetch injects a Turbo-Request-Id with a UID generated automatically", async ({ page }) => { + const response1 = await fetchRequestId(page) + const response2 = await fetchRequestId(page) + + assert.notEqual(response1, response2) + + for (const response of [response1, response2]) { + assert.match(response, /.+-.+-.+-.+/) + } +}) + +async function textContent(page) { + const messages = await page.locator("#content") + return await messages.textContent() +} + +async function fetchRequestId(page) { + return await page.evaluate(async () => { + const response = await window.fetch("/__turbo/request_id_header") + return response.text() + }) +} diff --git a/src/tests/functional/page_refresh_tests.js b/src/tests/functional/page_refresh_tests.js new file mode 100644 index 000000000..73c0f4c5f --- /dev/null +++ b/src/tests/functional/page_refresh_tests.js @@ -0,0 +1,115 @@ +import { test, expect } from "@playwright/test" +import { + nextAttributeMutationNamed, + nextBeat, + nextEventNamed, + nextEventOnTarget, + noNextEventNamed, + noNextEventOnTarget +} from "../helpers/page" + +test("renders a page refresh with morphing", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.click("#form-submit") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) +}) + +test("doesn't morph when the turbo-refresh-method meta tag is not 'morph'", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh_replace.html") + + await page.click("#form-submit") + expect(await noNextEventNamed(page, "turbo:render", { renderMethod: "morph" })).toBeTruthy() +}) + +test("doesn't morph when the navigation doesn't go to the same URL", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.click("#link") + await expect(page.locator("h1")).toHaveText("One") + + expect(await noNextEventNamed(page, "turbo:render", { renderMethod: "morph" })).toBeTruthy() +}) + +test("uses morphing to update remote frames marked with refresh='morph'", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.click("#form-submit") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + await nextBeat() + + // Only the frame marked with refresh="morph" uses morphing + expect(await nextEventOnTarget(page, "refresh-morph", "turbo:before-frame-morph")).toBeTruthy() + expect(await noNextEventOnTarget(page, "refresh-reload", "turbo:before-frame-morph")).toBeTruthy() +}) + +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") + + await page.evaluate(() => window.scrollTo(10, 10)) + await assertPageScroll(page, 10, 10) + + // not using page.locator("#form-submit").click() because it can reset the scroll position + await page.evaluate(() => document.getElementById("form-submit")?.click()) + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + + await assertPageScroll(page, 10, 10) +}) + +test("it resets the scroll position when the turbo-refresh-scroll meta tag is 'reset'", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh_scroll_reset.html") + + await page.evaluate(() => window.scrollTo(10, 10)) + await assertPageScroll(page, 10, 10) + + // not using page.locator("#form-submit").click() because it can reset the scroll position + await page.evaluate(() => document.getElementById("form-submit")?.click()) + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + + await assertPageScroll(page, 0, 0) +}) + +test("it preserves data-turbo-permanent elements", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.evaluate(() => { + const element = document.getElementById("preserve-me") + element.textContent = "Preserve me, I have a family!" + }) + + await expect(page.locator("#preserve-me")).toHaveText("Preserve me, I have a family!") + + await page.click("#form-submit") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + + await expect(page.locator("#preserve-me")).toHaveText("Preserve me, I have a family!") +}) + +test("it reloads data-controller attributes after a morph", async ({ page }) => { + await page.goto("/src/tests/fixtures/page_refresh.html") + + await page.click("#form-submit") + await nextEventNamed(page, "turbo:render", { renderMethod: "morph" }) + + expect( + await nextAttributeMutationNamed(page, "stimulus-controller", "data-controller") + ).toEqual(null) + + await nextBeat() + + expect( + await nextAttributeMutationNamed(page, "stimulus-controller", "data-controller") + ).toEqual("test") +}) + +async function assertPageScroll(page, top, left) { + const [scrollTop, scrollLeft] = await page.evaluate(() => { + return [ + document.documentElement.scrollTop || document.body.scrollTop, + document.documentElement.scrollLeft || document.body.scrollLeft + ] + }) + + expect(scrollTop).toEqual(top) + expect(scrollLeft).toEqual(left) +} diff --git a/src/tests/helpers/page.js b/src/tests/helpers/page.js index 580327d25..ccc75e654 100644 --- a/src/tests/helpers/page.js +++ b/src/tests/helpers/page.js @@ -68,11 +68,13 @@ export function nextBody(_page, timeout = 500) { return sleep(timeout) } -export async function nextEventNamed(page, eventName) { +export async function nextEventNamed(page, eventName, expectedDetail = {}) { let record while (!record) { const records = await readEventLogs(page, 1) - record = records.find(([name]) => name == eventName) + record = records.find(([name, detail]) => { + return name == eventName && Object.entries(expectedDetail).every(([key, value]) => detail[key] === value) + }) } return record[1] } @@ -126,9 +128,11 @@ export async function noNextAttributeMutationNamed(page, elementId, attributeNam return !records.some(([name, _, target]) => name == attributeName && target == elementId) } -export async function noNextEventNamed(page, eventName) { - const records = await readEventLogs(page, 1) - return !records.some(([name]) => name == eventName) +export async function noNextEventNamed(page, eventName, expectedDetail = {}) { + const records = await readEventLogs(page) + return !records.some(([name, detail]) => { + return name === eventName && Object.entries(expectedDetail).every(([key, value]) => value === detail[key]) + }) } export async function noNextEventOnTarget(page, elementId, eventName) { diff --git a/src/tests/server.mjs b/src/tests/server.mjs index 0628f2e30..0d35cb57d 100644 --- a/src/tests/server.mjs +++ b/src/tests/server.mjs @@ -4,7 +4,7 @@ import bodyParser from "body-parser" import multer from "multer" import path from "path" import url from "url" -import { fileURLToPath } from 'url' +import { fileURLToPath } from "url" import fs from "fs" const __filename = fileURLToPath(import.meta.url) @@ -51,6 +51,11 @@ router.get("/redirect", (request, response) => { response.redirect(301, url.format({ pathname, query })) }) +router.post("/refresh", (request, response) => { + const { sleep } = request.body + setTimeout(() => response.redirect("back"), parseInt(sleep || "0", 10)) +}) + router.post("/reject/tall", (request, response) => { const { status } = request.body const fixture = path.join(__dirname, `../../src/tests/fixtures/422_tall.html`) @@ -94,6 +99,28 @@ router.post("/messages", (request, response) => { } }) +router.post("/refreshes", (request, response) => { + const params = { ...request.body, ...request.query } + const { requestId } = params + + if(acceptsStreams(request)){ + response.type("text/vnd.turbo-stream.html; charset=utf-8") + response.send(renderPageRefresh(requestId)) + } else { + response.sendStatus(201) + } +}) + +router.get("/request_id_header", (request, response) => { + const turboRequestHeader = request.get("X-Turbo-Request-Id") + + if (turboRequestHeader) { + response.send(turboRequestHeader); + } else { + response.status(404).send("X-Turbo-Request header not found") + } +}) + router.post("/notfound", (request, response) => { response.type("html").status(404).send("

Not found

") }) @@ -166,6 +193,12 @@ function renderMessageForTargets(content, id, targets) { ` } +function renderPageRefresh(requestId) { + return ` + + ` +} + function acceptsStreams(request) { return !!request.accepts("text/vnd.turbo-stream.html") } diff --git a/src/tests/unit/limited_set_tests.js b/src/tests/unit/limited_set_tests.js new file mode 100644 index 000000000..6fa67ba13 --- /dev/null +++ b/src/tests/unit/limited_set_tests.js @@ -0,0 +1,17 @@ +import { assert } from "@open-wc/testing" +import { LimitedSet } from "../../core/drive/limited_set" + +test("add a limited number of elements", () => { + const set = new LimitedSet(3) + set.add(1) + set.add(2) + set.add(3) + set.add(4) + + assert.equal(set.size, 3) + + assert.notInclude(set, 1) + assert.include(set, 2) + assert.include(set, 3) + assert.include(set, 4) +}) diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index 05ecc8589..bd62c8aa5 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -2,6 +2,8 @@ import { StreamElement } from "../../elements" import { nextAnimationFrame } from "../../util" import { DOMTestCase } from "../helpers/dom_test_case" import { assert } from "@open-wc/testing" +import { nextBeat } from "../helpers/page" +import * as Turbo from "../../index" function createStreamElement(action, target, templateElement) { const element = new StreamElement() @@ -167,3 +169,30 @@ test("test action=before", async () => { assert.ok(subject.find("h1#before")) assert.isNull(element.parentElement) }) + +test("test action=refresh", async () => { + document.body.setAttribute("data-modified", "") + assert.ok(document.body.hasAttribute("data-modified")) + + const element = createStreamElement("refresh") + subject.append(element) + + await nextBeat() + + assert.notOk(document.body.hasAttribute("data-modified")) +}) + +test("test action=refresh discarded when matching request id", async () => { + Turbo.recentRequests.add("123") + + document.body.setAttribute("data-modified", "") + assert.ok(document.body.hasAttribute("data-modified")) + + const element = createStreamElement("refresh") + element.setAttribute("request-id", "123") + subject.append(element) + + await nextBeat() + + assert.ok(document.body.hasAttribute("data-modified")) +}) diff --git a/yarn.lock b/yarn.lock index fa00b7d23..b0e316fa0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1865,6 +1865,10 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +"idiomorph@https://github.com/basecamp/idiomorph#rollout-build": + version "0.0.8" + resolved "https://github.com/basecamp/idiomorph#e906820368e4c9c52489a3336b8c3826b1bf6de5" + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" From 3f452549ad3f22bd7dc40da197e61962ad631ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Sun, 8 Oct 2023 17:04:07 +0200 Subject: [PATCH 2/5] Introduce refresh attribute for frame element --- src/core/drive/morph_renderer.js | 2 +- src/elements/frame_element.js | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js index 4cc10a6e8..802cb6b07 100644 --- a/src/core/drive/morph_renderer.js +++ b/src/core/drive/morph_renderer.js @@ -73,7 +73,7 @@ export class MorphRenderer extends Renderer { } #isFrameReloadedWithMorph(element) { - return element.getAttribute("src") && element.getAttribute("refresh") === "morph" + return element.src && element.refresh === "morph" } #remoteFrames() { diff --git a/src/elements/frame_element.js b/src/elements/frame_element.js index 0e2bee917..4feb36713 100644 --- a/src/elements/frame_element.js +++ b/src/elements/frame_element.js @@ -75,6 +75,24 @@ export class FrameElement extends HTMLElement { } } + /** + * Gets the refresh mode for the frame. + */ + get refresh() { + return this.getAttribute("refresh") + } + + /** + * Sets the refresh mode for the frame. + */ + set refresh(value) { + if (value) { + this.setAttribute("refresh", value) + } else { + this.removeAttribute("refresh") + } + } + /** * Determines if the element is loading */ From a653d1238da17f4d7813b06b8ffa03a2e91dc300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Sun, 8 Oct 2023 17:21:07 +0200 Subject: [PATCH 3/5] Use dispatch util function in MorphRenderer --- src/core/drive/morph_renderer.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js index 802cb6b07..8f8181464 100644 --- a/src/core/drive/morph_renderer.js +++ b/src/core/drive/morph_renderer.js @@ -1,5 +1,5 @@ import Idiomorph from "idiomorph" -import { nextAnimationFrame } from "../../util" +import { dispatch, nextAnimationFrame } from "../../util" import { Renderer } from "../renderer" export class MorphRenderer extends Renderer { @@ -17,7 +17,12 @@ export class MorphRenderer extends Renderer { this.#morphElements(this.currentElement, this.newElement) this.#reloadRemoteFrames() - this.#dispatchEvent("turbo:morph", { currentElement: this.currentElement, newElement: this.newElement }) + dispatch("turbo:morph", { + detail: { + currentElement: this.currentElement, + newElement: this.newElement + } + }) } #morphElements(currentElement, newElement, morphStyle = "outerHTML") { @@ -47,7 +52,10 @@ export class MorphRenderer extends Renderer { } #morphFrameUpdate = (currentElement, newElement) => { - this.#dispatchEvent("turbo:before-frame-morph", { currentElement, newElement }, currentElement) + dispatch("turbo:before-frame-morph", { + target: currentElement, + detail: { currentElement, newElement } + }) this.#morphElements(currentElement, newElement, "innerHTML") } @@ -79,10 +87,4 @@ export class MorphRenderer extends Renderer { #remoteFrames() { return document.querySelectorAll("turbo-frame[src]") } - - #dispatchEvent(name, detail, target = document.documentElement) { - const event = new CustomEvent(name, { bubbles: true, cancelable: true, detail }) - target.dispatchEvent(event) - return event - } } From 2fb0190ce69089a537a3d0256917a388993c837c Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sun, 8 Oct 2023 18:49:42 -0400 Subject: [PATCH 4/5] [IGNORE]: extracted in https://github.com/hotwired/turbo/pull/1028 --- src/core/drive/morph_renderer.js | 3 +++ src/core/drive/page_view.js | 4 ++-- src/core/frames/frame_controller.js | 2 +- src/core/renderer.js | 8 ++++++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js index 8f8181464..e9a647779 100644 --- a/src/core/drive/morph_renderer.js +++ b/src/core/drive/morph_renderer.js @@ -3,6 +3,9 @@ import { dispatch, nextAnimationFrame } from "../../util" import { Renderer } from "../renderer" export class MorphRenderer extends Renderer { + static renderElement(currentElement, newElement) { + } + async render() { if (this.willRender) await this.#morphBody() } diff --git a/src/core/drive/page_view.js b/src/core/drive/page_view.js index 6cf25dfd5..bff0815f0 100644 --- a/src/core/drive/page_view.js +++ b/src/core/drive/page_view.js @@ -19,7 +19,7 @@ export class PageView extends View { const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage const rendererClass = shouldMorphPage ? MorphRenderer : PageRenderer - const renderer = new rendererClass(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender) + const renderer = new rendererClass(this.snapshot, snapshot, isPreview, willRender) if (!renderer.shouldRender) { this.forceReloaded = true @@ -32,7 +32,7 @@ export class PageView extends View { renderError(snapshot, visit) { visit?.changeHistory() - const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false) + const renderer = new ErrorRenderer(this.snapshot, snapshot, false) return this.render(renderer) } diff --git a/src/core/frames/frame_controller.js b/src/core/frames/frame_controller.js index 5940ba761..4a85196d8 100644 --- a/src/core/frames/frame_controller.js +++ b/src/core/frames/frame_controller.js @@ -307,7 +307,7 @@ export class FrameController { if (newFrameElement) { const snapshot = new Snapshot(newFrameElement) - const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false) + const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, false, false) if (this.view.renderPromise) await this.view.renderPromise this.changeHistory() diff --git a/src/core/renderer.js b/src/core/renderer.js index 56e73983e..414335b88 100644 --- a/src/core/renderer.js +++ b/src/core/renderer.js @@ -3,12 +3,16 @@ import { Bardo } from "./bardo" export class Renderer { #activeElement = null - constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { + static renderElement(currentElement, newElement) { + // Abstract method + } + + constructor(currentSnapshot, newSnapshot, isPreview, willRender = true) { this.currentSnapshot = currentSnapshot this.newSnapshot = newSnapshot this.isPreview = isPreview this.willRender = willRender - this.renderElement = renderElement + this.renderElement = this.constructor.renderElement this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })) } From 2fd95cfc5fb61defb0aa59a17a11b239bced92d4 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sun, 8 Oct 2023 20:04:28 -0400 Subject: [PATCH 5/5] Extract `Morph` class, then use it in `FrameRenderer` Declaring all `Idiomorph`-related logic in the `MorphRenderer` limits its accessibility to other parts of the system. For example, `` elements are unable to morph their renders when driven by `` navigations or `
` submissions. This commit extracts the bulk of the morphing logic into a new `Morph` class. The `Morph` encapsulates the call to `Idiomorph`, along with the remote `` reloading and `[data-turbo-permanent]` checking. With this extraction, the `MorphRenderer` is implemented in terms of delegating to the static `Morph.render` method. With that extraction in place, the `FrameRenderer.renderElement` method can incorporate the `element.src && element.refresh === "morph"` check, calling `FrameRenderer.morph` when true, then falling back to the default `FrameRenderer.replace` when false. For the sake of consistency, declare all `Renderer` subclasses' `renderElement` methods in terms of `static replace` and `static morph` methods to be explicit about which styles they support. This commit includes test coverage for morphing `` elements driven by typical navigation. The potential for `` --- With the `Morph.render` function existing separately from `MorphRenderer`, there's the potential to add a new `StreamAction.morph`. The implementation would look something like: ```js morph() { this.targetElements.forEach((targetElement) => { Morph.render(targetElement, this.templateContent) }) } ``` I've omitted that from this commit because I'm not sure if that's an interface we're interested in introducing, but I did want to highlight the possibility here. It'd be an Idiomorph-powered version of the [turbo-morph][] package. [turbo-morph]: https://github.com/marcoroth/turbo-morph --- package.json | 2 +- src/core/drive/error_renderer.js | 4 + src/core/drive/morph_renderer.js | 87 ++------------------ src/core/drive/page_renderer.js | 4 + src/core/frames/frame_renderer.js | 28 ++++++- src/core/morph.js | 54 ++++++++++++ src/tests/fixtures/frames.html | 11 +++ src/tests/fixtures/frames/refresh_morph.html | 23 ++++++ src/tests/functional/frame_tests.js | 47 +++++++++++ src/tests/helpers/page.js | 6 +- yarn.lock | 7 +- 11 files changed, 187 insertions(+), 86 deletions(-) create mode 100644 src/core/morph.js create mode 100644 src/tests/fixtures/frames/refresh_morph.html diff --git a/package.json b/package.json index 214ddee8e..74c0d20fc 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "access": "public" }, "dependencies": { - "idiomorph": "https://github.com/basecamp/idiomorph#rollout-build" + "idiomorph": "^0.0.9" }, "devDependencies": { "@open-wc/testing": "^3.1.7", diff --git a/src/core/drive/error_renderer.js b/src/core/drive/error_renderer.js index 0e5f2ce39..0d3b48b6b 100644 --- a/src/core/drive/error_renderer.js +++ b/src/core/drive/error_renderer.js @@ -3,6 +3,10 @@ import { Renderer } from "../renderer" export class ErrorRenderer extends Renderer { static renderElement(currentElement, newElement) { + ErrorRenderer.replace(currentElement, newElement) + } + + static replace(currentElement, newElement) { const { documentElement, body } = document documentElement.replaceChild(newElement, body) diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js index e9a647779..1eedaacf6 100644 --- a/src/core/drive/morph_renderer.js +++ b/src/core/drive/morph_renderer.js @@ -1,93 +1,20 @@ -import Idiomorph from "idiomorph" -import { dispatch, nextAnimationFrame } from "../../util" import { Renderer } from "../renderer" +import { Morph } from "../morph" export class MorphRenderer extends Renderer { static renderElement(currentElement, newElement) { + MorphRenderer.morph(currentElement, newElement) + } + + static morph(currentElement, newElement) { + Morph.render(currentElement, newElement) } async render() { - if (this.willRender) await this.#morphBody() + if (this.willRender) this.renderElement(this.currentElement, this.newElement) } get renderMethod() { return "morph" } - - // Private - - async #morphBody() { - this.#morphElements(this.currentElement, this.newElement) - this.#reloadRemoteFrames() - - dispatch("turbo:morph", { - detail: { - currentElement: this.currentElement, - newElement: this.newElement - } - }) - } - - #morphElements(currentElement, newElement, morphStyle = "outerHTML") { - Idiomorph.morph(currentElement, newElement, { - morphStyle: morphStyle, - callbacks: { - beforeNodeMorphed: this.#shouldMorphElement, - beforeNodeRemoved: this.#shouldRemoveElement, - afterNodeMorphed: this.#reloadStimulusControllers - } - }) - } - - #reloadRemoteFrames() { - this.#remoteFrames().forEach((frame) => { - if (this.#isFrameReloadedWithMorph(frame)) { - this.#renderFrameWithMorph(frame) - } - frame.reload() - }) - } - - #renderFrameWithMorph(frame) { - frame.addEventListener("turbo:before-frame-render", (event) => { - event.detail.render = this.#morphFrameUpdate - }, { once: true }) - } - - #morphFrameUpdate = (currentElement, newElement) => { - dispatch("turbo:before-frame-morph", { - target: currentElement, - detail: { currentElement, newElement } - }) - this.#morphElements(currentElement, newElement, "innerHTML") - } - - #shouldRemoveElement = (node) => { - return this.#shouldMorphElement(node) - } - - #shouldMorphElement = (node) => { - if (node instanceof HTMLElement) { - return !node.hasAttribute("data-turbo-permanent") - } else { - return true - } - } - - #reloadStimulusControllers = async (node) => { - if (node instanceof HTMLElement && node.hasAttribute("data-controller")) { - const originalAttribute = node.getAttribute("data-controller") - node.removeAttribute("data-controller") - await nextAnimationFrame() - node.setAttribute("data-controller", originalAttribute) - } - } - - #isFrameReloadedWithMorph(element) { - return element.src && element.refresh === "morph" - } - - #remoteFrames() { - return document.querySelectorAll("turbo-frame[src]") - } } diff --git a/src/core/drive/page_renderer.js b/src/core/drive/page_renderer.js index 0c1a222e6..3d6db9542 100644 --- a/src/core/drive/page_renderer.js +++ b/src/core/drive/page_renderer.js @@ -3,6 +3,10 @@ import { activateScriptElement, waitForLoad } from "../../util" export class PageRenderer extends Renderer { static renderElement(currentElement, newElement) { + PageRenderer.replace(currentElement, newElement) + } + + static replace(currentElement, newElement) { if (document.body && newElement instanceof HTMLBodyElement) { document.body.replaceWith(newElement) } else { diff --git a/src/core/frames/frame_renderer.js b/src/core/frames/frame_renderer.js index 1384465d6..a9b974077 100644 --- a/src/core/frames/frame_renderer.js +++ b/src/core/frames/frame_renderer.js @@ -1,8 +1,17 @@ -import { activateScriptElement, nextAnimationFrame } from "../../util" +import { activateScriptElement, nextAnimationFrame, dispatch } from "../../util" import { Renderer } from "../renderer" +import { Morph } from "../morph" export class FrameRenderer extends Renderer { static renderElement(currentElement, newElement) { + if (currentElement.src && currentElement.refresh === "morph") { + FrameRenderer.morph(currentElement, newElement) + } else { + FrameRenderer.replace(currentElement, newElement) + } + } + + static replace(currentElement, newElement) { const destinationRange = document.createRange() destinationRange.selectNodeContents(currentElement) destinationRange.deleteContents() @@ -15,6 +24,15 @@ export class FrameRenderer extends Renderer { } } + static morph(currentElement, newElement) { + dispatch("turbo:before-frame-morph", { + target: currentElement, + detail: { currentElement, newElement } + }) + + Morph.render(currentElement, newElement.children, "innerHTML") + } + constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender) this.delegate = delegate @@ -24,6 +42,14 @@ export class FrameRenderer extends Renderer { return true } + get renderMethod() { + if (this.currentElement.refresh === "morph") { + return "morph" + } else { + return super.renderMethod + } + } + async render() { await nextAnimationFrame() this.preservingPermanentElements(() => { diff --git a/src/core/morph.js b/src/core/morph.js new file mode 100644 index 000000000..42724aa1a --- /dev/null +++ b/src/core/morph.js @@ -0,0 +1,54 @@ +import "idiomorph" +import { nextAnimationFrame } from "../util" + +export class Morph { + static render(currentElement, newElement, morphStyle) { + const morph = new this(currentElement, newElement) + + morph.render(morphStyle) + } + + constructor(currentElement, newElement) { + this.currentElement = currentElement + this.newElement = newElement + } + + render(morphStyle = "outerHTML") { + window.Idiomorph.morph(this.currentElement, this.newElement, { + ignoreActiveValue: true, + morphStyle: morphStyle, + callbacks: { + beforeNodeMorphed: shouldMorphElement, + beforeNodeRemoved: shouldRemoveElement, + afterNodeMorphed: reloadStimulusControllers + } + }) + + this.#remoteFrames.forEach((frame) => frame.reload()) + } + + get #remoteFrames() { + return this.currentElement.querySelectorAll("turbo-frame[src]") + } +} + +function shouldRemoveElement(node) { + return shouldMorphElement(node) +} + +function shouldMorphElement(node) { + if (node instanceof HTMLElement) { + return !node.hasAttribute("data-turbo-permanent") + } else { + return true + } +} + +async function reloadStimulusControllers(node) { + if (node instanceof HTMLElement && node.hasAttribute("data-controller")) { + const originalAttribute = node.getAttribute("data-controller") + node.removeAttribute("data-controller") + await nextAnimationFrame() + node.setAttribute("data-controller", originalAttribute) + } +} diff --git a/src/tests/fixtures/frames.html b/src/tests/fixtures/frames.html index d6a82d831..fc8de4047 100644 --- a/src/tests/fixtures/frames.html +++ b/src/tests/fixtures/frames.html @@ -62,6 +62,17 @@

Frames: #hello

+ +

Source: #refresh-morph

+ +
Navigate #refresh-morph + + + + +
+
+ advance #hello diff --git a/src/tests/fixtures/frames/refresh_morph.html b/src/tests/fixtures/frames/refresh_morph.html new file mode 100644 index 000000000..a08cca945 --- /dev/null +++ b/src/tests/fixtures/frames/refresh_morph.html @@ -0,0 +1,23 @@ + + + + + Frames: Refresh=Morph + + + + +

Refresh=Morph

+ + +

Destination: #refresh-morph

+ + Navigate #refresh-morph + +
+ + +
+
+ + diff --git a/src/tests/functional/frame_tests.js b/src/tests/functional/frame_tests.js index bd06f2d15..f760ccf94 100644 --- a/src/tests/functional/frame_tests.js +++ b/src/tests/functional/frame_tests.js @@ -4,6 +4,7 @@ import { attributeForSelector, hasSelector, listenForEventOnTarget, + locatorHasFocus, nextAttributeMutationNamed, noNextAttributeMutationNamed, nextBeat, @@ -882,6 +883,52 @@ test("test navigating a eager frame with a link[method=get] that does not fetch assert.equal(pathname(page.url()), "/src/tests/fixtures/page_with_eager_frame.html") }) +test("test driving a morph frame with link preserves focus", async ({ page }) => { + const frame = await page.locator("turbo-frame#refresh-morph") + const link = await frame.locator("a:first-of-type") + + assert.include(await frame.textContent("h2"), "Source: #refresh-morph") + + await link.focus() + await link.press("Enter") + await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render") + + assert.ok(await locatorHasFocus(link), "restores focus after page loads") + assert.include(await frame.textContent("h2"), "Destination: #refresh-morph") + assert.equal(await frame.count("#input-in-morph"), 1) + + await link.press("Enter") + await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render") + + assert.ok(await locatorHasFocus(link), "restores focus after page loads") + assert.include(await frame.textContent("h2"), "Source: #refresh-morph") + assert.equal(await frame.count("#input-in-morph"), 1) +}) + +test("test driving a morph frame with form preserves focus", async ({ page }) => { + const frame = await page.locator("turbo-frame#refresh-morph") + const input = await frame.locator("#input-in-refresh-morph") + + assert.include(await frame.textContent("h2"), "Source: #refresh-morph") + + await input.type("hello") + await input.press("Enter") + await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render") + + assert.ok(await locatorHasFocus(input), "restores focus after page loads") + assert.include(await frame.textContent("h2"), "Destination: #refresh-morph") + await expect(input).toHaveValue("hello") + assert.equal(await frame.count("#input-in-morph"), 1) + + await input.press("Enter") + await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render") + + assert.ok(await locatorHasFocus(input), "restores focus after page loads") + assert.include(await frame.textContent("h2"), "Source: #refresh-morph") + await expect(input).toHaveValue("hello") + assert.equal(await frame.count("#input-in-morph"), 1) +}) + test("form submissions from frames clear snapshot cache", async ({ page }) => { await page.evaluate(() => { document.querySelector("h1").textContent = "Changed" diff --git a/src/tests/helpers/page.js b/src/tests/helpers/page.js index ccc75e654..a5753dced 100644 --- a/src/tests/helpers/page.js +++ b/src/tests/helpers/page.js @@ -205,7 +205,11 @@ export function searchParams(url) { } export function selectorHasFocus(page, selector) { - return page.locator(selector).evaluate((element) => element === document.activeElement) + return locatorHasFocus(page.locator(selector)) +} + +export function locatorHasFocus(locator) { + return locator.evaluate((element) => element === document.activeElement) } export function setLocalStorageFromEvent(page, eventName, storageKey, storageValue) { diff --git a/yarn.lock b/yarn.lock index b0e316fa0..36a8bcdd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1865,9 +1865,10 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -"idiomorph@https://github.com/basecamp/idiomorph#rollout-build": - version "0.0.8" - resolved "https://github.com/basecamp/idiomorph#e906820368e4c9c52489a3336b8c3826b1bf6de5" +idiomorph@^0.0.9: + version "0.0.9" + resolved "https://registry.yarnpkg.com/idiomorph/-/idiomorph-0.0.9.tgz#938d5964031381b0713398fb283aa3754306fa1b" + integrity sha512-X7SGsldE5jQD+peLjNLAnIJDEZtGpuLrNRUBrTWMMnzrx9gwy5U+SCRhaifO2v2Z+8j09IY2J+EYaxHsOLTD0A== ieee754@^1.1.13: version "1.2.1"