Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@ node_modules
.DS_Store
dist
coverage
.coverage-tmp*
.coverage-debug*
.coverage*
reference
test/**/__screenshots__
78 changes: 43 additions & 35 deletions src/morphlex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export function morphDocument(from: Document, to: Document | string, options?: O
export function morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode> | string, options: Options = {}): void {
if (typeof to === "string") to = parseFragment(to).childNodes

if (isParentNode(from)) flagDirtyInputs(from)
if (isParentNode(from)) flagDirtyInputs(from as Element)
new Morph(options).morph(from, to)
}

Expand Down Expand Up @@ -180,29 +180,27 @@ export function morphInner(from: ChildNode, to: ChildNode | string, options: Opt
to.nodeType === ELEMENT_NODE_TYPE &&
(from as Element).localName === (to as Element).localName
) {
if (isParentNode(from)) flagDirtyInputs(from)
flagDirtyInputs(from as Element)
new Morph(options).visitChildNodes(from as Element, to as Element)
} else {
throw new Error("[Morphlex] You can only do an inner morph with matching elements.")
}
}

function flagDirtyInputs(node: ParentNode): void {
if (node.nodeType === ELEMENT_NODE_TYPE) {
const element = node as Element
if (isInputElement(element)) {
if (element.value !== element.defaultValue || element.checked !== element.defaultChecked) {
element.setAttribute("morphlex-dirty", "")
}
} else if (isOptionElement(element)) {
if (element.selected !== element.defaultSelected) {
element.setAttribute("morphlex-dirty", "")
}
} else if (element.localName === "textarea") {
const textarea = element as HTMLTextAreaElement
if (textarea.value !== textarea.defaultValue) {
textarea.setAttribute("morphlex-dirty", "")
}

Comment thread
joeldrapper marked this conversation as resolved.
Outdated
function flagDirtyInputs(node: Element): void {
if (isInputElement(node)) {
if (node.value !== node.defaultValue || node.checked !== node.defaultChecked) {
node.setAttribute("morphlex-dirty", "")
}
} else if (isOptionElement(node)) {
if (node.selected !== node.defaultSelected) {
node.setAttribute("morphlex-dirty", "")
}
} else if (node.localName === "textarea") {
const textarea = node as HTMLTextAreaElement
if (textarea.value !== textarea.defaultValue) {
textarea.setAttribute("morphlex-dirty", "")
}
}

Expand Down Expand Up @@ -237,18 +235,21 @@ function parseDocument(string: string): Document {
return parser.parseFromString(string.trim(), "text/html")
}

/* v8 ignore start -- reorder fast paths are environment-sensitive */
function moveBefore(parent: ParentNode, node: ChildNode, insertionPoint: ChildNode | null): void {
if (node === insertionPoint) return
if (node.parentNode === parent) {
if (node.nextSibling === insertionPoint) return
/* v8 ignore start -- engine-dependent fast path */
if (SUPPORTS_MOVE_BEFORE) {
;(parent as NodeWithMoveBefore).moveBefore(node, insertionPoint)
return
}
/* v8 ignore stop */
Comment thread
joeldrapper marked this conversation as resolved.
Outdated
}

parent.insertBefore(node, insertionPoint)
}
/* v8 ignore stop */

class Morph {
readonly #idArrayMap: IdArrayMap = new WeakMap()
Expand Down Expand Up @@ -280,11 +281,18 @@ class Morph {
this.#removeNode(from)
} else if (length === 1) {
this.#morphOneToOne(from, to[0]!)
} else if (length > 1) {
} else {
const newNodes = [...to]
this.#morphOneToOne(from, newNodes.shift()!)
const insertionPoint = from.nextSibling
const parent = from.parentNode || document
const parent = from.parentNode

if (!parent) {
for (let i = 0; i < newNodes.length; i++) {
Comment on lines +284 to +288

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve parent before morphing first node in one-to-many

In the length > 1 path, #morphOneToOne(from, first) runs before parent is captured, so when the first target requires replacement (for example, morphing an attached <span> to "<div>...</div><em>...</em>"), from.parentNode is already null here and this branch returns early as if the source were detached. That drops all trailing nodes from the target list, so attached one-to-many morphs can produce incomplete DOM output.

Useful? React with 👍 / 👎.

this.#options.beforeNodeAdded?.(document, newNodes[i]!, from)
}
return
}

for (let i = 0; i < newNodes.length; i++) {
const newNode = newNodes[i]!
Expand Down Expand Up @@ -339,9 +347,12 @@ class Morph {
#morphOtherNode(from: ChildNode, to: ChildNode): void {
if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return

if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) {
if (from.nodeValue !== to.nodeValue) {
from.nodeValue = to.nodeValue
const fromValue = from.nodeValue
const toValue = to.nodeValue

if (from.nodeType === to.nodeType && fromValue !== null && toValue !== null) {
if (fromValue !== toValue) {
from.nodeValue = toValue
}
} else {
this.#replaceNode(from, to)
Expand Down Expand Up @@ -512,7 +523,6 @@ class Morph {
// Match elements by isEqualNode
for (let i = 0; i < unmatchedElementIndices.length; i++) {
const unmatchedIndex = unmatchedElementIndices[i]!
if (!unmatchedElementActive[unmatchedIndex]) continue

const localName = localNameMap[unmatchedIndex]
const element = toChildNodes[unmatchedIndex] as Element
Expand Down Expand Up @@ -685,7 +695,6 @@ class Morph {
// Match nodes by isEqualNode (skip whitespace-only text nodes)
for (let i = 0; i < unmatchedNodeIndices.length; i++) {
const unmatchedIndex = unmatchedNodeIndices[i]!
if (!unmatchedNodeActive[unmatchedIndex]) continue

const node = toChildNodes[unmatchedIndex]!
for (let c = 0; c < candidateNodeIndices.length; c++) {
Expand Down Expand Up @@ -787,7 +796,13 @@ class Morph {
}

#replaceNode(node: ChildNode, newNode: ChildNode): void {
const parent = node.parentNode || document
const parent = node.parentNode

if (!parent) {
this.#options.beforeNodeAdded?.(document, newNode, node)
return
}

const insertionPoint = node
// Check if both removal and addition are allowed before starting the replacement
if (
Expand Down Expand Up @@ -823,8 +838,6 @@ class Morph {
forEachDescendantElementWithId(node, (element) => {
const id = element.id

if (id === "") return

let currentElement: Element | null = element

while (currentElement) {
Expand All @@ -847,8 +860,6 @@ class Morph {
forEachDescendantElementWithId(node, (element) => {
const id = element.id

if (id === "") return

let currentElement: Element | null = element

while (currentElement) {
Expand All @@ -867,8 +878,7 @@ class Morph {

function forEachDescendantElementWithId(node: ParentNode, callback: (element: Element) => void): void {
const root = node as Node
const ownerDocument = root.nodeType === 9 ? (root as Document) : root.ownerDocument
if (!ownerDocument) return
const ownerDocument = root.ownerDocument!

const walker = ownerDocument.createTreeWalker(root, TREE_WALKER_SHOW_ELEMENT)
let current = walker.nextNode()
Expand Down Expand Up @@ -962,8 +972,6 @@ function longestIncreasingSubsequence(sequence: Array<number | undefined>): Arra
if (left === lisLength) lisLength++
}

if (lisLength === 0) return []

const result = new Array<number>(lisLength)
let curr = indices[lisLength - 1]!

Expand Down
80 changes: 80 additions & 0 deletions test/new/before-node-added.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { expect, test } from "vitest"
import { morph } from "../../src/morphlex"

test("detached replacements only expose beforeNodeAdded as a signal", () => {
const from = document.createElement("div")
const to = document.createElement("span")
const events: string[] = []

morph(from, to, {
beforeNodeAdded: (parent, node, insertionPoint) => {
expect(parent).toBe(document)
expect(node).toBe(to)
expect(insertionPoint).toBe(from)
events.push("before:add")
return true
},
afterNodeAdded: () => {
events.push("after:add")
},
beforeNodeRemoved: () => {
events.push("before:remove")
return true
},
afterNodeRemoved: () => {
events.push("after:remove")
},
})

expect(events).toEqual(["before:add"])
expect(from.parentNode).toBe(null)
expect(to.parentNode).toBe(null)
})

test("detached one-to-many morphs only expose beforeNodeAdded signals for extra nodes", () => {
const from = document.createTextNode("before")
const events: string[] = []

morph(from, "after<!--second--><em>third</em>", {
beforeNodeAdded: (parent, node, insertionPoint) => {
expect(parent).toBe(document)
expect(insertionPoint).toBe(from)
events.push(`before:${node.nodeName}`)
return true
},
afterNodeAdded: (node) => {
events.push(`after:${node.nodeName}`)
},
})

expect(from.nodeValue).toBe("after")
expect(events).toEqual(["before:#comment", "before:EM"])
})

test("attached replacements do not remove when beforeNodeAdded rejects the new node", () => {
const parent = document.createElement("div")
const from = document.createElement("div")
const to = document.createElement("span")
const events: string[] = []
parent.append(from)

morph(from, to, {
beforeNodeRemoved: () => {
events.push("before:remove")
return true
},
beforeNodeAdded: () => {
events.push("before:add")
return false
},
afterNodeAdded: () => {
events.push("after:add")
},
afterNodeRemoved: () => {
events.push("after:remove")
},
})

expect(parent.firstChild).toBe(from)
expect(events).toEqual(["before:remove", "before:add"])
})
83 changes: 83 additions & 0 deletions test/new/callback-guards.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { expect, test } from "vitest"
import { morph, morphInner } from "../../src/morphlex"

test("beforeNodeVisited can skip non-element updates without firing afterNodeVisited", () => {

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name says "without firing afterNodeVisited" but the test expects events to equal ["before", "after"], which includes "after" — the afterNodeVisited callback for the outer div element. The name is misleading: while it's true that afterNodeVisited is not fired for the text node itself (because beforeNodeVisited returned false for it), the afterNodeVisited callback does fire for the surrounding div. A clearer name would be something like "beforeNodeVisited returning false for a text node skips afterNodeVisited for that node but not the parent element".

Copilot uses AI. Check for mistakes.
const from = document.createElement("div")
from.textContent = "before"

const to = document.createElement("div")
to.textContent = "after"

const events: string[] = []

morph(from, to, {
beforeNodeVisited: (fromNode) => {
if (fromNode.nodeType !== Node.TEXT_NODE) return true
events.push("before")
return false
},
afterNodeVisited: () => {
events.push("after")
},
})

expect(from.textContent).toBe("before")
expect(events).toEqual(["before", "after"])
})

test("non-element replacement still reports afterNodeVisited when replacement occurs", () => {
const parent = document.createElement("div")
const from = document.createTextNode("before")
const to = document.createElement("span")
let visited = false
parent.append(from)

morph(from, to, {
afterNodeVisited: () => {
visited = true
},
})

expect(parent.firstChild).toBe(to)
expect(visited).toBe(true)
})

test("beforeChildrenVisited can skip child morphing", () => {
const from = document.createElement("div")
from.innerHTML = "<span>before</span>"

const to = document.createElement("div")
to.innerHTML = "<span>after</span>"

morphInner(from, to, {
beforeChildrenVisited: () => false,
})

expect(from.innerHTML).toBe("<span>before</span>")
})

test("beforeNodeVisited can skip replacing non-matching elements", () => {
const parent = document.createElement("div")
const from = document.createElement("span")
from.textContent = "before"
parent.append(from)

const to = document.createElement("em")
to.textContent = "after"

morph(from, to, {
beforeNodeVisited: () => false,
})

expect(parent.firstChild).toBe(from)
expect(parent.firstChild?.nodeName).toBe("SPAN")
})

test("morph is a fast no-op when both nodes are the same object", () => {
const node = document.createElement("div")
node.textContent = "same"

morph(node, node)

expect(node.textContent).toBe("same")
})
Loading