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
34 changes: 29 additions & 5 deletions src/morphlex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,27 +638,41 @@ 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) ||
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 Keep unkeyed dirty form controls matchable with preserveChanges

With preserveChanges: true, dirty form controls are marked before morphing, which prevents the isEqualNode fast-path and pushes matching to later passes. This new unconditional isFormControl(element) filter removes all unkeyed input/textarea/select elements from tag-name fallback, so controls without id/name are now replaced instead of reused, dropping user-entered value and focus even when the control is logically the same node. This is a behavior regression for the preserve-changes path and should be conditioned so form state is not discarded in common unkeyed forms.

Useful? React with 👍 / 👎.

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++) {
const candidateIndex = candidateElementIndices[c]!
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
Expand Down Expand Up @@ -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<string, unknown>)["formAssociated"] === true)
)
}

function isOptionElement(element: Element): element is HTMLOptionElement {
return element.localName === "option"
}
Expand Down
12 changes: 6 additions & 6 deletions test/ai-gen-coverage/input-localname-matching.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<form><input type="email" class="first"><input type="email" class="second"></form>`) as HTMLElement
const b = dom(`<form><input type="email" placeholder="a"><input type="email" placeholder="b"></form>`) as HTMLElement

Expand All @@ -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")
})
Expand Down
8 changes: 4 additions & 4 deletions test/ai-gen-coverage/input-type-continue.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<div>
<input type="text" data-value="old">
Expand All @@ -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")
})

Expand Down
16 changes: 8 additions & 8 deletions test/ai-gen-coverage/input-type-mismatch.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<div><input type="text" value="a"><input type="text" value="b"></div>`) as HTMLElement
const b = dom(`<div><input type="text" value="x"><input type="text" value="y"></div>`) as HTMLElement

Expand All @@ -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")
})
Expand All @@ -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(`<div><input type="text" class="a"><input type="number" class="b"></div>`) as HTMLElement
const b = dom(`<div><input type="text" class="x"><input type="number" class="y"></div>`) as HTMLElement

Expand All @@ -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")
})
Expand Down
23 changes: 11 additions & 12 deletions test/ai-gen-coverage/localname-matching.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<div><input type="text"><input type="text"></div>`) as HTMLElement
const b = dom(`<div><input type="text" placeholder="first"><input type="text" placeholder="second"></div>`) as HTMLElement

Expand All @@ -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", () => {
Expand All @@ -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(`<div><textarea>original</textarea></div>`) as HTMLElement
const b = dom(`<div><textarea>new</textarea></div>`) 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(`<div><textarea name="content">original</textarea></div>`) as HTMLElement
const b = dom(`<div><textarea name="content">new</textarea></div>`) as HTMLElement

const textarea = a.firstElementChild as HTMLTextAreaElement
textarea.value = "user input"
Expand Down
32 changes: 17 additions & 15 deletions test/morphlex-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 67 to +71
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

Indentation in this describe("Property updates") section is inconsistent with the rest of the file (the it(...) is not indented under the describe, and its body follows the same). Please reformat (e.g., run Prettier) so the nesting matches the surrounding tests.

Copilot uses AI. Check for mistakes.
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
Loading