Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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__
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,10 @@ morph(currentNode, newNode, {

```javascript
morph(currentNode, newNode, {
beforeAttributeUpdated: (element, name) => {
if (element.tagName === "DETAILS" && name === "open") return false
return true
},
beforeAttributeUpdated: (element, name) => {
if (element.tagName === "DETAILS" && name === "open") return false
return true
},
})
```

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"bench": "bun benchmark/run.ts",
"bench:thorough": "bun benchmark/run.ts --thorough",
"bench:decision": "bun benchmark/run.ts --repeats 3 --thorough",
"format": "prettier --write .",
"lint": "bun run oxlint --type-aware",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
126 changes: 73 additions & 53 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,26 @@ 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", "")
}
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,6 +234,7 @@ 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) {
Expand All @@ -246,9 +244,9 @@ function moveBefore(parent: ParentNode, node: ChildNode, insertionPoint: ChildNo
return
}
}

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

class Morph {
readonly #idArrayMap: IdArrayMap = new WeakMap()
Expand Down Expand Up @@ -280,11 +278,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
this.#morphOneToOne(from, newNodes.shift()!)

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 All @@ -302,7 +307,7 @@ class Morph {
if (from.isEqualNode(to)) return

if (from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE) {
if ((from as Element).localName === (to as Element).localName) {
if (canMorphElementInPlace(from as Element, to as Element)) {
this.#morphMatchingElements(from as Element, to as Element)
} else {
this.#morphNonMatchingElements(from as Element, to as Element)
Expand Down Expand Up @@ -339,10 +344,11 @@ 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) {
from.nodeValue = toValue
} else {
this.#replaceNode(from, to)
}
Expand Down Expand Up @@ -512,7 +518,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 @@ -645,14 +650,7 @@ class Morph {

const element = toChildNodes[unmatchedIndex] as Element

if (
element.id !== "" ||
isFormControl(element) ||
this.#idArrayMap.has(element) ||
element.hasAttribute("name") ||
element.hasAttribute("href") ||
element.hasAttribute("src")
) continue
if (!canSoftMatchByTagName(element, this.#idArrayMap.has(element))) continue

const localName = localNameMap[unmatchedIndex]

Expand All @@ -662,13 +660,7 @@ class Morph {

const candidate = fromChildNodes[candidateIndex] as Element

if (
isFormControl(candidate) ||
this.#idSetMap.has(candidate) ||
candidate.hasAttribute("name") ||
candidate.hasAttribute("href") ||
candidate.hasAttribute("src")
) continue
if (!canSoftMatchByTagName(candidate, this.#idSetMap.has(candidate))) continue

const candidateLocalName = candidateLocalNameMap[candidateIndex]

Expand All @@ -685,7 +677,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 +778,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 +820,6 @@ class Morph {
forEachDescendantElementWithId(node, (element) => {
const id = element.id

if (id === "") return

let currentElement: Element | null = element

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

if (id === "") return

let currentElement: Element | null = element

while (currentElement) {
Expand All @@ -867,8 +860,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 @@ -911,6 +903,36 @@ function isInputElement(element: Element): element is HTMLInputElement {
return element.localName === "input"
}

function canMorphElementInPlace(from: Element, to: Element): boolean {
if (from.localName !== to.localName) return false
if (isFormControl(from) && isFormControl(to)) {
const fromId = from.id
const toId = to.id

if ((fromId !== "" || toId !== "") && fromId !== toId) {
return false
}
}

if (isInputElement(from) && isInputElement(to)) {
return from.type === to.type
}

return true
}

function canSoftMatchByTagName(element: Element, hasDescendantIdMarker: boolean): boolean {
return !hasStableSoftMatchIdentity(element, hasDescendantIdMarker)
}

function hasStableSoftMatchIdentity(element: Element, hasDescendantIdMarker: boolean): boolean {
return element.id !== "" || isFormControl(element) || hasDescendantIdMarker || hasMatchKeyAttribute(element)
}

function hasMatchKeyAttribute(element: Element): boolean {
return element.hasAttribute("name") || element.hasAttribute("href") || element.hasAttribute("src")
}

function isFormControl(element: Element): boolean {
const localName = element.localName
return (
Expand Down Expand Up @@ -962,8 +984,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
4 changes: 4 additions & 0 deletions src/raw-html.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module "*.html?raw" {
const content: string
export default content
}
8 changes: 6 additions & 2 deletions test/ai-gen-coverage/attribute-removal.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import { dom } from "../new/utils"

describe("attribute removal edge cases", () => {
test("removing selected attribute from option with multiple options selected", () => {
const a = dom(`<select multiple><option value="a" selected>A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
const b = dom(`<select multiple><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
const a = dom(
`<select multiple><option value="a" selected>A</option><option value="b" selected>B</option></select>`,
) as HTMLSelectElement
const b = dom(
`<select multiple><option value="a">A</option><option value="b" selected>B</option></select>`,
) as HTMLSelectElement

morph(a, b, { preserveChanges: true })

Expand Down
34 changes: 17 additions & 17 deletions test/morphlex-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,23 @@ describe("Morphlex - Coverage Tests", () => {
})

describe("Property updates", () => {
it("should update input disabled property", () => {
const parent = document.createElement("div")
const input = document.createElement("input")
input.disabled = false
input.name = "test"
parent.appendChild(input)

const reference = document.createElement("div")
const refInput = document.createElement("input")
refInput.disabled = true
refInput.name = "test"
reference.appendChild(refInput)

morph(parent, reference)

expect(input.disabled).toBe(true)
})
it("should update input disabled property", () => {
const parent = document.createElement("div")
const input = document.createElement("input")
input.disabled = false
input.name = "test"
parent.appendChild(input)

const reference = document.createElement("div")
const refInput = document.createElement("input")
refInput.disabled = true
refInput.name = "test"
reference.appendChild(refInput)

morph(parent, reference)

expect(input.disabled).toBe(true)
})

it("should not update file input value", () => {
const parent = document.createElement("div")
Expand Down
19 changes: 19 additions & 0 deletions test/morphlex-uncovered.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,25 @@ describe("Morphlex - Remaining Uncovered Lines", () => {
parent.remove()
})

it("should keep trailing nodes when the first one-to-many morph replaces the source node", () => {
const parent = document.createElement("div")
const single = document.createElement("span")
single.id = "single"
single.textContent = "Single"
parent.appendChild(single)
document.body.appendChild(parent)

morph(single, "<div id='first'>First</div><em id='second'>Second</em>")

expect(parent.children.length).toBe(2)
expect(parent.children[0]?.tagName).toBe("DIV")
expect(parent.children[0]?.id).toBe("first")
expect(parent.children[1]?.tagName).toBe("EM")
expect(parent.children[1]?.id).toBe("second")

parent.remove()
})

it("should call callbacks when morphing one to many", () => {
const parent = document.createElement("div")
const single = document.createElement("span")
Expand Down
Loading