Skip to content

Commit 16fb125

Browse files
authored
Use a combination of idSets and idArrays and only flag dirty inputs when necessary (#37)
* Only flag dirty inputs if preserveChanges is disabled * Use idSets for one side * Simplify attribute loops * Bump deps
1 parent f7e2686 commit 16fb125

3 files changed

Lines changed: 65 additions & 44 deletions

File tree

bun.lockb

0 Bytes
Binary file not shown.

package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,17 @@
2727
"serve": "bun --bun vite --open /benchmark/"
2828
},
2929
"devDependencies": {
30-
"@types/bun": "^1.3.1",
31-
"@typescript/native-preview": "^7.0.0-dev.20251104.1",
32-
"@vitest/browser": "^4.0.6",
33-
"@vitest/browser-playwright": "^4.0.6",
34-
"@vitest/coverage-v8": "^4.0.6",
35-
"@vitest/ui": "^4.0.6",
30+
"@types/bun": "^1.3.2",
31+
"@typescript/native-preview": "^7.0.0-dev.20251109.1",
32+
"@vitest/browser": "^4.0.8",
33+
"@vitest/browser-playwright": "^4.0.8",
34+
"@vitest/coverage-v8": "^4.0.8",
35+
"@vitest/ui": "^4.0.8",
3636
"happy-dom": "^20.0.10",
37-
"oxlint": "^1.25.0",
37+
"oxlint": "^1.26.0",
3838
"oxlint-tsgolint": "^0.4.0",
3939
"prettier": "^3.6.2",
4040
"typescript": "^5.9.3",
41-
"vitest": "^4.0.6"
41+
"vitest": "^4.0.8"
4242
}
4343
}

src/morphlex.ts

Lines changed: 57 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ const unmatchedNodes: Set<number> = new Set()
99
const unmatchedElements: Set<number> = new Set()
1010
const whitespaceNodes: Set<number> = new Set()
1111

12-
type IdMap = WeakMap<Node, Array<string>>
12+
type IdSetMap = WeakMap<Node, Set<string>>
13+
type IdArrayMap = WeakMap<Node, Array<string>>
1314

1415
/**
1516
* Configuration options for morphing operations.
@@ -129,7 +130,7 @@ export function morphDocument(from: Document, to: Document | string, options?: O
129130
export function morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode> | string, options: Options = {}): void {
130131
if (typeof to === "string") to = parseFragment(to).childNodes
131132

132-
if (isParentNode(from)) flagDirtyInputs(from)
133+
if (!options.preserveChanges && isParentNode(from)) flagDirtyInputs(from)
133134

134135
new Morph(options).morph(from, to)
135136
}
@@ -214,7 +215,8 @@ function moveBefore(parent: ParentNode, node: ChildNode, insertionPoint: ChildNo
214215
}
215216

216217
class Morph {
217-
readonly #idMap: IdMap = new WeakMap()
218+
readonly #idArrayMap: IdArrayMap = new WeakMap()
219+
readonly #idSetMap: IdSetMap = new WeakMap()
218220
readonly #options: Options
219221

220222
constructor(options: Options = {}) {
@@ -284,10 +286,10 @@ class Morph {
284286
}
285287

286288
if (to instanceof NodeList) {
287-
this.#mapIdSetsForEach(to)
289+
this.#mapIdArraysForEach(to)
288290
this.#morphOneToMany(from, to)
289291
} else if (isParentNode(to)) {
290-
this.#mapIdSets(to)
292+
this.#mapIdArrays(to)
291293
this.#morphOneToOne(from, to)
292294
}
293295
}
@@ -365,9 +367,7 @@ class Morph {
365367
}
366368

367369
// First pass: update/add attributes from reference (iterate forwards)
368-
const toAttributes = to.attributes
369-
for (let i = 0; i < toAttributes.length; i++) {
370-
const { name, value } = toAttributes[i]!
370+
for (const { name, value } of to.attributes) {
371371
if (name === "value") {
372372
if (isInputElement(from) && from.value !== value) {
373373
if (!this.#options.preserveChanges || from.value === from.defaultValue) {
@@ -400,12 +400,8 @@ class Morph {
400400
}
401401
}
402402

403-
const fromAttrs = from.attributes
404-
405-
// Second pass: remove excess attributes (iterate backwards for efficiency)
406-
for (let i = fromAttrs.length - 1; i >= 0; i--) {
407-
const { name, value } = fromAttrs[i]!
408-
403+
// Second pass: remove excess attributes
404+
for (const { name, value } of from.attributes) {
409405
if (!to.hasAttribute(name)) {
410406
if (name === "selected") {
411407
if (isOptionElement(from) && from.selected) {
@@ -509,9 +505,9 @@ class Morph {
509505
const element = toChildNodes[unmatchedIndex] as Element
510506

511507
const id = element.id
512-
const idSet = this.#idMap.get(element)
508+
const idArray = this.#idArrayMap.get(element)
513509

514-
if (id === "" && !idSet) continue
510+
if (id === "" && !idArray) continue
515511

516512
candidateLoop: for (const candidateIndex of candidateElements) {
517513
const candidate = fromChildNodes[candidateIndex] as Element
@@ -525,20 +521,18 @@ class Morph {
525521
break candidateLoop
526522
}
527523

528-
// Match by idSet
529-
if (idSet) {
530-
const candidateIdSet = this.#idMap.get(candidate)
524+
// Match by idArray (to) against idSet (from)
525+
if (idArray) {
526+
const candidateIdSet = this.#idSetMap.get(candidate)
531527
if (candidateIdSet) {
532-
for (let i = 0; i < idSet.length; i++) {
533-
const setId = idSet[i]!
534-
for (let k = 0; k < candidateIdSet.length; k++) {
535-
if (candidateIdSet[k] === setId) {
536-
matches[unmatchedIndex] = candidateIndex
537-
seq[candidateIndex] = unmatchedIndex
538-
candidateElements.delete(candidateIndex)
539-
unmatchedElements.delete(unmatchedIndex)
540-
break candidateLoop
541-
}
528+
for (let i = 0; i < idArray.length; i++) {
529+
const arrayId = idArray[i]!
530+
if (candidateIdSet.has(arrayId)) {
531+
matches[unmatchedIndex] = candidateIndex
532+
seq[candidateIndex] = unmatchedIndex
533+
candidateElements.delete(candidateIndex)
534+
unmatchedElements.delete(unmatchedIndex)
535+
break candidateLoop
542536
}
543537
}
544538
}
@@ -687,17 +681,41 @@ class Morph {
687681
}
688682
}
689683

690-
#mapIdSetsForEach(nodeList: NodeList): void {
684+
#mapIdArraysForEach(nodeList: NodeList): void {
691685
for (const childNode of nodeList) {
692686
if (isParentNode(childNode)) {
693-
this.#mapIdSets(childNode)
687+
this.#mapIdArrays(childNode)
694688
}
695689
}
696690
}
697691

698-
// For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements.
692+
// For each node with an ID, push that ID into the IdArray on the IdArrayMap, for each of its parent elements.
693+
#mapIdArrays(node: ParentNode): void {
694+
const idArrayMap = this.#idArrayMap
695+
696+
for (const element of node.querySelectorAll("[id]")) {
697+
const id = element.id
698+
699+
if (id === "") continue
700+
701+
let currentElement: Element | null = element
702+
703+
while (currentElement) {
704+
const idArray = idArrayMap.get(currentElement)
705+
if (idArray) {
706+
idArray.push(id)
707+
} else {
708+
idArrayMap.set(currentElement, [id])
709+
}
710+
if (currentElement === node) break
711+
currentElement = currentElement.parentElement
712+
}
713+
}
714+
}
715+
716+
// For each node with an ID, add that ID into the IdSet on the IdSetMap, for each of its parent elements.
699717
#mapIdSets(node: ParentNode): void {
700-
const idMap = this.#idMap
718+
const idSetMap = this.#idSetMap
701719

702720
for (const element of node.querySelectorAll("[id]")) {
703721
const id = element.id
@@ -707,9 +725,12 @@ class Morph {
707725
let currentElement: Element | null = element
708726

709727
while (currentElement) {
710-
const idSet: Array<string> | undefined = idMap.get(currentElement)
711-
if (idSet) idSet.push(id)
712-
else idMap.set(currentElement, [id])
728+
const idSet = idSetMap.get(currentElement)
729+
if (idSet) {
730+
idSet.add(id)
731+
} else {
732+
idSetMap.set(currentElement, new Set([id]))
733+
}
713734
if (currentElement === node) break
714735
currentElement = currentElement.parentElement
715736
}

0 commit comments

Comments
 (0)