diff --git a/src/morphlex.ts b/src/morphlex.ts index 48c81bf..fd1d422 100644 --- a/src/morphlex.ts +++ b/src/morphlex.ts @@ -638,13 +638,22 @@ class Morph { } } - // Match by tagName + // Match by tagName (only for elements without distinguishing attributes) for (let i = 0; i < unmatchedElementIndices.length; i++) { const unmatchedIndex = unmatchedElementIndices[i]! if (!unmatchedElementActive[unmatchedIndex]) continue 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 + const localName = localNameMap[unmatchedIndex] for (let c = 0; c < candidateElementIndices.length; c++) { @@ -652,13 +661,18 @@ class Morph { if (!candidateElementActive[candidateIndex]) continue const candidate = fromChildNodes[candidateIndex] as Element + + if ( + isFormControl(candidate) || + this.#idSetMap.has(candidate) || + candidate.hasAttribute("name") || + candidate.hasAttribute("href") || + candidate.hasAttribute("src") + ) continue + const candidateLocalName = candidateLocalNameMap[candidateIndex] if (localName === candidateLocalName) { - if (localName === "input" && (candidate as HTMLInputElement).type !== (element as HTMLInputElement).type) { - // Treat inputs with different type as though they are different tags. - continue - } matches[unmatchedIndex] = candidateIndex op[unmatchedIndex] = Operation.SameElement candidateElementActive[candidateIndex] = 0 @@ -897,6 +911,16 @@ function isInputElement(element: Element): element is HTMLInputElement { return element.localName === "input" } +function isFormControl(element: Element): boolean { + const localName = element.localName + return ( + localName === "input" || + localName === "textarea" || + localName === "select" || + (localName.includes("-") && (element.constructor as unknown as Record)["formAssociated"] === true) + ) +} + function isOptionElement(element: Element): element is HTMLOptionElement { return element.localName === "option" } diff --git a/test/ai-gen-coverage/input-localname-matching.browser.test.ts b/test/ai-gen-coverage/input-localname-matching.browser.test.ts index f1fe6bb..1874331 100644 --- a/test/ai-gen-coverage/input-localname-matching.browser.test.ts +++ b/test/ai-gen-coverage/input-localname-matching.browser.test.ts @@ -2,9 +2,9 @@ import { test, expect } from "vitest" import { morph } from "../../src/morphlex" import { dom } from "../new/utils" -test("morphing inputs by localName with same types matches correctly", () => { - // This test ensures lines 566-568 are covered (the non-continue path) - // Inputs without id or name attributes fall through to localName matching +test("morphing inputs without distinguishing attributes are replaced, not reused", () => { + // Inputs are excluded from the tag-name matching pass, so bare inputs + // without id/name/href/src are treated as new elements const a = dom(`
`) as HTMLElement const b = dom(`
`) as HTMLElement @@ -13,9 +13,9 @@ test("morphing inputs by localName with same types matches correctly", () => { morph(a, b) - // Same type inputs should be reused via localName matching - expect(a.children[0]).toBe(first) - expect(a.children[1]).toBe(second) + // Inputs should be replaced, not reused via localName matching + expect(a.children[0]).not.toBe(first) + expect(a.children[1]).not.toBe(second) expect((a.children[0] as HTMLInputElement).placeholder).toBe("a") expect((a.children[1] as HTMLInputElement).placeholder).toBe("b") }) diff --git a/test/ai-gen-coverage/input-type-continue.browser.test.ts b/test/ai-gen-coverage/input-type-continue.browser.test.ts index 54b79bf..ba69930 100644 --- a/test/ai-gen-coverage/input-type-continue.browser.test.ts +++ b/test/ai-gen-coverage/input-type-continue.browser.test.ts @@ -76,8 +76,8 @@ test("input type mismatch with no matching type - all trigger continue", () => { expect((a.children[0] as HTMLInputElement).type).toBe("email") }) -test("input with matching type does not trigger continue", () => { - // When types match, the continue branch is NOT taken +test("input without distinguishing attributes is replaced, not reused", () => { + // Inputs are excluded from the tag-name matching pass entirely const a = dom( `
@@ -95,9 +95,9 @@ test("input with matching type does not trigger continue", () => { morph(a, b) - // First text input matches without triggering continue + // Inputs are not matched by tag name, so element is replaced expect(a.children.length).toBe(1) - expect(a.children[0]).toBe(firstInput) + expect(a.children[0]).not.toBe(firstInput) expect((a.children[0] as HTMLInputElement).getAttribute("data-value")).toBe("new") }) diff --git a/test/ai-gen-coverage/input-type-mismatch.browser.test.ts b/test/ai-gen-coverage/input-type-mismatch.browser.test.ts index b8f57ce..51fbd24 100644 --- a/test/ai-gen-coverage/input-type-mismatch.browser.test.ts +++ b/test/ai-gen-coverage/input-type-mismatch.browser.test.ts @@ -54,7 +54,7 @@ describe("input type mismatch", () => { expect(newInput.name).toBe("test") }) - test("morphing inputs with same type uses localName matching", () => { + test("morphing inputs without distinguishing attributes are replaced", () => { const a = dom(`
`) as HTMLElement const b = dom(`
`) as HTMLElement @@ -63,9 +63,9 @@ describe("input type mismatch", () => { morph(a, b) - // Lines 566-568: inputs match by localName and type, so they're reused - expect(a.children[0]).toBe(firstInput) - expect(a.children[1]).toBe(secondInput) + // Inputs are excluded from the tag-name matching pass, so they're replaced + expect(a.children[0]).not.toBe(firstInput) + expect(a.children[1]).not.toBe(secondInput) expect((a.children[0] as HTMLInputElement).value).toBe("x") expect((a.children[1] as HTMLInputElement).value).toBe("y") }) @@ -86,7 +86,7 @@ describe("input type mismatch", () => { expect(inputs[2].id).toBe("c") }) - test("morphing inputs without IDs triggers localName matching with type check", () => { + test("morphing inputs without IDs are replaced, not matched by localName", () => { const a = dom(`
`) as HTMLElement const b = dom(`
`) as HTMLElement @@ -95,9 +95,9 @@ describe("input type mismatch", () => { morph(a, b) - // Lines 566-568: same-type inputs are matched and reused via localName - expect(a.children[0]).toBe(firstInput) - expect(a.children[1]).toBe(secondInput) + // Inputs are excluded from the tag-name matching pass, so they're replaced + expect(a.children[0]).not.toBe(firstInput) + expect(a.children[1]).not.toBe(secondInput) expect((a.children[0] as HTMLInputElement).className).toBe("x") expect((a.children[1] as HTMLInputElement).className).toBe("y") }) diff --git a/test/ai-gen-coverage/localname-matching.browser.test.ts b/test/ai-gen-coverage/localname-matching.browser.test.ts index 877377f..7adc819 100644 --- a/test/ai-gen-coverage/localname-matching.browser.test.ts +++ b/test/ai-gen-coverage/localname-matching.browser.test.ts @@ -2,9 +2,8 @@ import { test, expect } from "vitest" import { morph } from "../../src/morphlex" import { dom } from "../new/utils" -test("morphing inputs by localName without any matching attributes", () => { - // Lines 566-568: inputs match by localName when types are the same - // Remove all id, name, href, src attributes to force localName matching +test("morphing inputs without distinguishing attributes are replaced, not reused by localName", () => { + // Inputs are excluded from the tag-name matching pass const a = dom(`
`) as HTMLElement const b = dom(`
`) as HTMLElement @@ -13,11 +12,11 @@ test("morphing inputs by localName without any matching attributes", () => { morph(a, b) - // Elements should be reused via localName matching - expect(a.children[0]).toBe(firstInput) - expect(a.children[1]).toBe(secondInput) - expect(firstInput.placeholder).toBe("first") - expect(secondInput.placeholder).toBe("second") + // Inputs should be replaced, not reused + expect(a.children[0]).not.toBe(firstInput) + expect(a.children[1]).not.toBe(secondInput) + expect((a.children[0] as HTMLInputElement).placeholder).toBe("first") + expect((a.children[1] as HTMLInputElement).placeholder).toBe("second") }) test("morphing inputs with type mismatch skips candidate", () => { @@ -32,10 +31,10 @@ test("morphing inputs with type mismatch skips candidate", () => { expect(inputs[1].type).toBe("text") }) -test("morphing textarea with modified value preserves change", () => { - // Line 190: textarea dirty flag - const a = dom(`
`) as HTMLElement - const b = dom(`
`) as HTMLElement +test("morphing textarea with modified value preserves change when matched by name", () => { + // Line 190: textarea dirty flag — textareas need a name attribute to match via heuristics + const a = dom(`
`) as HTMLElement + const b = dom(`
`) as HTMLElement const textarea = a.firstElementChild as HTMLTextAreaElement textarea.value = "user input" diff --git a/test/morphlex-coverage.test.ts b/test/morphlex-coverage.test.ts index 67e9353..5397bb1 100644 --- a/test/morphlex-coverage.test.ts +++ b/test/morphlex-coverage.test.ts @@ -65,21 +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 - parent.appendChild(input) - - const reference = document.createElement("div") - const refInput = document.createElement("input") - refInput.disabled = true - 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") diff --git a/test/new/tagname-exclusions.browser.test.ts b/test/new/tagname-exclusions.browser.test.ts new file mode 100644 index 0000000..9692387 --- /dev/null +++ b/test/new/tagname-exclusions.browser.test.ts @@ -0,0 +1,216 @@ +import { test, expect } from "vitest" +import { morph } from "../../src/morphlex" +import { dom } from "./utils" + +test("elements with unmatched id are not matched by tag name", () => { + const a = dom(`

old

`) + const b = dom(`

new

`) + + const original = a.children[0]! + + morph(a, b) + + // The

has an id that didn't match any candidate, so it should be + // inserted as new rather than morphed into the existing

+ expect(a.children[0]).not.toBe(original) + expect(a.children[0]!.id).toBe("b") + expect(a.children[0]!.textContent).toBe("new") +}) + +test("elements with unmatched name attribute are not matched by tag name", () => { + const a = dom(`

old
`) + const b = dom(`
new
`) + + const original = a.children[0]! + + morph(a, b) + + expect(a.children[0]).not.toBe(original) + expect(a.children[0]!.getAttribute("name")).toBe("new-anchor") +}) + +test("elements with unmatched href attribute are not matched by tag name", () => { + const a = dom(`
old
`) + const b = dom(`
new
`) + + const original = a.children[0]! + + morph(a, b) + + expect(a.children[0]).not.toBe(original) + expect(a.children[0]!.getAttribute("href")).toBe("/new") +}) + +test("elements with unmatched src attribute are not matched by tag name", () => { + const a = dom(`
`) + const b = dom(`
`) + + const original = a.children[0]! + + morph(a, b) + + expect(a.children[0]).not.toBe(original) + expect(a.children[0]!.getAttribute("src")).toBe("/new.png") +}) + +test("input elements are not matched by tag name", () => { + const a = dom(`
`) + const b = dom(`
`) + + const original = a.children[0]! + + morph(a, b) + + expect(a.children[0]).not.toBe(original) + expect(a.children[0]!.className).toBe("new") +}) + +test("textarea elements are not matched by tag name", () => { + const a = dom(`
`) + const b = dom(`
`) + + const original = a.children[0]! + + morph(a, b) + + expect(a.children[0]).not.toBe(original) + expect(a.children[0]!.className).toBe("new") +}) + +test("select elements are not matched by tag name", () => { + const a = dom(`
`) + const b = dom(`
`) + + const original = a.children[0]! + + morph(a, b) + + expect(a.children[0]).not.toBe(original) + expect(a.children[0]!.className).toBe("new") +}) + +test("from-side candidates with distinguishing attributes are not matched by tag name", () => { + // A bare

in the "to" tree should not match a

in the "from" tree + const a = dom(`

old

`) + const b = dom(`

new

`) + + const original = a.children[0]! + + morph(a, b) + + expect(a.children[0]).not.toBe(original) + expect(a.children[0]!.textContent).toBe("new") +}) + +test("from-side input candidates are not matched by tag name", () => { + // A bare should not be blocked, but an candidate should be skipped + const a = dom(`
old
`) + const b = dom(`
new
`) + + const span = a.children[1]! + + morph(a, b) + + // The span should be reused via tag name matching + expect(a.children[0]).toBe(span) + expect(a.children[0]!.textContent).toBe("new") +}) + +test("elements with descendant IDs are not matched by tag name", () => { + const a = dom(`
  • A
`) + const b = dom(`
  • B
`) + + const originalUl = a.children[0]! + + morph(a, b) + + // The
    has descendant IDs that didn't overlap, so it should not + // be reused via tag name matching + expect(a.children[0]).not.toBe(originalUl) +}) + +test("plain elements without distinguishing attributes still match by tag name", () => { + const a = dom(`

    old

    `) + const b = dom(`

    new

    `) + + const original = a.children[0]! + + morph(a, b) + + // No id, no name/href/src, not a form control, no descendant IDs + // — should still match by tag name and be reused + expect(a.children[0]).toBe(original) + expect(a.children[0]!.className).toBe("new") + expect(a.children[0]!.textContent).toBe("new") +}) + +test("input elements with matching name attributes are still reused", () => { + const a = dom(`
    `) + const b = dom(`
    `) + + const original = a.children[0]! + + morph(a, b) + + // Input has a name attribute that matches — should be reused via heuristic matching + expect(a.children[0]).toBe(original) +}) + +test("input elements with matching id are still reused", () => { + const a = dom(`
    `) + const b = dom(`
    `) + + const original = a.children[0]! + + morph(a, b) + + // Input has an id that matches — should be reused via id matching + expect(a.children[0]).toBe(original) +}) + +test.skipIf(!("attachInternals" in HTMLElement.prototype))("form-associated custom elements in the from-side are not matched by tag name", () => { + class MyControl extends HTMLElement { + static formAssociated = true + constructor() { + super() + this.attachInternals() + } + } + if (!customElements.get("my-control")) { + customElements.define("my-control", MyControl) + } + + const a = document.createElement("div") + const control = document.createElement("my-control") + control.setAttribute("class", "old") + a.appendChild(control) + + const b = dom(`
    `) + + morph(a, b) + + // The from-side custom element is form-associated, so it should not + // be matched by tag name — it should be replaced + expect(a.children[0]).not.toBe(control) + expect(a.children[0]!.className).toBe("new") +}) + +test.skipIf(!("attachInternals" in HTMLElement.prototype))("non-form-associated custom elements are still matched by tag name", () => { + class MyWidget extends HTMLElement {} + if (!customElements.get("my-widget")) { + customElements.define("my-widget", MyWidget) + } + + const a = document.createElement("div") + const widget = document.createElement("my-widget") + widget.setAttribute("class", "old") + a.appendChild(widget) + + const b = dom(`
    `) + + morph(a, b) + + // Non-form-associated custom element — should still match by tag name + expect(a.children[0]).toBe(widget) + expect(a.children[0]!.className).toBe("new") +})