From 922e488b23be5fe4b277b0fb3a0278dea88a1d72 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 `morph{Page,Frames,Elements}` functions --- Introduce a new `src/core/morphing` module to expose a centralized and re-usable `morphElements(currentElement, newElement)` function to be invoked across the various morphing contexts. Next, move the logic from the `MorphRenderer` into a module-private `IdomorphCallbacks` class. The `IdomorphCallbacks` 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 `IdomorphCallbacks` class, including checks for `[data-turbo-permanent]`. To serve as a seam for integration, the class retains a reference to a callback responsible for: * providing options for the `Idiomorph` * determining whether or not a node should be skipped while morphing The `MorphingPageRenderer` skips `` elements so that it can override their rendering to use morphing. Similarly, the `MorphingFrameRenderer` provides the `morphStyle: "innerHTML"` option to morph its children. Changes to the renderers --- To integrate with the new module, first rename the `MorphRenderer` to `MorphingPageRenderer` 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 `MorphingPageRenderer.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 `MorphingPageRenderer` 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 `MorphingFrameRenderer` class to define the `MorphingFrameRenderer.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][#1177] by overriding the rendering mechanism in `turbo:before-render`: ```js addEventListener("turbo:before-render", (event) => { const someCriteriaForMorphing = ... if (someCriteriaForMorphing) { event.detail.render = Turbo.morphPage } }) addEventListener("turbo:before-frame-render", (event) => { const someCriteriaForMorphingAFrame = ... if (someCriteriaForMorphingAFrame) { event.detail.render = Turbo.morphFrames } }) ``` [#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 | 122 --------------------- src/core/drive/morphing_page_renderer.js | 48 ++++++++ src/core/drive/page_view.js | 8 +- src/core/frames/morphing_frame_renderer.js | 14 +++ src/core/morphing.js | 66 +++++++++++ src/core/streams/actions/morph.js | 65 ----------- src/core/streams/stream_actions.js | 8 +- 7 files changed, 138 insertions(+), 193 deletions(-) delete mode 100644 src/core/drive/morph_renderer.js create mode 100644 src/core/drive/morphing_page_renderer.js create mode 100644 src/core/frames/morphing_frame_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 11142463e..000000000 --- a/src/core/drive/morph_renderer.js +++ /dev/null @@ -1,122 +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" - } - - get shouldAutofocus() { - return false - } - - // 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/morphing_page_renderer.js b/src/core/drive/morphing_page_renderer.js new file mode 100644 index 000000000..2884a24fb --- /dev/null +++ b/src/core/drive/morphing_page_renderer.js @@ -0,0 +1,48 @@ +import { FrameElement } from "../../elements/frame_element" +import { MorphingFrameRenderer } from "../frames/morphing_frame_renderer" +import { PageRenderer } from "./page_renderer" +import { dispatch } from "../../util" +import { morphElements } from "../morphing" + +export class MorphingPageRenderer extends PageRenderer { + static renderElement(currentElement, newElement) { + morphElements(currentElement, newElement, { + callbacks: { + beforeNodeMorphed: element => !canRefreshFrame(element) + } + }) + + for (const frame of currentElement.querySelectorAll("turbo-frame")) { + if (canRefreshFrame(frame)) refreshFrame(frame) + } + + dispatch("turbo:morph", { detail: { currentElement, newElement } }) + } + + async preservingPermanentElements(callback) { + return await callback() + } + + get renderMethod() { + return "morph" + } + + get shouldAutofocus() { + return false + } +} + +function canRefreshFrame(frame) { + return frame instanceof FrameElement && + frame.src && + frame.refresh === "morph" && + !frame.closest("[data-turbo-permanent]") +} + +function refreshFrame(frame) { + frame.addEventListener("turbo:before-frame-render", ({ detail }) => { + detail.render = MorphingFrameRenderer.renderElement + }, { once: true }) + + frame.reload() +} diff --git a/src/core/drive/page_view.js b/src/core/drive/page_view.js index 6a40a94d9..1bcb04134 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 { MorphingPageRenderer } from "./morphing_page_renderer" import { PageRenderer } from "./page_renderer" import { PageSnapshot } from "./page_snapshot" import { SnapshotCache } from "./snapshot_cache" @@ -16,10 +16,10 @@ export class PageView extends View { } renderPage(snapshot, isPreview = false, willRender = true, visit) { - const shouldMorphPage = this.isPageRefresh(visit) && snapshot.shouldMorphPage - const rendererClass = shouldMorphPage ? MorphRenderer : PageRenderer + const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage + const rendererClass = shouldMorphPage ? MorphingPageRenderer : 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/morphing_frame_renderer.js b/src/core/frames/morphing_frame_renderer.js new file mode 100644 index 000000000..fe42065d5 --- /dev/null +++ b/src/core/frames/morphing_frame_renderer.js @@ -0,0 +1,14 @@ +import { FrameRenderer } from "./frame_renderer" +import { morphChildren } from "../morphing" +import { dispatch } from "../../util" + +export class MorphingFrameRenderer extends FrameRenderer { + static renderElement(currentElement, newElement) { + dispatch("turbo:before-frame-morph", { + target: currentElement, + detail: { currentElement, newElement } + }) + + morphChildren(currentElement, newElement) + } +} diff --git a/src/core/morphing.js b/src/core/morphing.js new file mode 100644 index 000000000..dfd21e839 --- /dev/null +++ b/src/core/morphing.js @@ -0,0 +1,66 @@ +import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js" +import { dispatch } from "../util" + +export function morphElements(currentElement, newElement, { callbacks, ...options } = {}) { + Idiomorph.morph(currentElement, newElement, { + ...options, + callbacks: new DefaultIdiomorphCallbacks(callbacks) + }) +} + +export function morphChildren(currentElement, newElement) { + morphElements(currentElement, newElement.children, { + morphStyle: "innerHTML" + }) +} + +class DefaultIdiomorphCallbacks { + #beforeNodeMorphed + + constructor({ beforeNodeMorphed } = {}) { + this.#beforeNodeMorphed = beforeNodeMorphed || (() => true) + } + + beforeNodeAdded = (node) => { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) + } + + beforeNodeMorphed = (currentElement, newElement) => { + if (currentElement instanceof Element) { + if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + target: currentElement, + detail: { currentElement, 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 = (currentElement, newElement) => { + if (currentElement instanceof Element) { + dispatch("turbo:morph-element", { + target: currentElement, + detail: { currentElement, newElement } + }) + } + } +} 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..48ad92da5 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, morphChildren } from "../morphing" export const StreamActions = { after() { @@ -40,6 +40,10 @@ export const StreamActions = { }, morph() { - morph(this) + const morph = this.hasAttribute("children-only") ? + morphChildren : + morphElements + + this.targetElements.forEach((targetElement) => morph(targetElement, this.templateContent)) } }