From fae93def77e8556cbcdda5a39eee7c6e109524dc Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sun, 8 Oct 2023 20:04:28 -0400 Subject: [PATCH] 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 --- 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 | 53 +++++++++++++++ src/tests/functional/rendering_tests.js | 12 ++++ 6 files changed, 107 insertions(+), 81 deletions(-) create mode 100644 src/core/morph.js 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..57cd40990 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, "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..2fe613421 --- /dev/null +++ b/src/core/morph.js @@ -0,0 +1,53 @@ +import Idiomorph from "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") { + Idiomorph.morph(this.currentElement, this.newElement, { + 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/functional/rendering_tests.js b/src/tests/functional/rendering_tests.js index f5c0810e2..33c221789 100644 --- a/src/tests/functional/rendering_tests.js +++ b/src/tests/functional/rendering_tests.js @@ -8,6 +8,7 @@ import { nextBody, nextBodyMutation, nextEventNamed, + nextEventOnTarget, noNextBodyMutation, pathname, propertyForSelector, @@ -440,6 +441,17 @@ test("test restores focus during turbo-frame rendering when transposing the acti assert.ok(await selectorHasFocus(page, "#permanent-input-in-frame"), "restores focus after page loads") }) +test("test restores focus during turbo-frame morphing when transposing the activeElement", async ({ page }) => { + const input = await page.locator("#permanent-input-in-frame") + const frame = await page.locator("turbo-frame#hello") + + await frame.evaluate((frame) => frame.setAttribute("refresh", "morph")) + await input.press("Enter") + await nextEventOnTarget(page, "hello", "turbo:frame-load") + + assert.ok(await selectorHasFocus(page, "#permanent-input-in-frame"), "restores focus after page loads") +}) + test("test restores focus during turbo-frame rendering when transposing a descendant of the activeElement", async ({ page }) => {