Skip to content

Commit

Permalink
Extract and re-use element morphing logic
Browse files Browse the repository at this point in the history
Follow-up to [hotwired#1185][]
Related to [hotwired#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 `Morph` class. The
`Morph` 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 `Morph` 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 `<turbo-frame refresh="morph">` 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 [hotwired#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, {
        ...
      })
    }
  }
})
```

[hotwired#1185]: hotwired#1185 (comment)
[hotwired#1192]: hotwired#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
[hotwired#1028]: hotwired#1028
[hotwired#1177]: hotwired#1177
  • Loading branch information
seanpdoyle committed Mar 30, 2024
1 parent 9fb05e3 commit 7a52d6d
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 188 deletions.
118 changes: 0 additions & 118 deletions src/core/drive/morph_renderer.js

This file was deleted.

44 changes: 44 additions & 0 deletions src/core/drive/page_morph_renderer.js
Original file line number Diff line number Diff line change
@@ -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()
}
6 changes: 3 additions & 3 deletions src/core/drive/page_view.js
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/core/frames/frame_morph_renderer.js
Original file line number Diff line number Diff line change
@@ -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"
}
})
}
}
74 changes: 74 additions & 0 deletions src/core/morphing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js"
import { dispatch } from "../util"

export function morphElements(currentElement, newElement, delegate = {}) {
const renderer = new Morph(delegate)

renderer.render(currentElement, newElement)
}

const defaultOptions = {
morphStyle: "outerHTML"
}

class Morph {
constructor(delegate) {
this.delegate = delegate || {}
}

render(currentElement, newElement) {
const options = this.delegate.options || {}

Idiomorph.morph(currentElement, newElement, {
...defaultOptions,
...options,
callbacks: {
beforeNodeAdded: this.#shouldAddElement,
beforeNodeMorphed: this.#shouldMorphElement,
beforeAttributeUpdated: this.#shouldUpdateAttribute,
beforeNodeRemoved: this.#shouldRemoveElement,
afterNodeMorphed: this.#didMorphElement
}
})
}

// Private

#shouldAddElement = (node) => {
return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id))
}

#shouldMorphElement = (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
}
}
}

#shouldUpdateAttribute = (attributeName, target, mutationType) => {
const event = dispatch("turbo:before-morph-attribute", { cancelable: true, target, detail: { attributeName, mutationType } })

return !event.defaultPrevented
}

#didMorphElement = (target, newNode) => {
if (newNode instanceof HTMLElement) {
dispatch("turbo:morph-element", { target, detail: { newElement: newNode } })
}
}

#shouldRemoveElement = (node) => {
return this.#shouldMorphElement(node)
}
}

function invoke(delegate, methodName, ...methodArguments) {
if (delegate && typeof delegate[methodName] === "function") {
return delegate[methodName](...methodArguments)
}
}
65 changes: 0 additions & 65 deletions src/core/streams/actions/morph.js

This file was deleted.

8 changes: 6 additions & 2 deletions src/core/streams/stream_actions.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { session } from "../"
import morph from "./actions/morph"
import { morphElements } from "../morphing"

export const StreamActions = {
after() {
Expand Down Expand Up @@ -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 } })
})
}
}

0 comments on commit 7a52d6d

Please sign in to comment.