tangled
alpha
login
or
join now
yippee.fun
/
morphlex
0
fork
atom
Precise DOM morphing
morphing
typescript
dom
0
fork
atom
overview
issues
pulls
pipelines
Optimise removal whitespace
joel.drapper.me
4 months ago
41905810
3e2296b7
+635
-478
10 changed files
expand all
collapse all
unified
split
.gitignore
src
morphlex.ts
test
inputs.browser.test.ts
new
inputs.browser.test.ts
insert.browser.test.ts
remove.browser.test.ts
reordering.browser.test.ts
utils.ts
optimal-reordering.browser.test.ts
test_debug.js
+1
-1
.gitignore
···
3
3
dist
4
4
coverage
5
5
reference
6
6
-
test/__screenshots__
6
6
+
test/**/__screenshots__
+29
-27
src/morphlex.ts
···
453
453
}
454
454
}
455
455
456
456
-
// Match nodes by isEqualNode
456
456
+
// Match nodes by isEqualNode (skip whitespace-only text nodes)
457
457
for (let i = 0; i < toChildNodes.length; i++) {
458
458
if (matches[i]) continue
459
459
const node = toChildNodes[i]!
460
460
if (isElement(node)) continue
461
461
+
if (isWhitespace(node)) continue
461
462
462
463
for (const candidate of candidateNodes) {
463
464
if (candidate.isEqualNode(node)) {
···
468
469
}
469
470
}
470
471
471
471
-
// Match by nodeType
472
472
+
// Match by nodeType (skip whitespace-only text nodes)
472
473
for (let i = 0; i < toChildNodes.length; i++) {
473
474
if (matches[i]) continue
474
475
const node = toChildNodes[i]!
475
476
if (isElement(node)) continue
477
477
+
if (isWhitespace(node)) continue
476
478
477
479
const nodeType = node.nodeType
478
480
···
485
487
}
486
488
}
487
489
488
488
-
// Build sequence of current indices for LIS calculation
490
490
+
// Remove unmatched nodes from candidate sets (they were matched and should not be removed)
491
491
+
for (const match of matches) {
492
492
+
if (match) {
493
493
+
candidateNodes.delete(match)
494
494
+
if (isElement(match)) {
495
495
+
candidateElements.delete(match)
496
496
+
}
497
497
+
}
498
498
+
}
499
499
+
500
500
+
// Remove any unmatched candidates first, before calculating LIS and repositioning
501
501
+
for (const candidate of candidateNodes) {
502
502
+
this.#removeNode(candidate)
503
503
+
}
504
504
+
505
505
+
for (const candidate of candidateElements) {
506
506
+
this.#removeNode(candidate)
507
507
+
}
508
508
+
509
509
+
// Build sequence of current indices for LIS calculation (after removals)
489
510
const fromIndex = new Map<ChildNode, number>()
490
490
-
Array.from(fromChildNodes).forEach((node, i) => fromIndex.set(node, i))
511
511
+
Array.from(parent.childNodes).forEach((node, i) => fromIndex.set(node, i))
491
512
492
513
const sequence: Array<number> = []
493
514
for (let i = 0; i < matches.length; i++) {
···
519
540
}
520
541
this.#morphOneToOne(match, node)
521
542
insertionPoint = match.nextSibling
522
522
-
// Skip over any nodes that will be removed to avoid unnecessary moves
523
523
-
while (
524
524
-
insertionPoint &&
525
525
-
(candidateNodes.has(insertionPoint) || (isElement(insertionPoint) && candidateElements.has(insertionPoint)))
526
526
-
) {
527
527
-
insertionPoint = insertionPoint.nextSibling
528
528
-
}
529
543
} else {
530
544
if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) {
531
545
moveBefore(parent, node, insertionPoint)
532
546
this.#options.afterNodeAdded?.(node)
533
547
insertionPoint = node.nextSibling
534
534
-
// Skip over any nodes that will be removed to avoid unnecessary moves
535
535
-
while (
536
536
-
insertionPoint &&
537
537
-
(candidateNodes.has(insertionPoint) || (isElement(insertionPoint) && candidateElements.has(insertionPoint)))
538
538
-
) {
539
539
-
insertionPoint = insertionPoint.nextSibling
540
540
-
}
541
548
}
542
549
}
543
543
-
}
544
544
-
545
545
-
// Remove any remaining unmatched candidates
546
546
-
for (const candidate of candidateNodes) {
547
547
-
this.#removeNode(candidate)
548
548
-
}
549
549
-
550
550
-
for (const candidate of candidateElements) {
551
551
-
this.#removeNode(candidate)
552
550
}
553
551
554
552
this.#options.afterChildrenVisited?.(from)
···
619
617
620
618
function isInputElement(element: Element): element is HTMLInputElement {
621
619
return element.localName === "input"
620
620
+
}
621
621
+
622
622
+
function isWhitespace(node: ChildNode): boolean {
623
623
+
return node.nodeType === 3 && node.textContent?.trim() === ""
622
624
}
623
625
624
626
function isOptionElement(element: Element): element is HTMLOptionElement {
-193
test/inputs.browser.test.ts
···
1
1
-
import { test, expect, describe } from "vitest"
2
2
-
import { morph } from "../src/morphlex"
3
3
-
4
4
-
function parseHTML(html: string): HTMLElement {
5
5
-
const tmp = document.createElement("div")
6
6
-
tmp.innerHTML = html.trim()
7
7
-
return tmp.firstChild as HTMLElement
8
8
-
}
9
9
-
10
10
-
describe("text input", () => {
11
11
-
test("morphing a modified value with preserveModified enabled", () => {
12
12
-
const a = parseHTML(`<input type="text" value="a">`) as HTMLInputElement
13
13
-
const b = parseHTML(`<input type="text" value="b">`) as HTMLInputElement
14
14
-
15
15
-
a.value = "c"
16
16
-
morph(a, b, { preserveModified: true })
17
17
-
18
18
-
expect(a.outerHTML).toBe(`<input type="text" value="b">`)
19
19
-
expect(a.value).toBe("c")
20
20
-
})
21
21
-
22
22
-
test("morphing a modified value preserveModified disabled", () => {
23
23
-
const a = parseHTML(`<input type="text" value="a">`) as HTMLInputElement
24
24
-
const b = parseHTML(`<input type="text" value="b">`) as HTMLInputElement
25
25
-
26
26
-
a.value = "c"
27
27
-
morph(a, b, { preserveModified: false })
28
28
-
29
29
-
expect(a.outerHTML).toBe(`<input type="text" value="b">`)
30
30
-
expect(a.value).toBe("b")
31
31
-
})
32
32
-
33
33
-
test("morphing an unmodified value with preserveModified enabled", () => {
34
34
-
const a = parseHTML(`<input type="text" value="a">`) as HTMLInputElement
35
35
-
const b = parseHTML(`<input type="text" value="b">`) as HTMLInputElement
36
36
-
37
37
-
morph(a, b, { preserveModified: true })
38
38
-
39
39
-
expect(a.outerHTML).toBe(`<input type="text" value="b">`)
40
40
-
expect(a.value).toBe("b")
41
41
-
})
42
42
-
})
43
43
-
44
44
-
describe("checkbox", () => {
45
45
-
test("morphing a modified checkbox checked with preserveModified enabled", () => {
46
46
-
const a = parseHTML(`<input type="checkbox">`) as HTMLInputElement
47
47
-
const b = parseHTML(`<input type="checkbox" checked>`) as HTMLInputElement
48
48
-
49
49
-
a.checked = true
50
50
-
morph(a, b, { preserveModified: true })
51
51
-
52
52
-
expect(a.hasAttribute("checked")).toBe(true)
53
53
-
expect(a.checked).toBe(true)
54
54
-
})
55
55
-
56
56
-
test("morphing a modified checkbox checked with preserveModified disabled", () => {
57
57
-
const a = parseHTML(`<input type="checkbox">`) as HTMLInputElement
58
58
-
const b = parseHTML(`<input type="checkbox" checked>`) as HTMLInputElement
59
59
-
60
60
-
a.checked = true
61
61
-
morph(a, b, { preserveModified: false })
62
62
-
63
63
-
expect(a.hasAttribute("checked")).toBe(true)
64
64
-
expect(a.checked).toBe(true)
65
65
-
})
66
66
-
67
67
-
test("morphing an unmodified checkbox with preserveModified enabled", () => {
68
68
-
const a = parseHTML(`<input type="checkbox">`) as HTMLInputElement
69
69
-
const b = parseHTML(`<input type="checkbox" checked>`) as HTMLInputElement
70
70
-
71
71
-
morph(a, b, { preserveModified: true })
72
72
-
73
73
-
expect(a.hasAttribute("checked")).toBe(true)
74
74
-
expect(a.checked).toBe(true)
75
75
-
})
76
76
-
77
77
-
test("morphing a modified checkbox unchecked with preserveModified enabled", () => {
78
78
-
const a = parseHTML(`<input type="checkbox" checked>`) as HTMLInputElement
79
79
-
const b = parseHTML(`<input type="checkbox">`) as HTMLInputElement
80
80
-
81
81
-
a.checked = false
82
82
-
morph(a, b, { preserveModified: true })
83
83
-
84
84
-
expect(a.hasAttribute("checked")).toBe(false)
85
85
-
expect(a.checked).toBe(false)
86
86
-
})
87
87
-
88
88
-
test("morphing a modified checkbox unchecked with preserveModified disabled", () => {
89
89
-
const a = parseHTML(`<input type="checkbox" checked>`) as HTMLInputElement
90
90
-
const b = parseHTML(`<input type="checkbox">`) as HTMLInputElement
91
91
-
92
92
-
a.checked = false
93
93
-
morph(a, b, { preserveModified: false })
94
94
-
95
95
-
expect(a.hasAttribute("checked")).toBe(false)
96
96
-
expect(a.checked).toBe(false)
97
97
-
})
98
98
-
})
99
99
-
100
100
-
describe("select", () => {
101
101
-
test("morphing a modified select option with preserveModified enabled", () => {
102
102
-
const a = parseHTML(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement
103
103
-
const b = parseHTML(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
104
104
-
105
105
-
a.value = "b"
106
106
-
morph(a, b, { preserveModified: true })
107
107
-
108
108
-
expect(a.options[1].hasAttribute("selected")).toBe(true)
109
109
-
expect(a.value).toBe("b")
110
110
-
expect(a.options[1].selected).toBe(true)
111
111
-
})
112
112
-
113
113
-
test("morphing a modified select option with preserveModified disabled", () => {
114
114
-
const a = parseHTML(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement
115
115
-
const b = parseHTML(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
116
116
-
117
117
-
a.value = "b"
118
118
-
morph(a, b, { preserveModified: false })
119
119
-
120
120
-
expect(a.options[1].hasAttribute("selected")).toBe(true)
121
121
-
expect(a.value).toBe("b")
122
122
-
expect(a.options[1].selected).toBe(true)
123
123
-
})
124
124
-
125
125
-
test("morphing an unmodified select option with preserveModified enabled", () => {
126
126
-
const a = parseHTML(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement
127
127
-
const b = parseHTML(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
128
128
-
129
129
-
morph(a, b, { preserveModified: true })
130
130
-
131
131
-
expect(a.options[1].hasAttribute("selected")).toBe(true)
132
132
-
expect(a.value).toBe("b")
133
133
-
expect(a.options[1].selected).toBe(true)
134
134
-
})
135
135
-
136
136
-
test("morphing a modified select option back to default with preserveModified enabled", () => {
137
137
-
const a = parseHTML(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
138
138
-
const b = parseHTML(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement
139
139
-
140
140
-
a.value = "a"
141
141
-
morph(a, b, { preserveModified: true })
142
142
-
143
143
-
expect(a.options[1].hasAttribute("selected")).toBe(false)
144
144
-
expect(a.value).toBe("a")
145
145
-
expect(a.options[0].selected).toBe(true)
146
146
-
})
147
147
-
148
148
-
test("morphing a modified select option back to default with preserveModified disabled", () => {
149
149
-
const a = parseHTML(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
150
150
-
const b = parseHTML(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement
151
151
-
152
152
-
a.value = "a"
153
153
-
morph(a, b, { preserveModified: false })
154
154
-
155
155
-
expect(a.options[1].hasAttribute("selected")).toBe(false)
156
156
-
expect(a.value).toBe("a")
157
157
-
expect(a.options[0].selected).toBe(true)
158
158
-
})
159
159
-
})
160
160
-
161
161
-
describe("textarea", () => {
162
162
-
test("morphing a modified textarea value with preserveModified enabled", () => {
163
163
-
const a = parseHTML(`<textarea>a</textarea>`) as HTMLTextAreaElement
164
164
-
const b = parseHTML(`<textarea>b</textarea>`) as HTMLTextAreaElement
165
165
-
166
166
-
a.value = "c"
167
167
-
morph(a, b, { preserveModified: true })
168
168
-
169
169
-
expect(a.textContent).toBe("b")
170
170
-
expect(a.value).toBe("c")
171
171
-
})
172
172
-
173
173
-
test("morphing a modified textarea value with preserveModified disabled", () => {
174
174
-
const a = parseHTML(`<textarea>a</textarea>`) as HTMLTextAreaElement
175
175
-
const b = parseHTML(`<textarea>b</textarea>`) as HTMLTextAreaElement
176
176
-
177
177
-
a.value = "c"
178
178
-
morph(a, b, { preserveModified: false })
179
179
-
180
180
-
expect(a.textContent).toBe("b")
181
181
-
expect(a.value).toBe("b")
182
182
-
})
183
183
-
184
184
-
test("morphing an unmodified textarea value with preserveModified enabled", () => {
185
185
-
const a = parseHTML(`<textarea>a</textarea>`) as HTMLTextAreaElement
186
186
-
const b = parseHTML(`<textarea>b</textarea>`) as HTMLTextAreaElement
187
187
-
188
188
-
morph(a, b, { preserveModified: true })
189
189
-
190
190
-
expect(a.textContent).toBe("b")
191
191
-
expect(a.value).toBe("b")
192
192
-
})
193
193
-
})
+188
test/new/inputs.browser.test.ts
···
1
1
+
import { test, expect, describe } from "vitest"
2
2
+
import { morph } from "../../src/morphlex"
3
3
+
import { dom } from "./utils"
4
4
+
5
5
+
describe("text input", () => {
6
6
+
test("morphing a modified value with preserveModified enabled", () => {
7
7
+
const a = dom(`<input type="text" value="a">`) as HTMLInputElement
8
8
+
const b = dom(`<input type="text" value="b">`) as HTMLInputElement
9
9
+
10
10
+
a.value = "c"
11
11
+
morph(a, b, { preserveModified: true })
12
12
+
13
13
+
expect(a.outerHTML).toBe(`<input type="text" value="b">`)
14
14
+
expect(a.value).toBe("c")
15
15
+
})
16
16
+
17
17
+
test("morphing a modified value preserveModified disabled", () => {
18
18
+
const a = dom(`<input type="text" value="a">`) as HTMLInputElement
19
19
+
const b = dom(`<input type="text" value="b">`) as HTMLInputElement
20
20
+
21
21
+
a.value = "c"
22
22
+
morph(a, b, { preserveModified: false })
23
23
+
24
24
+
expect(a.outerHTML).toBe(`<input type="text" value="b">`)
25
25
+
expect(a.value).toBe("b")
26
26
+
})
27
27
+
28
28
+
test("morphing an unmodified value with preserveModified enabled", () => {
29
29
+
const a = dom(`<input type="text" value="a">`) as HTMLInputElement
30
30
+
const b = dom(`<input type="text" value="b">`) as HTMLInputElement
31
31
+
32
32
+
morph(a, b, { preserveModified: true })
33
33
+
34
34
+
expect(a.outerHTML).toBe(`<input type="text" value="b">`)
35
35
+
expect(a.value).toBe("b")
36
36
+
})
37
37
+
})
38
38
+
39
39
+
describe("checkbox", () => {
40
40
+
test("morphing a modified checkbox checked with preserveModified enabled", () => {
41
41
+
const a = dom(`<input type="checkbox">`) as HTMLInputElement
42
42
+
const b = dom(`<input type="checkbox" checked>`) as HTMLInputElement
43
43
+
44
44
+
a.checked = true
45
45
+
morph(a, b, { preserveModified: true })
46
46
+
47
47
+
expect(a.hasAttribute("checked")).toBe(true)
48
48
+
expect(a.checked).toBe(true)
49
49
+
})
50
50
+
51
51
+
test("morphing a modified checkbox checked with preserveModified disabled", () => {
52
52
+
const a = dom(`<input type="checkbox">`) as HTMLInputElement
53
53
+
const b = dom(`<input type="checkbox" checked>`) as HTMLInputElement
54
54
+
55
55
+
a.checked = true
56
56
+
morph(a, b, { preserveModified: false })
57
57
+
58
58
+
expect(a.hasAttribute("checked")).toBe(true)
59
59
+
expect(a.checked).toBe(true)
60
60
+
})
61
61
+
62
62
+
test("morphing an unmodified checkbox with preserveModified enabled", () => {
63
63
+
const a = dom(`<input type="checkbox">`) as HTMLInputElement
64
64
+
const b = dom(`<input type="checkbox" checked>`) as HTMLInputElement
65
65
+
66
66
+
morph(a, b, { preserveModified: true })
67
67
+
68
68
+
expect(a.hasAttribute("checked")).toBe(true)
69
69
+
expect(a.checked).toBe(true)
70
70
+
})
71
71
+
72
72
+
test("morphing a modified checkbox unchecked with preserveModified enabled", () => {
73
73
+
const a = dom(`<input type="checkbox" checked>`) as HTMLInputElement
74
74
+
const b = dom(`<input type="checkbox">`) as HTMLInputElement
75
75
+
76
76
+
a.checked = false
77
77
+
morph(a, b, { preserveModified: true })
78
78
+
79
79
+
expect(a.hasAttribute("checked")).toBe(false)
80
80
+
expect(a.checked).toBe(false)
81
81
+
})
82
82
+
83
83
+
test("morphing a modified checkbox unchecked with preserveModified disabled", () => {
84
84
+
const a = dom(`<input type="checkbox" checked>`) as HTMLInputElement
85
85
+
const b = dom(`<input type="checkbox">`) as HTMLInputElement
86
86
+
87
87
+
a.checked = false
88
88
+
morph(a, b, { preserveModified: false })
89
89
+
90
90
+
expect(a.hasAttribute("checked")).toBe(false)
91
91
+
expect(a.checked).toBe(false)
92
92
+
})
93
93
+
})
94
94
+
95
95
+
describe("select", () => {
96
96
+
test("morphing a modified select option with preserveModified enabled", () => {
97
97
+
const a = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement
98
98
+
const b = dom(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
99
99
+
100
100
+
a.value = "b"
101
101
+
morph(a, b, { preserveModified: true })
102
102
+
103
103
+
expect(a.options[1].hasAttribute("selected")).toBe(true)
104
104
+
expect(a.value).toBe("b")
105
105
+
expect(a.options[1].selected).toBe(true)
106
106
+
})
107
107
+
108
108
+
test("morphing a modified select option with preserveModified disabled", () => {
109
109
+
const a = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement
110
110
+
const b = dom(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
111
111
+
112
112
+
a.value = "b"
113
113
+
morph(a, b, { preserveModified: false })
114
114
+
115
115
+
expect(a.options[1].hasAttribute("selected")).toBe(true)
116
116
+
expect(a.value).toBe("b")
117
117
+
expect(a.options[1].selected).toBe(true)
118
118
+
})
119
119
+
120
120
+
test("morphing an unmodified select option with preserveModified enabled", () => {
121
121
+
const a = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement
122
122
+
const b = dom(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
123
123
+
124
124
+
morph(a, b, { preserveModified: true })
125
125
+
126
126
+
expect(a.options[1].hasAttribute("selected")).toBe(true)
127
127
+
expect(a.value).toBe("b")
128
128
+
expect(a.options[1].selected).toBe(true)
129
129
+
})
130
130
+
131
131
+
test("morphing a modified select option back to default with preserveModified enabled", () => {
132
132
+
const a = dom(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
133
133
+
const b = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement
134
134
+
135
135
+
a.value = "a"
136
136
+
morph(a, b, { preserveModified: true })
137
137
+
138
138
+
expect(a.options[1].hasAttribute("selected")).toBe(false)
139
139
+
expect(a.value).toBe("a")
140
140
+
expect(a.options[0].selected).toBe(true)
141
141
+
})
142
142
+
143
143
+
test("morphing a modified select option back to default with preserveModified disabled", () => {
144
144
+
const a = dom(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement
145
145
+
const b = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement
146
146
+
147
147
+
a.value = "a"
148
148
+
morph(a, b, { preserveModified: false })
149
149
+
150
150
+
expect(a.options[1].hasAttribute("selected")).toBe(false)
151
151
+
expect(a.value).toBe("a")
152
152
+
expect(a.options[0].selected).toBe(true)
153
153
+
})
154
154
+
})
155
155
+
156
156
+
describe("textarea", () => {
157
157
+
test("morphing a modified textarea value with preserveModified enabled", () => {
158
158
+
const a = dom(`<textarea>a</textarea>`) as HTMLTextAreaElement
159
159
+
const b = dom(`<textarea>b</textarea>`) as HTMLTextAreaElement
160
160
+
161
161
+
a.value = "c"
162
162
+
morph(a, b, { preserveModified: true })
163
163
+
164
164
+
expect(a.textContent).toBe("b")
165
165
+
expect(a.value).toBe("c")
166
166
+
})
167
167
+
168
168
+
test("morphing a modified textarea value with preserveModified disabled", () => {
169
169
+
const a = dom(`<textarea>a</textarea>`) as HTMLTextAreaElement
170
170
+
const b = dom(`<textarea>b</textarea>`) as HTMLTextAreaElement
171
171
+
172
172
+
a.value = "c"
173
173
+
morph(a, b, { preserveModified: false })
174
174
+
175
175
+
expect(a.textContent).toBe("b")
176
176
+
expect(a.value).toBe("b")
177
177
+
})
178
178
+
179
179
+
test("morphing an unmodified textarea value with preserveModified enabled", () => {
180
180
+
const a = dom(`<textarea>a</textarea>`) as HTMLTextAreaElement
181
181
+
const b = dom(`<textarea>b</textarea>`) as HTMLTextAreaElement
182
182
+
183
183
+
morph(a, b, { preserveModified: true })
184
184
+
185
185
+
expect(a.textContent).toBe("b")
186
186
+
expect(a.value).toBe("b")
187
187
+
})
188
188
+
})
+81
test/new/insert.browser.test.ts
···
1
1
+
import { test, expect } from "vitest"
2
2
+
import { morph } from "../../src/morphlex"
3
3
+
import { dom, observeMutations } from "./utils"
4
4
+
5
5
+
test("insert item at the end of a list", () => {
6
6
+
const from = dom(`
7
7
+
<ul>
8
8
+
<li>Item 1</li>
9
9
+
<li>Item 2</li>
10
10
+
</ul>
11
11
+
`)
12
12
+
13
13
+
const to = dom(`
14
14
+
<ul>
15
15
+
<li>Item 1</li>
16
16
+
<li>Item 2</li>
17
17
+
<li>New Item</li>
18
18
+
</ul>
19
19
+
`)
20
20
+
21
21
+
const expected = to.outerHTML
22
22
+
23
23
+
const mutations = observeMutations(from, () => {
24
24
+
morph(from, to)
25
25
+
})
26
26
+
27
27
+
expect(from.outerHTML).toBe(expected)
28
28
+
expect(mutations.elementsAdded).toBe(1)
29
29
+
})
30
30
+
31
31
+
test("insert item at the beginning of a list", () => {
32
32
+
const from = dom(`
33
33
+
<ul>
34
34
+
<li>Item 1</li>
35
35
+
<li>Item 2</li>
36
36
+
</ul>
37
37
+
`)
38
38
+
39
39
+
const to = dom(`
40
40
+
<ul>
41
41
+
<li>New Item</li>
42
42
+
<li>Item 1</li>
43
43
+
<li>Item 2</li>
44
44
+
</ul>
45
45
+
`)
46
46
+
47
47
+
const expected = to.outerHTML
48
48
+
49
49
+
const mutations = observeMutations(from, () => {
50
50
+
morph(from, to)
51
51
+
})
52
52
+
53
53
+
expect(from.outerHTML).toBe(expected)
54
54
+
expect(mutations.elementsAdded).toBe(1)
55
55
+
})
56
56
+
57
57
+
test("insert item in the middle of a list", () => {
58
58
+
const from = dom(`
59
59
+
<ul>
60
60
+
<li>Item 1</li>
61
61
+
<li>Item 2</li>
62
62
+
</ul>
63
63
+
`)
64
64
+
65
65
+
const to = dom(`
66
66
+
<ul>
67
67
+
<li>Item 1</li>
68
68
+
<li>New Item</li>
69
69
+
<li>Item 2</li>
70
70
+
</ul>
71
71
+
`)
72
72
+
73
73
+
const expected = to.outerHTML
74
74
+
75
75
+
const mutations = observeMutations(from, () => {
76
76
+
morph(from, to)
77
77
+
})
78
78
+
79
79
+
expect(from.outerHTML).toBe(expected)
80
80
+
expect(mutations.elementsAdded).toBe(1)
81
81
+
})
+81
test/new/remove.browser.test.ts
···
1
1
+
import { test, expect } from "vitest"
2
2
+
import { morph } from "../../src/morphlex"
3
3
+
import { dom, observeMutations } from "./utils"
4
4
+
5
5
+
test("remove item from the end of a list", () => {
6
6
+
const from = dom(`
7
7
+
<ul>
8
8
+
<li>Item 1</li>
9
9
+
<li>Item 2</li>
10
10
+
<li>Item 3</li>
11
11
+
</ul>
12
12
+
`)
13
13
+
14
14
+
const to = dom(`
15
15
+
<ul>
16
16
+
<li>Item 1</li>
17
17
+
<li>Item 2</li>
18
18
+
</ul>
19
19
+
`)
20
20
+
21
21
+
const expected = to.outerHTML
22
22
+
23
23
+
const mutations = observeMutations(from, () => {
24
24
+
morph(from, to)
25
25
+
})
26
26
+
27
27
+
expect(from.outerHTML).toBe(expected)
28
28
+
expect(mutations.elementsRemoved).toBe(1)
29
29
+
})
30
30
+
31
31
+
test("remove item from the beginning of a list", () => {
32
32
+
const from = dom(`
33
33
+
<ul>
34
34
+
<li>Item 1</li>
35
35
+
<li>Item 2</li>
36
36
+
<li>Item 3</li>
37
37
+
</ul>
38
38
+
`)
39
39
+
40
40
+
const to = dom(`
41
41
+
<ul>
42
42
+
<li>Item 2</li>
43
43
+
<li>Item 3</li>
44
44
+
</ul>
45
45
+
`)
46
46
+
47
47
+
const expected = to.outerHTML
48
48
+
49
49
+
const mutations = observeMutations(from, () => {
50
50
+
morph(from, to)
51
51
+
})
52
52
+
53
53
+
expect(from.outerHTML).toBe(expected)
54
54
+
expect(mutations.elementsRemoved).toBe(1)
55
55
+
})
56
56
+
57
57
+
test("remove item from the middle of a list", () => {
58
58
+
const from = dom(`
59
59
+
<ul>
60
60
+
<li>Item 1</li>
61
61
+
<li>Item 2</li>
62
62
+
<li>Item 3</li>
63
63
+
</ul>
64
64
+
`)
65
65
+
66
66
+
const to = dom(`
67
67
+
<ul>
68
68
+
<li>Item 1</li>
69
69
+
<li>Item 3</li>
70
70
+
</ul>
71
71
+
`)
72
72
+
73
73
+
const expected = to.outerHTML
74
74
+
75
75
+
const mutations = observeMutations(from, () => {
76
76
+
morph(from, to)
77
77
+
})
78
78
+
79
79
+
expect(from.outerHTML).toBe(expected)
80
80
+
expect(mutations.elementsRemoved).toBe(1)
81
81
+
})
+117
test/new/reordering.browser.test.ts
···
1
1
+
import { describe, test, expect } from "vitest"
2
2
+
import { morph } from "../../src/morphlex"
3
3
+
import { dom, observeMutations } from "./utils"
4
4
+
5
5
+
describe("Optimal Reordering", () => {
6
6
+
test("should minimize moves when reordering - simple rotation", () => {
7
7
+
const from = document.createElement("ul")
8
8
+
for (let i = 1; i <= 5; i++) {
9
9
+
from.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`))
10
10
+
}
11
11
+
12
12
+
const to = document.createElement("ul")
13
13
+
for (const id of [5, 1, 2, 3, 4]) {
14
14
+
to.appendChild(dom(`<li id="item-${id}">Item ${id}</li>`))
15
15
+
}
16
16
+
17
17
+
document.body.appendChild(from)
18
18
+
19
19
+
const mutations = observeMutations(from, () => {
20
20
+
morph(from, to)
21
21
+
})
22
22
+
23
23
+
document.body.removeChild(from)
24
24
+
25
25
+
expect(mutations.childListChanges).toBe(2)
26
26
+
})
27
27
+
28
28
+
test("should minimize moves when reordering - partial reorder", () => {
29
29
+
const from = document.createElement("ul")
30
30
+
for (let i = 1; i <= 10; i++) {
31
31
+
from.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`))
32
32
+
}
33
33
+
34
34
+
const to = document.createElement("ul")
35
35
+
for (const id of [3, 1, 5, 7, 2, 8, 4, 9, 6, 10]) {
36
36
+
to.appendChild(dom(`<li id="item-${id}">Item ${id}</li>`))
37
37
+
}
38
38
+
39
39
+
document.body.appendChild(from)
40
40
+
41
41
+
const mutations = observeMutations(from, () => {
42
42
+
morph(from, to)
43
43
+
})
44
44
+
45
45
+
document.body.removeChild(from)
46
46
+
47
47
+
expect(mutations.childListChanges).toBe(8)
48
48
+
})
49
49
+
50
50
+
test("should minimize moves when reordering - complete reversal", () => {
51
51
+
const from = document.createElement("ul")
52
52
+
for (let i = 1; i <= 6; i++) {
53
53
+
from.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`))
54
54
+
}
55
55
+
56
56
+
const to = document.createElement("ul")
57
57
+
for (let i = 6; i >= 1; i--) {
58
58
+
to.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`))
59
59
+
}
60
60
+
61
61
+
document.body.appendChild(from)
62
62
+
63
63
+
const mutations = observeMutations(from, () => {
64
64
+
morph(from, to)
65
65
+
})
66
66
+
67
67
+
document.body.removeChild(from)
68
68
+
69
69
+
expect(mutations.childListChanges).toBe(10)
70
70
+
})
71
71
+
72
72
+
test("should minimize moves when reordering - already optimal", () => {
73
73
+
const from = document.createElement("ul")
74
74
+
for (let i = 1; i <= 5; i++) {
75
75
+
from.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`))
76
76
+
}
77
77
+
78
78
+
const to = document.createElement("ul")
79
79
+
for (const id of [1, 2, 4, 5, 3]) {
80
80
+
to.appendChild(dom(`<li id="item-${id}">Item ${id}</li>`))
81
81
+
}
82
82
+
83
83
+
document.body.appendChild(from)
84
84
+
85
85
+
const mutations = observeMutations(from, () => {
86
86
+
morph(from, to)
87
87
+
})
88
88
+
89
89
+
document.body.removeChild(from)
90
90
+
91
91
+
expect(mutations.childListChanges).toBe(2)
92
92
+
})
93
93
+
94
94
+
test("should minimize moves with mixed operations", () => {
95
95
+
const from = document.createElement("ul")
96
96
+
for (let i = 1; i <= 8; i++) {
97
97
+
from.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`))
98
98
+
}
99
99
+
100
100
+
const to = document.createElement("ul")
101
101
+
for (const id of [4, 1, 9, 5, 7, 3, 10, 8]) {
102
102
+
to.appendChild(dom(`<li id="item-${id}">Item ${id}</li>`))
103
103
+
}
104
104
+
105
105
+
document.body.appendChild(from)
106
106
+
107
107
+
observeMutations(from, () => {
108
108
+
morph(from, to)
109
109
+
})
110
110
+
111
111
+
document.body.removeChild(from)
112
112
+
113
113
+
expect(from.children.length).toBe(8)
114
114
+
expect(from.children[0]?.id).toBe("item-4")
115
115
+
expect(from.children[7]?.id).toBe("item-8")
116
116
+
})
117
117
+
})
+84
test/new/utils.ts
···
1
1
+
export function dom(html: string): HTMLElement {
2
2
+
const tmp = document.createElement("div")
3
3
+
tmp.innerHTML = html.trim()
4
4
+
return tmp.firstChild as HTMLElement
5
5
+
}
6
6
+
7
7
+
export class Mutations {
8
8
+
records: Array<MutationRecord> = []
9
9
+
10
10
+
push(...records: MutationRecord[]) {
11
11
+
this.records.push(...records)
12
12
+
}
13
13
+
14
14
+
get count(): number {
15
15
+
return this.records.length
16
16
+
}
17
17
+
18
18
+
get childListChanges(): number {
19
19
+
return this.records.filter((m) => m.type === "childList").length
20
20
+
}
21
21
+
22
22
+
get elementsAdded(): number {
23
23
+
return this.records.filter(
24
24
+
(m) => m.type === "childList" && Array.from(m.addedNodes).some((n) => n.nodeType === Node.ELEMENT_NODE),
25
25
+
).length
26
26
+
}
27
27
+
28
28
+
get elementsRemoved(): number {
29
29
+
return this.records.filter(
30
30
+
(m) => m.type === "childList" && Array.from(m.removedNodes).some((n) => n.nodeType === Node.ELEMENT_NODE),
31
31
+
).length
32
32
+
}
33
33
+
34
34
+
get textNodesAdded(): number {
35
35
+
return this.records.filter(
36
36
+
(m) => m.type === "childList" && Array.from(m.addedNodes).some((n) => n.nodeType === Node.TEXT_NODE),
37
37
+
).length
38
38
+
}
39
39
+
40
40
+
get textNodesRemoved(): number {
41
41
+
return this.records.filter(
42
42
+
(m) => m.type === "childList" && Array.from(m.removedNodes).some((n) => n.nodeType === Node.TEXT_NODE),
43
43
+
).length
44
44
+
}
45
45
+
46
46
+
get nodesAdded(): number {
47
47
+
return this.records.filter((m) => m.type === "childList" && m.addedNodes.length > 0).length
48
48
+
}
49
49
+
50
50
+
get nodesRemoved(): number {
51
51
+
return this.records.filter((m) => m.type === "childList" && m.removedNodes.length > 0).length
52
52
+
}
53
53
+
54
54
+
get attributeChanges(): number {
55
55
+
return this.records.filter((m) => m.type === "attributes").length
56
56
+
}
57
57
+
58
58
+
get characterDataChanges(): number {
59
59
+
return this.records.filter((m) => m.type === "characterData").length
60
60
+
}
61
61
+
}
62
62
+
63
63
+
export function observeMutations(target: Node, callback: () => void): Mutations {
64
64
+
const mutations = new Mutations()
65
65
+
const observer = new MutationObserver((records) => {
66
66
+
mutations.push(...records)
67
67
+
})
68
68
+
69
69
+
observer.observe(target, {
70
70
+
childList: true,
71
71
+
attributes: true,
72
72
+
characterData: true,
73
73
+
subtree: true,
74
74
+
})
75
75
+
76
76
+
callback()
77
77
+
78
78
+
// Flush any pending mutations
79
79
+
const records = observer.takeRecords()
80
80
+
mutations.push(...records)
81
81
+
82
82
+
observer.disconnect()
83
83
+
return mutations
84
84
+
}
-257
test/optimal-reordering.browser.test.ts
···
1
1
-
import { describe, test, expect } from "vitest"
2
2
-
import { morph } from "../src/morphlex"
3
3
-
4
4
-
describe("Optimal Reordering", () => {
5
5
-
test("should minimize moves when reordering - simple rotation", async () => {
6
6
-
const from = document.createElement("ul")
7
7
-
for (let i = 1; i <= 5; i++) {
8
8
-
const li = document.createElement("li")
9
9
-
li.id = `item-${i}`
10
10
-
li.textContent = `Item ${i}`
11
11
-
from.appendChild(li)
12
12
-
}
13
13
-
14
14
-
const to = document.createElement("ul")
15
15
-
// Rotate: move last to first [1,2,3,4,5] → [5,1,2,3,4]
16
16
-
for (const id of [5, 1, 2, 3, 4]) {
17
17
-
const li = document.createElement("li")
18
18
-
li.id = `item-${id}`
19
19
-
li.textContent = `Item ${id}`
20
20
-
to.appendChild(li)
21
21
-
}
22
22
-
23
23
-
document.body.appendChild(from)
24
24
-
25
25
-
const mutations: MutationRecord[] = []
26
26
-
const observer = new MutationObserver((records) => {
27
27
-
mutations.push(...records)
28
28
-
})
29
29
-
30
30
-
observer.observe(from, {
31
31
-
childList: true,
32
32
-
subtree: true,
33
33
-
})
34
34
-
35
35
-
morph(from, to)
36
36
-
37
37
-
await new Promise((resolve) => setTimeout(resolve, 0))
38
38
-
39
39
-
observer.disconnect()
40
40
-
document.body.removeChild(from)
41
41
-
42
42
-
// With LIS optimization:
43
43
-
// Sequence: [4, 0, 1, 2, 3] (current indices in desired order)
44
44
-
// LIS: [0, 1, 2, 3] (items 1,2,3,4 stay in order)
45
45
-
// Only item 5 needs to move!
46
46
-
// Expected: 2 mutations (1 remove + 1 add for moving item 5)
47
47
-
48
48
-
const childListMutations = mutations.filter((m) => m.type === "childList")
49
49
-
console.log(`\nRotation test: ${childListMutations.length} childList mutations`)
50
50
-
51
51
-
// Currently fails with 2 moves (4 mutations)
52
52
-
// Should pass with 1 move (2 mutations) after LIS optimization
53
53
-
expect(childListMutations.length).toBe(2)
54
54
-
})
55
55
-
56
56
-
test("should minimize moves when reordering - partial reorder", async () => {
57
57
-
const from = document.createElement("ul")
58
58
-
for (let i = 1; i <= 10; i++) {
59
59
-
const li = document.createElement("li")
60
60
-
li.id = `item-${i}`
61
61
-
li.textContent = `Item ${i}`
62
62
-
from.appendChild(li)
63
63
-
}
64
64
-
65
65
-
const to = document.createElement("ul")
66
66
-
// [1,2,3,4,5,6,7,8,9,10] → [3,1,5,7,2,8,4,9,6,10]
67
67
-
// Items in LIS: 3,5,7,8,9,10 (6 items stay)
68
68
-
// Items to move: 1,2,4,6 (4 items)
69
69
-
for (const id of [3, 1, 5, 7, 2, 8, 4, 9, 6, 10]) {
70
70
-
const li = document.createElement("li")
71
71
-
li.id = `item-${id}`
72
72
-
li.textContent = `Item ${id}`
73
73
-
to.appendChild(li)
74
74
-
}
75
75
-
76
76
-
document.body.appendChild(from)
77
77
-
78
78
-
const mutations: MutationRecord[] = []
79
79
-
const observer = new MutationObserver((records) => {
80
80
-
mutations.push(...records)
81
81
-
})
82
82
-
83
83
-
observer.observe(from, {
84
84
-
childList: true,
85
85
-
subtree: true,
86
86
-
})
87
87
-
88
88
-
morph(from, to)
89
89
-
90
90
-
await new Promise((resolve) => setTimeout(resolve, 0))
91
91
-
92
92
-
observer.disconnect()
93
93
-
document.body.removeChild(from)
94
94
-
95
95
-
// Sequence: [2, 0, 4, 6, 1, 7, 3, 8, 5, 9]
96
96
-
// LIS: [2, 4, 6, 7, 8, 9] length 6 (items 3,5,7,8,9,10)
97
97
-
// Move: 10 - 6 = 4 items
98
98
-
// Expected: 8 mutations (4 moves × 2)
99
99
-
100
100
-
const childListMutations = mutations.filter((m) => m.type === "childList")
101
101
-
console.log(`\nPartial reorder test: ${childListMutations.length} childList mutations`)
102
102
-
103
103
-
expect(childListMutations.length).toBeLessThanOrEqual(8)
104
104
-
})
105
105
-
106
106
-
test("should minimize moves when reordering - complete reversal", async () => {
107
107
-
const from = document.createElement("ul")
108
108
-
for (let i = 1; i <= 6; i++) {
109
109
-
const li = document.createElement("li")
110
110
-
li.id = `item-${i}`
111
111
-
li.textContent = `Item ${i}`
112
112
-
from.appendChild(li)
113
113
-
}
114
114
-
115
115
-
const to = document.createElement("ul")
116
116
-
// Complete reversal [1,2,3,4,5,6] → [6,5,4,3,2,1]
117
117
-
for (let i = 6; i >= 1; i--) {
118
118
-
const li = document.createElement("li")
119
119
-
li.id = `item-${i}`
120
120
-
li.textContent = `Item ${i}`
121
121
-
to.appendChild(li)
122
122
-
}
123
123
-
124
124
-
document.body.appendChild(from)
125
125
-
126
126
-
const mutations: MutationRecord[] = []
127
127
-
const observer = new MutationObserver((records) => {
128
128
-
mutations.push(...records)
129
129
-
})
130
130
-
131
131
-
observer.observe(from, {
132
132
-
childList: true,
133
133
-
subtree: true,
134
134
-
})
135
135
-
136
136
-
morph(from, to)
137
137
-
138
138
-
await new Promise((resolve) => setTimeout(resolve, 0))
139
139
-
140
140
-
observer.disconnect()
141
141
-
document.body.removeChild(from)
142
142
-
143
143
-
// Sequence: [5, 4, 3, 2, 1, 0] (completely decreasing)
144
144
-
// LIS: any single element, length 1
145
145
-
// Move: 6 - 1 = 5 items
146
146
-
// Expected: 10 mutations (5 moves × 2)
147
147
-
148
148
-
const childListMutations = mutations.filter((m) => m.type === "childList")
149
149
-
console.log(`\nReversal test: ${childListMutations.length} childList mutations`)
150
150
-
151
151
-
// This is actually optimal for a reversal - can't do better than moving 5 items
152
152
-
expect(childListMutations.length).toBeLessThanOrEqual(10)
153
153
-
})
154
154
-
155
155
-
test("should minimize moves when reordering - already optimal", async () => {
156
156
-
const from = document.createElement("ul")
157
157
-
for (let i = 1; i <= 5; i++) {
158
158
-
const li = document.createElement("li")
159
159
-
li.id = `item-${i}`
160
160
-
li.textContent = `Item ${i}`
161
161
-
from.appendChild(li)
162
162
-
}
163
163
-
164
164
-
const to = document.createElement("ul")
165
165
-
// [1,2,3,4,5] → [1,2,4,5,3]
166
166
-
// Move only item 3 to the end
167
167
-
for (const id of [1, 2, 4, 5, 3]) {
168
168
-
const li = document.createElement("li")
169
169
-
li.id = `item-${id}`
170
170
-
li.textContent = `Item ${id}`
171
171
-
to.appendChild(li)
172
172
-
}
173
173
-
174
174
-
document.body.appendChild(from)
175
175
-
176
176
-
const mutations: MutationRecord[] = []
177
177
-
const observer = new MutationObserver((records) => {
178
178
-
mutations.push(...records)
179
179
-
})
180
180
-
181
181
-
observer.observe(from, {
182
182
-
childList: true,
183
183
-
subtree: true,
184
184
-
})
185
185
-
186
186
-
morph(from, to)
187
187
-
188
188
-
await new Promise((resolve) => setTimeout(resolve, 0))
189
189
-
190
190
-
observer.disconnect()
191
191
-
document.body.removeChild(from)
192
192
-
193
193
-
// Sequence: [0, 1, 3, 4, 2]
194
194
-
// LIS: [0, 1, 3, 4] length 4 (items 1,2,4,5)
195
195
-
// Move: only item 3
196
196
-
// Expected: 2 mutations (1 move × 2)
197
197
-
198
198
-
const childListMutations = mutations.filter((m) => m.type === "childList")
199
199
-
console.log(`\nAlready optimal test: ${childListMutations.length} childList mutations`)
200
200
-
201
201
-
expect(childListMutations.length).toBe(2)
202
202
-
})
203
203
-
204
204
-
test("should minimize moves with mixed operations", async () => {
205
205
-
const from = document.createElement("ul")
206
206
-
for (let i = 1; i <= 8; i++) {
207
207
-
const li = document.createElement("li")
208
208
-
li.id = `item-${i}`
209
209
-
li.textContent = `Item ${i}`
210
210
-
from.appendChild(li)
211
211
-
}
212
212
-
213
213
-
const to = document.createElement("ul")
214
214
-
// Remove 2 and 6, add 9 and 10, reorder rest
215
215
-
// [1,2,3,4,5,6,7,8] → [4,1,9,5,7,3,10,8]
216
216
-
for (const id of [4, 1, 9, 5, 7, 3, 10, 8]) {
217
217
-
const li = document.createElement("li")
218
218
-
li.id = `item-${id}`
219
219
-
li.textContent = `Item ${id}`
220
220
-
to.appendChild(li)
221
221
-
}
222
222
-
223
223
-
document.body.appendChild(from)
224
224
-
225
225
-
const mutations: MutationRecord[] = []
226
226
-
const observer = new MutationObserver((records) => {
227
227
-
mutations.push(...records)
228
228
-
})
229
229
-
230
230
-
observer.observe(from, {
231
231
-
childList: true,
232
232
-
subtree: true,
233
233
-
})
234
234
-
235
235
-
morph(from, to)
236
236
-
237
237
-
await new Promise((resolve) => setTimeout(resolve, 0))
238
238
-
239
239
-
observer.disconnect()
240
240
-
document.body.removeChild(from)
241
241
-
242
242
-
// Matched items: [4,1,5,7,3,8] at indices [3,0,4,6,2,7]
243
243
-
// Sequence: [3, 0, 4, 6, 2, 7]
244
244
-
// LIS: [3, 4, 6, 7] length 4 (items 4,5,7,8)
245
245
-
// Move: 6 - 4 = 2 items (1 and 3)
246
246
-
// Plus: 2 removals (2,6) and 2 additions (9,10)
247
247
-
// Expected: ~8 mutations (2 moves + 2 removes + 2 adds = 6 ops × variable mutations)
248
248
-
249
249
-
const childListMutations = mutations.filter((m) => m.type === "childList")
250
250
-
console.log(`\nMixed operations test: ${childListMutations.length} childList mutations`)
251
251
-
252
252
-
// Just verify it completes correctly
253
253
-
expect(from.children.length).toBe(8)
254
254
-
expect(from.children[0]?.id).toBe("item-4")
255
255
-
expect(from.children[7]?.id).toBe("item-8")
256
256
-
})
257
257
-
})
+54
test_debug.js
···
1
1
+
import { morph } from "./src/morphlex.js"
2
2
+
3
3
+
function dom(html) {
4
4
+
const tmp = document.createElement("div")
5
5
+
tmp.innerHTML = html.trim()
6
6
+
return tmp.firstChild
7
7
+
}
8
8
+
9
9
+
const from = dom(`
10
10
+
<ul>
11
11
+
<li>Item 1</li>
12
12
+
<li>Item 2</li>
13
13
+
<li>Item 3</li>
14
14
+
</ul>
15
15
+
`)
16
16
+
17
17
+
const to = dom(`
18
18
+
<ul>
19
19
+
<li>Item 2</li>
20
20
+
<li>Item 3</li>
21
21
+
</ul>
22
22
+
`)
23
23
+
24
24
+
console.log("From before:", from.outerHTML)
25
25
+
console.log("To:", to.outerHTML)
26
26
+
27
27
+
const observer = new MutationObserver((records) => {
28
28
+
console.log("Mutations:", records.length)
29
29
+
records.forEach((r, i) => {
30
30
+
console.log(` ${i}: type=${r.type}`)
31
31
+
if (r.type === "childList") {
32
32
+
console.log(` added: ${r.addedNodes.length}, removed: ${r.removedNodes.length}`)
33
33
+
r.removedNodes.forEach(n => console.log(` removed: ${n.nodeName} ${n.textContent?.trim()}`))
34
34
+
}
35
35
+
})
36
36
+
})
37
37
+
38
38
+
observer.observe(from, { childList: true, subtree: true })
39
39
+
40
40
+
morph(from, to)
41
41
+
42
42
+
const pending = observer.takeRecords()
43
43
+
console.log("Pending mutations:", pending.length)
44
44
+
pending.forEach((r, i) => {
45
45
+
console.log(` ${i}: type=${r.type}`)
46
46
+
if (r.type === "childList") {
47
47
+
console.log(` added: ${r.addedNodes.length}, removed: ${r.removedNodes.length}`)
48
48
+
r.removedNodes.forEach(n => console.log(` removed: ${n.nodeName} ${n.textContent?.trim()}`))
49
49
+
}
50
50
+
})
51
51
+
52
52
+
observer.disconnect()
53
53
+
54
54
+
console.log("From after:", from.outerHTML)