Skip to content

Commit

Permalink
Re-structure turbo-stream[action=morph] support
Browse files Browse the repository at this point in the history
This commit re-structures the new support for
`turbo-stream[action="morph"]` elements introduced in [hotwired#1185][].

As an alternative to introduce a new Stream Action, this commit changes
existing actions to be more flexible.

For example, the `<turbo-stream method="morph">` element behaves like a
specialized version of a `<turbo-stream method="replace">`, since it
operates on the target element's `outerHTML` property.

Similarly, the `<turbo-stream method="morph" children-only>` element
behaves like a specialized version of a `<turbo-stream
method="update">`, since it operates on the target element's `innerHTML`
property.

This commit removes the `[action="morph"]` support entirely, and
re-implements it in terms of the `[action="replace"]` and
`[action="update"]` support.

By consolidating concepts, the "scope" of the modifications is more
clearly communicated to callers that are familiar with the underlying
DOM interfaces (`Element.replaceWith` and `Element.innerHTML`) that are
invoked by the conventionally established Replace and Update actions.

This proposal also aims to reinforce the "method" terminology introduced
by the Page Refresh `<meta name="refresh-method" content="morph">`
element.

[hotwired#1185]: hotwired#1185
  • Loading branch information
seanpdoyle committed Apr 4, 2024
1 parent 9fb05e3 commit c9dbc34
Show file tree
Hide file tree
Showing 7 changed files with 93 additions and 93 deletions.
31 changes: 18 additions & 13 deletions src/core/streams/actions/morph.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
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
}
})
export function morphElement(target, element) {
idiomorph(target, element, { morphStyle: "outerHTML" })
}

export function morphChildren(target, childElements) {
idiomorph(target, childElements, { morphStyle: "innerHTML" })
}

function idiomorph(target, element, options = {}) {
Idiomorph.morph(target, element, {
...options,
callbacks: {
beforeNodeAdded,
beforeNodeMorphed,
beforeAttributeUpdated,
beforeNodeRemoved,
afterNodeMorphed
}
})
}

Expand Down
26 changes: 18 additions & 8 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 { morphChildren, morphElement } from "./actions/morph"

export const StreamActions = {
after() {
Expand All @@ -25,21 +25,31 @@ export const StreamActions = {
},

replace() {
this.targetElements.forEach((e) => e.replaceWith(this.templateContent))
const method = this.getAttribute("method")

this.targetElements.forEach((targetElement) => {
if (method === "morph") {
morphElement(targetElement, this.templateContent)
} else {
targetElement.replaceWith(this.templateContent)
}
})
},

update() {
const method = this.getAttribute("method")

this.targetElements.forEach((targetElement) => {
targetElement.innerHTML = ""
targetElement.append(this.templateContent)
if (method === "morph") {
morphChildren(targetElement, this.templateContent)
} else {
targetElement.innerHTML = ""
targetElement.append(this.templateContent)
}
})
},

refresh() {
session.refresh(this.baseURI, this.requestId)
},

morph() {
morph(this)
}
}
16 changes: 0 additions & 16 deletions src/tests/fixtures/morph_stream_action.html

This file was deleted.

4 changes: 4 additions & 0 deletions src/tests/fixtures/stream.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,9 @@
<div id="container">
<input id="container-element">
</div>

<div id="message_1">
<div>Morph me</div>
</div>
</body>
</html>
48 changes: 0 additions & 48 deletions src/tests/functional/morph_stream_action_tests.js

This file was deleted.

46 changes: 45 additions & 1 deletion src/tests/functional/stream_tests.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { test } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { assert } from "chai"
import {
hasSelector,
nextBeat,
nextEventNamed,
nextEventOnTarget,
noNextEventOnTarget,
readEventLogs,
waitUntilNoSelector,
waitUntilText
Expand Down Expand Up @@ -182,6 +184,48 @@ test("receiving a remove stream message preserves focus blurs the activeElement"
assert.notOk(await hasSelector(page, ":focus"))
})

test("dispatches a turbo:before-morph-element & turbo:morph-element for each morph stream action", async ({ page }) => {
await page.evaluate(() => {
window.Turbo.renderStreamMessage(`
<turbo-stream action="replace" method="morph" target="message_1">
<template>
<div id="message_1">
<h1>Morphed</h1>
</div>
</template>
</turbo-stream>
`)
})

await nextEventOnTarget(page, "message_1", "turbo:before-morph-element")
await nextEventOnTarget(page, "message_1", "turbo:morph-element")
await expect(page.locator("#message_1")).toHaveText("Morphed")
})

test("preventing a turbo:before-morph-element prevents the morph", async ({ page }) => {
await page.evaluate(() => {
addEventListener("turbo:before-morph-element", (event) => {
event.preventDefault()
})
})

await page.evaluate(() => {
window.Turbo.renderStreamMessage(`
<turbo-stream action="replace" method="morph" target="message_1">
<template>
<div id="message_1">
<h1>Morphed</h1>
</div>
</template>
</turbo-stream>
`)
})

await nextEventOnTarget(page, "message_1", "turbo:before-morph-element")
await noNextEventOnTarget(page, "message_1", "turbo:morph-element")
await expect(page.locator("#message_1")).toHaveText("Morph me")
})

async function getReadyState(page, id) {
return page.evaluate((id) => {
const element = document.getElementById(id)
Expand Down
15 changes: 8 additions & 7 deletions src/tests/unit/stream_element_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { assert } from "@open-wc/testing"
import { sleep } from "../helpers/page"
import * as Turbo from "../../index"

function createStreamElement(action, target, templateElement) {
function createStreamElement(action, target, templateElement, attributes = {}) {
const element = new StreamElement()
if (action) element.setAttribute("action", action)
if (target) element.setAttribute("target", target)
if (templateElement) element.appendChild(templateElement)
Object.entries(attributes).forEach((attribute) => element.setAttribute(...attribute))
return element
}

Expand Down Expand Up @@ -197,9 +198,9 @@ test("test action=refresh discarded when matching request id", async () => {
assert.ok(document.body.hasAttribute("data-modified"))
})

test("action=morph", async () => {
test("action=replace method=morph", async () => {
const templateElement = createTemplateElement(`<h1 id="hello">Hello Turbo Morphed</h1>`)
const element = createStreamElement("morph", "hello", templateElement)
const element = createStreamElement("replace", "hello", templateElement, { method: "morph" })

assert.equal(subject.find("div#hello")?.textContent, "Hello Turbo")

Expand All @@ -210,9 +211,9 @@ test("action=morph", async () => {
assert.equal(subject.find("h1#hello")?.textContent, "Hello Turbo Morphed")
})

test("action=morph with text content change", async () => {
test("action=replace method=morph with text content change", async () => {
const templateElement = createTemplateElement(`<div id="hello">Hello Turbo Morphed</div>`)
const element = createStreamElement("morph", "hello", templateElement)
const element = createStreamElement("replace", "hello", templateElement, { method: "morph" })

assert.equal(subject.find("div#hello")?.textContent, "Hello Turbo")

Expand All @@ -223,9 +224,9 @@ test("action=morph with text content change", async () => {
assert.equal(subject.find("div#hello")?.textContent, "Hello Turbo Morphed")
})

test("action=morph children-only", async () => {
test("action=update method=morph", async () => {
const templateElement = createTemplateElement(`<h1 id="hello-child-element">Hello Turbo Morphed</h1>`)
const element = createStreamElement("morph", "hello", templateElement)
const element = createStreamElement("update", "hello", templateElement, { method: "morph" })
const target = subject.find("div#hello")
assert.equal(target?.textContent, "Hello Turbo")
element.setAttribute("children-only", true)
Expand Down

0 comments on commit c9dbc34

Please sign in to comment.