Skip to content

Commit 317316f

Browse files
authored
Restrict tag-name matching to exclude elements with identity or form state (#42)
The tag-name fallback pass now skips elements that have distinguishing attributes (id, name, href, src, descendant IDs) or are form controls (input, textarea, select, form-associated custom elements). These elements carry identity or live state that shouldn't be transferred to an unrelated element that happens to share the same tag name.
1 parent 579f098 commit 317316f

7 files changed

Lines changed: 291 additions & 50 deletions

src/morphlex.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -638,27 +638,41 @@ class Morph {
638638
}
639639
}
640640

641-
// Match by tagName
641+
// Match by tagName (only for elements without distinguishing attributes)
642642
for (let i = 0; i < unmatchedElementIndices.length; i++) {
643643
const unmatchedIndex = unmatchedElementIndices[i]!
644644
if (!unmatchedElementActive[unmatchedIndex]) continue
645645

646646
const element = toChildNodes[unmatchedIndex] as Element
647647

648+
if (
649+
element.id !== "" ||
650+
isFormControl(element) ||
651+
this.#idArrayMap.has(element) ||
652+
element.hasAttribute("name") ||
653+
element.hasAttribute("href") ||
654+
element.hasAttribute("src")
655+
) continue
656+
648657
const localName = localNameMap[unmatchedIndex]
649658

650659
for (let c = 0; c < candidateElementIndices.length; c++) {
651660
const candidateIndex = candidateElementIndices[c]!
652661
if (!candidateElementActive[candidateIndex]) continue
653662

654663
const candidate = fromChildNodes[candidateIndex] as Element
664+
665+
if (
666+
isFormControl(candidate) ||
667+
this.#idSetMap.has(candidate) ||
668+
candidate.hasAttribute("name") ||
669+
candidate.hasAttribute("href") ||
670+
candidate.hasAttribute("src")
671+
) continue
672+
655673
const candidateLocalName = candidateLocalNameMap[candidateIndex]
656674

657675
if (localName === candidateLocalName) {
658-
if (localName === "input" && (candidate as HTMLInputElement).type !== (element as HTMLInputElement).type) {
659-
// Treat inputs with different type as though they are different tags.
660-
continue
661-
}
662676
matches[unmatchedIndex] = candidateIndex
663677
op[unmatchedIndex] = Operation.SameElement
664678
candidateElementActive[candidateIndex] = 0
@@ -897,6 +911,16 @@ function isInputElement(element: Element): element is HTMLInputElement {
897911
return element.localName === "input"
898912
}
899913

914+
function isFormControl(element: Element): boolean {
915+
const localName = element.localName
916+
return (
917+
localName === "input" ||
918+
localName === "textarea" ||
919+
localName === "select" ||
920+
(localName.includes("-") && (element.constructor as unknown as Record<string, unknown>)["formAssociated"] === true)
921+
)
922+
}
923+
900924
function isOptionElement(element: Element): element is HTMLOptionElement {
901925
return element.localName === "option"
902926
}

test/ai-gen-coverage/input-localname-matching.browser.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { test, expect } from "vitest"
22
import { morph } from "../../src/morphlex"
33
import { dom } from "../new/utils"
44

5-
test("morphing inputs by localName with same types matches correctly", () => {
6-
// This test ensures lines 566-568 are covered (the non-continue path)
7-
// Inputs without id or name attributes fall through to localName matching
5+
test("morphing inputs without distinguishing attributes are replaced, not reused", () => {
6+
// Inputs are excluded from the tag-name matching pass, so bare inputs
7+
// without id/name/href/src are treated as new elements
88
const a = dom(`<form><input type="email" class="first"><input type="email" class="second"></form>`) as HTMLElement
99
const b = dom(`<form><input type="email" placeholder="a"><input type="email" placeholder="b"></form>`) as HTMLElement
1010

@@ -13,9 +13,9 @@ test("morphing inputs by localName with same types matches correctly", () => {
1313

1414
morph(a, b)
1515

16-
// Same type inputs should be reused via localName matching
17-
expect(a.children[0]).toBe(first)
18-
expect(a.children[1]).toBe(second)
16+
// Inputs should be replaced, not reused via localName matching
17+
expect(a.children[0]).not.toBe(first)
18+
expect(a.children[1]).not.toBe(second)
1919
expect((a.children[0] as HTMLInputElement).placeholder).toBe("a")
2020
expect((a.children[1] as HTMLInputElement).placeholder).toBe("b")
2121
})

test/ai-gen-coverage/input-type-continue.browser.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ test("input type mismatch with no matching type - all trigger continue", () => {
7676
expect((a.children[0] as HTMLInputElement).type).toBe("email")
7777
})
7878

79-
test("input with matching type does not trigger continue", () => {
80-
// When types match, the continue branch is NOT taken
79+
test("input without distinguishing attributes is replaced, not reused", () => {
80+
// Inputs are excluded from the tag-name matching pass entirely
8181
const a = dom(
8282
`<div>
8383
<input type="text" data-value="old">
@@ -95,9 +95,9 @@ test("input with matching type does not trigger continue", () => {
9595

9696
morph(a, b)
9797

98-
// First text input matches without triggering continue
98+
// Inputs are not matched by tag name, so element is replaced
9999
expect(a.children.length).toBe(1)
100-
expect(a.children[0]).toBe(firstInput)
100+
expect(a.children[0]).not.toBe(firstInput)
101101
expect((a.children[0] as HTMLInputElement).getAttribute("data-value")).toBe("new")
102102
})
103103

test/ai-gen-coverage/input-type-mismatch.browser.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe("input type mismatch", () => {
5454
expect(newInput.name).toBe("test")
5555
})
5656

57-
test("morphing inputs with same type uses localName matching", () => {
57+
test("morphing inputs without distinguishing attributes are replaced", () => {
5858
const a = dom(`<div><input type="text" value="a"><input type="text" value="b"></div>`) as HTMLElement
5959
const b = dom(`<div><input type="text" value="x"><input type="text" value="y"></div>`) as HTMLElement
6060

@@ -63,9 +63,9 @@ describe("input type mismatch", () => {
6363

6464
morph(a, b)
6565

66-
// Lines 566-568: inputs match by localName and type, so they're reused
67-
expect(a.children[0]).toBe(firstInput)
68-
expect(a.children[1]).toBe(secondInput)
66+
// Inputs are excluded from the tag-name matching pass, so they're replaced
67+
expect(a.children[0]).not.toBe(firstInput)
68+
expect(a.children[1]).not.toBe(secondInput)
6969
expect((a.children[0] as HTMLInputElement).value).toBe("x")
7070
expect((a.children[1] as HTMLInputElement).value).toBe("y")
7171
})
@@ -86,7 +86,7 @@ describe("input type mismatch", () => {
8686
expect(inputs[2].id).toBe("c")
8787
})
8888

89-
test("morphing inputs without IDs triggers localName matching with type check", () => {
89+
test("morphing inputs without IDs are replaced, not matched by localName", () => {
9090
const a = dom(`<div><input type="text" class="a"><input type="number" class="b"></div>`) as HTMLElement
9191
const b = dom(`<div><input type="text" class="x"><input type="number" class="y"></div>`) as HTMLElement
9292

@@ -95,9 +95,9 @@ describe("input type mismatch", () => {
9595

9696
morph(a, b)
9797

98-
// Lines 566-568: same-type inputs are matched and reused via localName
99-
expect(a.children[0]).toBe(firstInput)
100-
expect(a.children[1]).toBe(secondInput)
98+
// Inputs are excluded from the tag-name matching pass, so they're replaced
99+
expect(a.children[0]).not.toBe(firstInput)
100+
expect(a.children[1]).not.toBe(secondInput)
101101
expect((a.children[0] as HTMLInputElement).className).toBe("x")
102102
expect((a.children[1] as HTMLInputElement).className).toBe("y")
103103
})

test/ai-gen-coverage/localname-matching.browser.test.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import { test, expect } from "vitest"
22
import { morph } from "../../src/morphlex"
33
import { dom } from "../new/utils"
44

5-
test("morphing inputs by localName without any matching attributes", () => {
6-
// Lines 566-568: inputs match by localName when types are the same
7-
// Remove all id, name, href, src attributes to force localName matching
5+
test("morphing inputs without distinguishing attributes are replaced, not reused by localName", () => {
6+
// Inputs are excluded from the tag-name matching pass
87
const a = dom(`<div><input type="text"><input type="text"></div>`) as HTMLElement
98
const b = dom(`<div><input type="text" placeholder="first"><input type="text" placeholder="second"></div>`) as HTMLElement
109

@@ -13,11 +12,11 @@ test("morphing inputs by localName without any matching attributes", () => {
1312

1413
morph(a, b)
1514

16-
// Elements should be reused via localName matching
17-
expect(a.children[0]).toBe(firstInput)
18-
expect(a.children[1]).toBe(secondInput)
19-
expect(firstInput.placeholder).toBe("first")
20-
expect(secondInput.placeholder).toBe("second")
15+
// Inputs should be replaced, not reused
16+
expect(a.children[0]).not.toBe(firstInput)
17+
expect(a.children[1]).not.toBe(secondInput)
18+
expect((a.children[0] as HTMLInputElement).placeholder).toBe("first")
19+
expect((a.children[1] as HTMLInputElement).placeholder).toBe("second")
2120
})
2221

2322
test("morphing inputs with type mismatch skips candidate", () => {
@@ -32,10 +31,10 @@ test("morphing inputs with type mismatch skips candidate", () => {
3231
expect(inputs[1].type).toBe("text")
3332
})
3433

35-
test("morphing textarea with modified value preserves change", () => {
36-
// Line 190: textarea dirty flag
37-
const a = dom(`<div><textarea>original</textarea></div>`) as HTMLElement
38-
const b = dom(`<div><textarea>new</textarea></div>`) as HTMLElement
34+
test("morphing textarea with modified value preserves change when matched by name", () => {
35+
// Line 190: textarea dirty flag — textareas need a name attribute to match via heuristics
36+
const a = dom(`<div><textarea name="content">original</textarea></div>`) as HTMLElement
37+
const b = dom(`<div><textarea name="content">new</textarea></div>`) as HTMLElement
3938

4039
const textarea = a.firstElementChild as HTMLTextAreaElement
4140
textarea.value = "user input"

test/morphlex-coverage.test.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,21 +65,23 @@ describe("Morphlex - Coverage Tests", () => {
6565
})
6666

6767
describe("Property updates", () => {
68-
it("should update input disabled property", () => {
69-
const parent = document.createElement("div")
70-
const input = document.createElement("input")
71-
input.disabled = false
72-
parent.appendChild(input)
73-
74-
const reference = document.createElement("div")
75-
const refInput = document.createElement("input")
76-
refInput.disabled = true
77-
reference.appendChild(refInput)
78-
79-
morph(parent, reference)
80-
81-
expect(input.disabled).toBe(true)
82-
})
68+
it("should update input disabled property", () => {
69+
const parent = document.createElement("div")
70+
const input = document.createElement("input")
71+
input.disabled = false
72+
input.name = "test"
73+
parent.appendChild(input)
74+
75+
const reference = document.createElement("div")
76+
const refInput = document.createElement("input")
77+
refInput.disabled = true
78+
refInput.name = "test"
79+
reference.appendChild(refInput)
80+
81+
morph(parent, reference)
82+
83+
expect(input.disabled).toBe(true)
84+
})
8385

8486
it("should not update file input value", () => {
8587
const parent = document.createElement("div")

0 commit comments

Comments
 (0)