From 63b4edb6867571b28d1c58b3cd99cdcf29c43277 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sat, 30 Mar 2024 11:02:49 -0400 Subject: [PATCH] Extract and re-use element morphing logic Follow-up to [#1185][] Related to [#1192][] The `morphElements` function --- Introduce a new `src/core/morphing` module to expose a centralized and re-usable `morphElements(currentElement, newElement, delegate)` function to be invoked across the various morphing contexts. Next, move the logic from the `MorphRenderer` into a module-private `IdomorphDelegate` class. The `IdomorphDelegate` class (like its `MorphRenderer` predecessor) wraps a call to `Idiomorph` based on its own set of callbacks. The bulk of the logic remains in the `IdomorphDelegate` class, including checks for `[data-turbo-permanent]`. To serve as a seam for integration, the class retains a reference to a delegate responsible for: * providing options for the `Idiomorph` * determining whether or not a node should be skipped while morphing The `PageMorphRenderer` skips `` elements so that it can override their rendering to use morphing. Similarly, the `FrameMorphRenderer` provides the `morphStyle: "innerHTML"` option to morph its children. Changes to the renderers --- To integrate with the new module, first rename the `MorphRenderer` to `PageMorphRenderer` to set a new precedent that communicates the level of the document the morphing is scoped to. With that change in place, define the static `PageMorphRenderer.renderElement` to mirror the other existing renderer static functions (like [PageRenderer.renderElement][], [ErrorRenderer.renderElement][], and [FrameRenderer.renderElement][]). This integrates with the changes proposed in [#1028][]. Next, modify the rest of the `PageMorphRenderer` to integrate with its `PageRenderer` ancestor in a way that invokes the static `renderElement` function. This involves overriding the `preservingPermanentElements(callback)` method. In theory, morphing has implications on the concept of "permanence". In practice, morphing has the `[data-turbo-permanent]` attribute receive special treatment during morphing. Following the new precedent, introduce a new `FrameMorphRenderer` class to define the `FrameMorphRenderer.renderElement` function that invokes the `morphElements` function with `newElement.children` and `morphStyle: "innerHTML"`. Changes to the StreamActions --- The extraction of the `morphElements` function makes entirety of the `src/core/streams/actions/morph.js` module redundant. This commit removes that module and invokes `morphElements` directly within the `StreamActions.morph` function. Future possibilities --- In the future, additional changes could be made to expose the morphing capabilities as part of the `window.Turbo` interface. For example, applications could experiment with supporting [Page Refresh-style morphing for pages with different URL pathnames][] by overriding the rendering mechanism in `turbo:before-render`: ```js addEventListener("turbo:before-render", (event) => { const someCriteriaForMorphing = ... if (someCriteriaForMorphing) { event.detail.render = (currentElement, newElement) => { window.Turbo.morphElements(currentElement, newElement, { ... }) } } }) ``` [#1185]: https://github.com/hotwired/turbo/pull/1185#discussion_r1525281450 [#1192]: https://github.com/hotwired/turbo/pull/1192 [PageRenderer.renderElement]: https://github.com/hotwired/turbo/blob/9fb05e3ed3ebb15fe7b13f52941f25df425e3d15/src/core/drive/page_renderer.js#L5-L11 [ErrorRenderer.renderElement]: https://github.com/hotwired/turbo/blob/9fb05e3ed3ebb15fe7b13f52941f25df425e3d15/src/core/drive/error_renderer.js#L5-L9 [FrameRenderer.renderElement]: https://github.com/hotwired/turbo/blob/9fb05e3ed3ebb15fe7b13f52941f25df425e3d15/src/core/frames/frame_renderer.js#L5-L16 [#1028]: https://github.com/hotwired/turbo/pull/1028 [#1177]: https://github.com/hotwired/turbo/issues/1177 --- src/core/drive/morph_renderer.js | 118 ------------------------ src/core/drive/page_morph_renderer.js | 44 +++++++++ src/core/drive/page_view.js | 6 +- src/core/frames/frame_morph_renderer.js | 18 ++++ src/core/morphing.js | 61 ++++++++++++ src/core/streams/actions/morph.js | 65 ------------- src/core/streams/stream_actions.js | 8 +- 7 files changed, 132 insertions(+), 188 deletions(-) delete mode 100644 src/core/drive/morph_renderer.js create mode 100644 src/core/drive/page_morph_renderer.js create mode 100644 src/core/frames/frame_morph_renderer.js create mode 100644 src/core/morphing.js delete mode 100644 src/core/streams/actions/morph.js diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js deleted file mode 100644 index 2c5d14874..000000000 --- a/src/core/drive/morph_renderer.js +++ /dev/null @@ -1,118 +0,0 @@ -import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js" -import { dispatch } from "../../util" -import { PageRenderer } from "./page_renderer" - -export class MorphRenderer extends PageRenderer { - async render() { - if (this.willRender) await this.#morphBody() - } - - 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") { - this.isMorphingTurboFrame = this.#isFrameReloadedWithMorph(currentElement) - - Idiomorph.morph(currentElement, newElement, { - morphStyle: morphStyle, - callbacks: { - beforeNodeAdded: this.#shouldAddElement, - beforeNodeMorphed: this.#shouldMorphElement, - beforeAttributeUpdated: this.#shouldUpdateAttribute, - beforeNodeRemoved: this.#shouldRemoveElement, - afterNodeMorphed: this.#didMorphElement - } - }) - } - - #shouldAddElement = (node) => { - return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) - } - - #shouldMorphElement = (oldNode, newNode) => { - if (oldNode instanceof HTMLElement) { - if (!oldNode.hasAttribute("data-turbo-permanent") && (this.isMorphingTurboFrame || !this.#isFrameReloadedWithMorph(oldNode))) { - const event = dispatch("turbo:before-morph-element", { - cancelable: true, - target: oldNode, - detail: { - newElement: newNode - } - }) - - return !event.defaultPrevented - } else { - return false - } - } - } - - #shouldUpdateAttribute = (attributeName, target, mutationType) => { - const event = dispatch("turbo:before-morph-attribute", { cancelable: true, target, detail: { attributeName, mutationType } }) - - return !event.defaultPrevented - } - - #didMorphElement = (oldNode, newNode) => { - if (newNode instanceof HTMLElement) { - dispatch("turbo:morph-element", { - target: oldNode, - detail: { - newElement: newNode - } - }) - } - } - - #shouldRemoveElement = (node) => { - return this.#shouldMorphElement(node) - } - - #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.children, "innerHTML") - } - - #isFrameReloadedWithMorph(element) { - return element.src && element.refresh === "morph" - } - - #remoteFrames() { - return Array.from(document.querySelectorAll('turbo-frame[src]')).filter(frame => { - return !frame.closest('[data-turbo-permanent]') - }) - } -} diff --git a/src/core/drive/page_morph_renderer.js b/src/core/drive/page_morph_renderer.js new file mode 100644 index 000000000..46e92d68d --- /dev/null +++ b/src/core/drive/page_morph_renderer.js @@ -0,0 +1,44 @@ +import { morphElements } from "../morphing" +import { PageRenderer } from "./page_renderer" +import { FrameMorphRenderer } from "../frames/frame_morph_renderer" +import { FrameElement } from "../../elements/frame_element" +import { dispatch } from "../../util" + +export class PageMorphRenderer extends PageRenderer { + static renderElement(currentElement, newElement) { + morphElements(currentElement, newElement, { + shouldSkipMorphing(element) { + return shouldRefresh(element) + } + }) + + for (const frame of document.querySelectorAll("turbo-frame")) { + if (shouldRefresh(frame)) refresh(frame) + } + + dispatch("turbo:morph", { detail: { currentElement, newElement } }) + } + + async preservingPermanentElements(callback) { + return await callback() + } + + get renderMethod() { + return "morph" + } +} + +function shouldRefresh(frame) { + return frame instanceof FrameElement && + frame.src && + frame.refresh === "morph" && + !frame.closest("[data-turbo-permanent]") +} + +function refresh(frame) { + frame.addEventListener("turbo:before-frame-render", ({ detail }) => { + detail.render = FrameMorphRenderer.renderElement + }, { once: true }) + + frame.reload() +} diff --git a/src/core/drive/page_view.js b/src/core/drive/page_view.js index 1583f25a0..b4f3a99c7 100644 --- a/src/core/drive/page_view.js +++ b/src/core/drive/page_view.js @@ -1,7 +1,7 @@ import { nextEventLoopTick } from "../../util" import { View } from "../view" import { ErrorRenderer } from "./error_renderer" -import { MorphRenderer } from "./morph_renderer" +import { PageMorphRenderer } from "./page_morph_renderer" import { PageRenderer } from "./page_renderer" import { PageSnapshot } from "./page_snapshot" import { SnapshotCache } from "./snapshot_cache" @@ -17,9 +17,9 @@ export class PageView extends View { renderPage(snapshot, isPreview = false, willRender = true, visit) { const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage - const rendererClass = shouldMorphPage ? MorphRenderer : PageRenderer + const rendererClass = shouldMorphPage ? PageMorphRenderer : PageRenderer - const renderer = new rendererClass(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender) + const renderer = new rendererClass(this.snapshot, snapshot, rendererClass.renderElement, isPreview, willRender) if (!renderer.shouldRender) { this.forceReloaded = true diff --git a/src/core/frames/frame_morph_renderer.js b/src/core/frames/frame_morph_renderer.js new file mode 100644 index 000000000..6672bec14 --- /dev/null +++ b/src/core/frames/frame_morph_renderer.js @@ -0,0 +1,18 @@ +import { FrameRenderer } from "./frame_renderer" +import { morphElements } from "../morphing" +import { dispatch } from "../../util" + +export class FrameMorphRenderer extends FrameRenderer { + static renderElement(currentElement, newElement) { + dispatch("turbo:before-frame-morph", { + target: currentElement, + detail: { currentElement, newElement } + }) + + morphElements(currentElement, newElement.children, { + options: { + morphStyle: "innerHTML" + } + }) + } +} diff --git a/src/core/morphing.js b/src/core/morphing.js new file mode 100644 index 000000000..090fb0edd --- /dev/null +++ b/src/core/morphing.js @@ -0,0 +1,61 @@ +import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js" +import { dispatch } from "../util" + +const defaultOptions = { + morphStyle: "outerHTML" +} + +export function morphElements(currentElement, newElement, delegate = {}) { + const options = delegate.options || {} + const callbacks = new IdiomorphDelegate(delegate) + + Idiomorph.morph(currentElement, newElement, { + ...defaultOptions, + ...options, + callbacks + }) +} + +class IdiomorphDelegate { + constructor(delegate) { + this.delegate = delegate + } + + beforeNodeAdded = (node) => { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) + } + + beforeNodeMorphed = (target, newElement) => { + if (target instanceof HTMLElement) { + if (!target.hasAttribute("data-turbo-permanent") && !invoke(this.delegate, "shouldSkipMorphing", target)) { + const event = dispatch("turbo:before-morph-element", { cancelable: true, target, detail: { newElement } }) + + return !event.defaultPrevented + } else { + return false + } + } + } + + beforeAttributeUpdated = (attributeName, target, mutationType) => { + const event = dispatch("turbo:before-morph-attribute", { cancelable: true, target, detail: { attributeName, mutationType } }) + + return !event.defaultPrevented + } + + beforeNodeRemoved = (node) => { + return this.beforeNodeMorphed(node) + } + + afterNodeMorphed = (target, newNode) => { + if (newNode instanceof HTMLElement) { + dispatch("turbo:morph-element", { target, detail: { newElement: newNode } }) + } + } +} + +function invoke(delegate, methodName, ...methodArguments) { + if (delegate && typeof delegate[methodName] === "function") { + return delegate[methodName](...methodArguments) + } +} diff --git a/src/core/streams/actions/morph.js b/src/core/streams/actions/morph.js deleted file mode 100644 index 42e655c95..000000000 --- a/src/core/streams/actions/morph.js +++ /dev/null @@ -1,65 +0,0 @@ -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 486dc8566..3b5599f2f 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -1,5 +1,5 @@ import { session } from "../" -import morph from "./actions/morph" +import { morphElements } from "../morphing" export const StreamActions = { after() { @@ -40,6 +40,10 @@ export const StreamActions = { }, morph() { - morph(this) + const morphStyle = this.hasAttribute("children-only") ? "innerHTML" : "outerHTML" + + this.targetElements.forEach((targetElement) => { + morphElements(targetElement, this.templateContent, { options: { morphStyle } }) + }) } }