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 [#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 `<turbo-frame refresh="morph">` 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]: #1185 (comment)
[#1192]: #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]: #1028
[#1177]: #1177
  • Loading branch information
seanpdoyle committed Apr 15, 2024
1 parent 9fb05e3 commit 5e99865
Show file tree
Hide file tree
Showing 7 changed files with 133 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/morphing_page_renderer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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"
}
}

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()
}
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 { MorphingPageRenderer } from "./morphing_page_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 ? 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
Expand Down
14 changes: 14 additions & 0 deletions src/core/frames/morphing_frame_renderer.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
66 changes: 66 additions & 0 deletions src/core/morphing.js
Original file line number Diff line number Diff line change
@@ -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 IdiomorphCallbacks(callbacks)
})
}

export function morphChildren(currentElement, newElement) {
morphElements(currentElement, newElement.children, {
morphStyle: "innerHTML"
})
}

class IdiomorphCallbacks {
#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 }
})
}
}
}
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, morphChildren } from "../morphing"

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

0 comments on commit 5e99865

Please sign in to comment.