-
-
Notifications
You must be signed in to change notification settings - Fork 7
Add focused Morphlex regression coverage #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,5 +2,8 @@ node_modules | |
| .DS_Store | ||
| dist | ||
| coverage | ||
| .coverage-tmp* | ||
| .coverage-debug* | ||
| .coverage* | ||
| reference | ||
| test/**/__screenshots__ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
|
|
||
|
|
@@ -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", "") | ||
| } | ||
|
|
||
| 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", "") | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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 */ | ||
|
joeldrapper marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| parent.insertBefore(node, insertionPoint) | ||
| } | ||
| /* v8 ignore stop */ | ||
|
|
||
| class Morph { | ||
| readonly #idArrayMap: IdArrayMap = new WeakMap() | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the Useful? React with 👍 / 👎. |
||
| this.#options.beforeNodeAdded?.(document, newNodes[i]!, from) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| for (let i = 0; i < newNodes.length; i++) { | ||
| const newNode = newNodes[i]! | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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++) { | ||
|
|
@@ -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 ( | ||
|
|
@@ -823,8 +838,6 @@ class Morph { | |
| forEachDescendantElementWithId(node, (element) => { | ||
| const id = element.id | ||
|
|
||
| if (id === "") return | ||
|
|
||
| let currentElement: Element | null = element | ||
|
|
||
| while (currentElement) { | ||
|
|
@@ -847,8 +860,6 @@ class Morph { | |
| forEachDescendantElementWithId(node, (element) => { | ||
| const id = element.id | ||
|
|
||
| if (id === "") return | ||
|
|
||
| let currentElement: Element | null = element | ||
|
|
||
| while (currentElement) { | ||
|
|
@@ -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() | ||
|
|
@@ -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]! | ||
|
|
||
|
|
||
| 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"]) | ||
| }) |
| 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", () => { | ||
|
||
| 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") | ||
| }) | ||
Uh oh!
There was an error while loading. Please reload this page.