diff --git a/src/core/streams/actions/morph.js b/src/core/streams/actions/morph.js
new file mode 100644
index 000000000..d177bfd01
--- /dev/null
+++ b/src/core/streams/actions/morph.js
@@ -0,0 +1,63 @@
+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 && !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 064e94ca4..486dc8566 100644
--- a/src/core/streams/stream_actions.js
+++ b/src/core/streams/stream_actions.js
@@ -1,4 +1,5 @@
import { session } from "../"
+import morph from "./actions/morph"
export const StreamActions = {
after() {
@@ -36,5 +37,9 @@ export const StreamActions = {
refresh() {
session.refresh(this.baseURI, this.requestId)
+ },
+
+ morph() {
+ morph(this)
}
}
diff --git a/src/tests/fixtures/morph_stream_action.html b/src/tests/fixtures/morph_stream_action.html
new file mode 100644
index 000000000..df91274f5
--- /dev/null
+++ b/src/tests/fixtures/morph_stream_action.html
@@ -0,0 +1,16 @@
+
+
+
+
+ Morph Stream Action
+
+
+
+
+
+
+
+
+
diff --git a/src/tests/functional/morph_stream_action_tests.js b/src/tests/functional/morph_stream_action_tests.js
new file mode 100644
index 000000000..b4f04c9d7
--- /dev/null
+++ b/src/tests/functional/morph_stream_action_tests.js
@@ -0,0 +1,48 @@
+import { test, expect } from "@playwright/test"
+import { nextEventOnTarget, noNextEventOnTarget } from "../helpers/page"
+
+test("dispatches a turbo:before-morph-element & turbo:morph-element for each morph stream action", async ({ page }) => {
+ await page.goto("/src/tests/fixtures/morph_stream_action.html")
+
+ await page.evaluate(() => {
+ window.Turbo.renderStreamMessage(`
+
+
+
+
Morphed
+
+
+
+ `)
+ })
+
+ 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.goto("/src/tests/fixtures/morph_stream_action.html")
+
+ await page.evaluate(() => {
+ addEventListener("turbo:before-morph-element", (event) => {
+ event.preventDefault()
+ })
+ })
+
+ await page.evaluate(() => {
+ window.Turbo.renderStreamMessage(`
+
+
+
+
Morphed
+
+
+
+ `)
+ })
+
+ 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")
+})
diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js
index 21a9ca8aa..1e3b99f92 100644
--- a/src/tests/unit/stream_element_tests.js
+++ b/src/tests/unit/stream_element_tests.js
@@ -196,3 +196,32 @@ test("test action=refresh discarded when matching request id", async () => {
assert.ok(document.body.hasAttribute("data-modified"))
})
+
+test("action=morph", async () => {
+ const templateElement = createTemplateElement(`Hello Turbo Morphed
`)
+ const element = createStreamElement("morph", "hello", templateElement)
+
+ assert.equal(subject.find("div#hello")?.textContent, "Hello Turbo")
+
+ subject.append(element)
+ await nextAnimationFrame()
+
+ assert.notOk(subject.find("div#hello"))
+ assert.equal(subject.find("h1#hello")?.textContent, "Hello Turbo Morphed")
+})
+
+test("action=morph children-only", async () => {
+ const templateElement = createTemplateElement(`Hello Turbo Morphed
`)
+ const element = createStreamElement("morph", "hello", templateElement)
+ const target = subject.find("div#hello")
+ assert.equal(target?.textContent, "Hello Turbo")
+ element.setAttribute("children-only", true)
+
+ subject.append(element)
+
+ await nextAnimationFrame()
+
+ assert.ok(subject.find("div#hello"))
+ assert.ok(subject.find("div#hello > h1#hello-child-element"))
+ assert.equal(subject.find("div#hello > h1#hello-child-element").textContent, "Hello Turbo Morphed")
+})