Precise DOM morphing
morphing
typescript
dom
1import { describe, it, expect, beforeEach, afterEach } from "vitest"
2import { morph } from "../src/morphlex"
3
4describe("Morphlex - Infinite Loop Bug Detection", () => {
5 let container: HTMLElement
6
7 beforeEach(() => {
8 container = document.createElement("div")
9 document.body.appendChild(container)
10 })
11
12 afterEach(() => {
13 if (container && container.parentNode) {
14 container.parentNode.removeChild(container)
15 }
16 })
17
18 describe("Input value handling", () => {
19 it("should not infinite loop with modified input value", () => {
20 const parent = document.createElement("div")
21
22 const input = document.createElement("input") as HTMLInputElement
23 input.id = "input1"
24 input.defaultValue = "default"
25 input.value = "modified"
26
27 const div = document.createElement("div")
28 div.id = "div1"
29
30 parent.appendChild(input)
31 parent.appendChild(div)
32
33 const reference = document.createElement("div")
34
35 const refDiv = document.createElement("div")
36 refDiv.id = "div1"
37
38 const refInput = document.createElement("input") as HTMLInputElement
39 refInput.id = "input1"
40
41 reference.appendChild(refDiv)
42 reference.appendChild(refInput)
43
44 const startTime = Date.now()
45 morph(parent, reference)
46 const endTime = Date.now()
47
48 expect(endTime - startTime).toBeLessThan(1000)
49 })
50 })
51
52 describe("ID-based matching loops", () => {
53 it("should not infinite loop when matching by overlapping ID sets", () => {
54 const parent = document.createElement("div")
55
56 const outer1 = document.createElement("div")
57 outer1.id = "outer1"
58 const inner1 = document.createElement("span")
59 inner1.id = "inner1"
60 outer1.appendChild(inner1)
61
62 const outer2 = document.createElement("div")
63 outer2.id = "outer2"
64 const inner2 = document.createElement("span")
65 inner2.id = "inner2"
66 outer2.appendChild(inner2)
67
68 parent.appendChild(outer1)
69 parent.appendChild(outer2)
70
71 // Reference has them in reverse order
72 const reference = document.createElement("div")
73
74 const refOuter2 = document.createElement("div")
75 refOuter2.id = "outer2"
76 const refInner2 = document.createElement("span")
77 refInner2.id = "inner2"
78 refOuter2.appendChild(refInner2)
79
80 const refOuter1 = document.createElement("div")
81 refOuter1.id = "outer1"
82 const refInner1 = document.createElement("span")
83 refInner1.id = "inner1"
84 refOuter1.appendChild(refInner1)
85
86 reference.appendChild(refOuter2)
87 reference.appendChild(refOuter1)
88
89 const startTime = Date.now()
90 morph(parent, reference)
91 const endTime = Date.now()
92
93 expect(endTime - startTime).toBeLessThan(1000)
94 expect(parent.children[0].id).toBe("outer2")
95 expect(parent.children[1].id).toBe("outer1")
96 })
97
98 it("should not infinite loop when currentNode becomes child during matching", () => {
99 const parent = document.createElement("div")
100
101 const child1 = document.createElement("div")
102 child1.id = "child1"
103
104 const child2 = document.createElement("div")
105 child2.id = "child2"
106
107 const child3 = document.createElement("div")
108 child3.id = "child3"
109
110 parent.appendChild(child1)
111 parent.appendChild(child2)
112 parent.appendChild(child3)
113
114 const reference = document.createElement("div")
115
116 const refChild2 = document.createElement("div")
117 refChild2.id = "child2"
118
119 const refChild3 = document.createElement("div")
120 refChild3.id = "child3"
121
122 const refChild1 = document.createElement("div")
123 refChild1.id = "child1"
124
125 reference.appendChild(refChild2)
126 reference.appendChild(refChild3)
127 reference.appendChild(refChild1)
128
129 const startTime = Date.now()
130 morph(parent, reference)
131 const endTime = Date.now()
132
133 expect(endTime - startTime).toBeLessThan(1000)
134 })
135 })
136
137 describe("Head element morphing loops", () => {
138 it("should not infinite loop when morphing head with many elements", () => {
139 const originalHead = document.createElement("head")
140
141 for (let i = 0; i < 10; i++) {
142 const meta = document.createElement("meta")
143 meta.setAttribute("name", `meta-${i}`)
144 meta.setAttribute("content", `value-${i}`)
145 originalHead.appendChild(meta)
146 }
147
148 const referenceHead = document.createElement("head")
149
150 for (let i = 0; i < 10; i++) {
151 const meta = document.createElement("meta")
152 meta.setAttribute("name", `meta-${i}`)
153 meta.setAttribute("content", `updated-${i}`)
154 referenceHead.appendChild(meta)
155 }
156
157 const startTime = Date.now()
158 morph(originalHead, referenceHead)
159 const endTime = Date.now()
160
161 expect(endTime - startTime).toBeLessThan(1000)
162 })
163
164 it("should not infinite loop with identical outerHTML", () => {
165 const originalHead = document.createElement("head")
166 const script1 = document.createElement("script")
167 script1.textContent = "console.log('test')"
168 originalHead.appendChild(script1)
169
170 const script2 = document.createElement("script")
171 script2.textContent = "console.log('test')"
172 originalHead.appendChild(script2)
173
174 const referenceHead = document.createElement("head")
175 const refScript = document.createElement("script")
176 refScript.textContent = "console.log('test')"
177 referenceHead.appendChild(refScript)
178
179 const startTime = Date.now()
180 morph(originalHead, referenceHead)
181 const endTime = Date.now()
182
183 expect(endTime - startTime).toBeLessThan(1000)
184 })
185 })
186
187 describe("Recursive morphing loops", () => {
188 it("should not infinite loop with deeply nested structures", () => {
189 const parent = document.createElement("div")
190 let current = parent
191
192 for (let i = 0; i < 20; i++) {
193 const child = document.createElement("div")
194 child.id = `level-${i}`
195 current.appendChild(child)
196 current = child
197 }
198
199 const reference = document.createElement("div")
200 let refCurrent = reference
201
202 for (let i = 0; i < 20; i++) {
203 const child = document.createElement("div")
204 child.id = `level-${i}`
205 child.textContent = "updated"
206 refCurrent.appendChild(child)
207 refCurrent = child
208 }
209
210 const startTime = Date.now()
211 morph(parent, reference)
212 const endTime = Date.now()
213
214 expect(endTime - startTime).toBeLessThan(2000)
215 })
216
217 it("should not infinite loop with circular-looking ID references", () => {
218 const parent = document.createElement("div")
219
220 const a = document.createElement("div")
221 a.id = "a"
222 const b = document.createElement("div")
223 b.id = "b"
224 const c = document.createElement("div")
225 c.id = "c"
226
227 parent.appendChild(a)
228 parent.appendChild(b)
229 parent.appendChild(c)
230
231 const reference = document.createElement("div")
232
233 const refB = document.createElement("div")
234 refB.id = "b"
235 const refC = document.createElement("div")
236 refC.id = "c"
237 const refA = document.createElement("div")
238 refA.id = "a"
239
240 reference.appendChild(refB)
241 reference.appendChild(refC)
242 reference.appendChild(refA)
243
244 const startTime = Date.now()
245 morph(parent, reference)
246 const endTime = Date.now()
247
248 expect(endTime - startTime).toBeLessThan(1000)
249 expect(parent.children[0].id).toBe("b")
250 expect(parent.children[1].id).toBe("c")
251 expect(parent.children[2].id).toBe("a")
252 })
253 })
254
255 describe("Edge case loops", () => {
256 it("should not infinite loop when beforeNodeRemoved returns false", () => {
257 const parent = document.createElement("div")
258
259 // Create a custom element parent
260 const customElement = document.createElement("my-component")
261 const child1 = document.createElement("span")
262 child1.textContent = "child1"
263 const child2 = document.createElement("span")
264 child2.textContent = "child2"
265
266 customElement.appendChild(child1)
267 customElement.appendChild(child2)
268 parent.appendChild(customElement)
269
270 // Reference only has one child in the custom element
271 const reference = document.createElement("div")
272 const refCustomElement = document.createElement("my-component")
273 const refChild1 = document.createElement("span")
274 refChild1.textContent = "child1"
275 refCustomElement.appendChild(refChild1)
276 reference.appendChild(refCustomElement)
277
278 const startTime = Date.now()
279
280 // This should cause an infinite loop if not handled correctly
281 // because child2 can't be removed (beforeNodeRemoved returns false)
282 // but the algorithm keeps trying to remove it
283 morph(parent, reference, {
284 beforeNodeRemoved: (oldNode: Node) => {
285 let parent = oldNode.parentElement
286
287 while (parent) {
288 if (parent.tagName && parent.tagName.includes("-")) return false
289 parent = parent.parentElement
290 }
291
292 return true
293 },
294 })
295
296 const endTime = Date.now()
297
298 // Should complete quickly without infinite loop
299 expect(endTime - startTime).toBeLessThan(1000)
300 // child2 should still be there since it couldn't be removed
301 expect(customElement.children.length).toBe(2)
302 })
303
304 it("should remove removable nodes even when some nodes cannot be removed", () => {
305 const parent = document.createElement("div")
306
307 // Create a custom element parent
308 const customElement = document.createElement("my-component")
309 const child1 = document.createElement("span")
310 child1.textContent = "child1"
311 const child2 = document.createElement("span")
312 child2.textContent = "child2"
313 const child3 = document.createElement("span")
314 child3.textContent = "child3"
315
316 customElement.appendChild(child1)
317 customElement.appendChild(child2)
318 parent.appendChild(customElement)
319 parent.appendChild(child3) // This one is outside the custom element
320
321 // Reference only has child1 in custom element, no child3
322 const reference = document.createElement("div")
323 const refCustomElement = document.createElement("my-component")
324 const refChild1 = document.createElement("span")
325 refChild1.textContent = "child1"
326 refCustomElement.appendChild(refChild1)
327 reference.appendChild(refCustomElement)
328
329 morph(parent, reference, {
330 beforeNodeRemoved: (oldNode: Node) => {
331 let parent = oldNode.parentElement
332
333 while (parent) {
334 if (parent.tagName && parent.tagName.includes("-")) return false
335 parent = parent.parentElement
336 }
337
338 return true
339 },
340 })
341
342 // child2 should still be there (inside custom element, can't be removed)
343 expect(customElement.children.length).toBe(2)
344 expect(customElement.children[1].textContent).toBe("child2")
345
346 // child3 should be removed (outside custom element)
347 expect(parent.children.length).toBe(1)
348 expect(parent.children[0]).toBe(customElement)
349 })
350
351 it("should not infinite loop when node equals insertionPoint", () => {
352 const parent = document.createElement("div")
353 const child = document.createElement("span")
354 child.textContent = "test"
355 parent.appendChild(child)
356
357 const reference = document.createElement("div")
358 const refChild = document.createElement("span")
359 refChild.textContent = "test updated"
360 reference.appendChild(refChild)
361
362 const startTime = Date.now()
363 morph(parent, reference)
364 const endTime = Date.now()
365
366 expect(endTime - startTime).toBeLessThan(1000)
367 })
368
369 it("should not infinite loop with empty elements", () => {
370 const parent = document.createElement("div")
371 const reference = document.createElement("div")
372
373 const startTime = Date.now()
374 morph(parent, reference)
375 const endTime = Date.now()
376
377 expect(endTime - startTime).toBeLessThan(100)
378 })
379
380 it("should not infinite loop when cleaning up excess nodes", () => {
381 const parent = document.createElement("div")
382
383 for (let i = 0; i < 100; i++) {
384 const child = document.createElement("div")
385 parent.appendChild(child)
386 }
387
388 const reference = document.createElement("div")
389
390 const startTime = Date.now()
391 morph(parent, reference)
392 const endTime = Date.now()
393
394 expect(endTime - startTime).toBeLessThan(1000)
395 expect(parent.children.length).toBe(0)
396 })
397 })
398})