Precise DOM morphing
morphing
typescript
dom
1/*
2 * These tests were inspired by morphdom.
3 * Here's their license:
4 *
5 * The MIT License (MIT)
6 *
7 * Copyright (c) Patrick Steele-Idem <pnidem@gmail.com> (psteeleidem.com)
8 *
9 * Permission is hereby granted, free of charge, to any person obtaining a copy
10 * of this software and associated documentation files (the "Software"), to deal
11 * in the Software without restriction, including without limitation the rights
12 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 * copies of the Software, and to permit persons to whom the Software is
14 * furnished to do so, subject to the following conditions:
15 *
16 * The above copyright notice and this permission notice shall be included in
17 * all copies or substantial portions of the Software.
18 *
19 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25 * THE SOFTWARE.
26 */
27
28import { describe, it, expect, beforeEach, afterEach } from "vitest"
29import { morph } from "../src/morphlex"
30
31describe("Morphdom-style fixture tests", () => {
32 let container: HTMLElement
33
34 beforeEach(() => {
35 container = document.createElement("div")
36 document.body.appendChild(container)
37 })
38
39 afterEach(() => {
40 if (container && container.parentNode) {
41 container.parentNode.removeChild(container)
42 }
43 })
44
45 function parseHTML(html: string): HTMLElement {
46 const tmp = document.createElement("div")
47 tmp.innerHTML = html.trim()
48 return tmp.firstChild as HTMLElement
49 }
50
51 describe("simple morphing", () => {
52 it("should add new element before existing element", () => {
53 const from = parseHTML("<div><b>bold</b></div>")
54 const to = parseHTML("<div><i>italics</i><b>bold</b></div>")
55
56 morph(from, to)
57
58 expect(from.innerHTML).toBe("<i>italics</i><b>bold</b>")
59 })
60
61 it("should handle equal elements", () => {
62 const from = parseHTML("<div>test</div>")
63 const to = parseHTML("<div>test</div>")
64
65 morph(from, to)
66
67 expect(from.innerHTML).toBe("test")
68 })
69
70 it("should shorten list of children", () => {
71 const from = parseHTML("<div><span>1</span><span>2</span><span>3</span></div>")
72 const to = parseHTML("<div><span>1</span></div>")
73
74 morph(from, to)
75
76 expect(from.children.length).toBe(1)
77 expect(from.innerHTML).toBe("<span>1</span>")
78 })
79
80 it("should lengthen list of children", () => {
81 const from = parseHTML("<div><span>1</span></div>")
82 const to = parseHTML("<div><span>1</span><span>2</span><span>3</span></div>")
83
84 morph(from, to)
85
86 expect(from.children.length).toBe(3)
87 expect(from.innerHTML).toBe("<span>1</span><span>2</span><span>3</span>")
88 })
89
90 it("should reverse children", () => {
91 const from = parseHTML("<div><span>a</span><span>b</span><span>c</span></div>")
92 const to = parseHTML("<div><span>c</span><span>b</span><span>a</span></div>")
93
94 morph(from, to)
95
96 expect(from.innerHTML).toBe("<span>c</span><span>b</span><span>a</span>")
97 })
98 })
99
100 describe("attribute handling", () => {
101 it("should handle empty string attribute values", () => {
102 const from = parseHTML('<div class="foo"></div>')
103 const to = parseHTML('<div class=""></div>')
104
105 morph(from, to)
106
107 expect(from.getAttribute("class")).toBe("")
108 })
109 })
110
111 describe("input elements", () => {
112 it("should morph input element", () => {
113 const from = parseHTML('<input type="text" value="Hello">')
114 const to = parseHTML('<input type="text" value="World">')
115
116 morph(from, to)
117
118 // Input values are updated by default when not modified
119 expect((from as HTMLInputElement).value).toBe("World")
120 })
121
122 it("should add disabled attribute to input", () => {
123 const from = parseHTML('<input type="text" value="Hello World">')
124 const to = parseHTML('<input type="text" value="Hello World" disabled>')
125
126 morph(from, to)
127
128 expect((from as HTMLInputElement).disabled).toBe(true)
129 })
130
131 it("should remove disabled attribute from input", () => {
132 const from = parseHTML('<input type="text" value="Hello World" disabled>')
133 const to = parseHTML('<input type="text" value="Hello World">')
134
135 morph(from, to)
136
137 expect((from as HTMLInputElement).disabled).toBe(false)
138 })
139 })
140
141 describe("select elements", () => {
142 it("should handle select element with selected option", () => {
143 const from = parseHTML(`
144 <select>
145 <option value="1">One</option>
146 <option value="2" selected>Two</option>
147 <option value="3">Three</option>
148 </select>
149 `)
150 const to = parseHTML(`
151 <select>
152 <option value="1" selected>One</option>
153 <option value="2">Two</option>
154 <option value="3">Three</option>
155 </select>
156 `)
157
158 morph(from, to)
159
160 // Selected attribute is removed but not added - select defaults to first option
161 const select = from as HTMLSelectElement
162 expect(select.value).toBe("1")
163 expect(select.options[0].selected).toBe(true)
164 expect(select.options[1].selected).toBe(false)
165 })
166
167 it("should handle select element with default selection", () => {
168 const from = parseHTML(`
169 <select>
170 <option value="1">One</option>
171 <option value="2">Two</option>
172 <option value="3">Three</option>
173 </select>
174 `)
175 const to = parseHTML(`
176 <select>
177 <option value="1">One</option>
178 <option value="2" selected>Two</option>
179 <option value="3">Three</option>
180 </select>
181 `)
182
183 morph(from, to)
184
185 // Selected options are updated by default when not modified
186 const select = from as HTMLSelectElement
187 expect(select.value).toBe("2")
188 expect(select.options[1].selected).toBe(true)
189 })
190 })
191
192 describe("id-based morphing", () => {
193 it("should handle nested elements with IDs", () => {
194 const from = parseHTML(`
195 <div>
196 <div id="a">A</div>
197 <div id="b">B</div>
198 </div>
199 `)
200 const to = parseHTML(`
201 <div>
202 <div id="b">B Updated</div>
203 <div id="a">A Updated</div>
204 </div>
205 `)
206
207 const aEl = from.querySelector("#a")
208 const bEl = from.querySelector("#b")
209
210 morph(from, to)
211
212 // Elements with IDs should be preserved
213 expect(from.querySelector("#a")).toBe(aEl)
214 expect(from.querySelector("#b")).toBe(bEl)
215 expect(from.querySelector("#a")?.textContent).toBe("A Updated")
216 expect(from.querySelector("#b")?.textContent).toBe("B Updated")
217 })
218
219 it("should handle reversing elements with IDs", () => {
220 const from = parseHTML(`
221 <div>
222 <div id="a">a</div>
223 <div id="b">b</div>
224 <div id="c">c</div>
225 </div>
226 `)
227 const to = parseHTML(`
228 <div>
229 <div id="c">c</div>
230 <div id="b">b</div>
231 <div id="a">a</div>
232 </div>
233 `)
234
235 const aEl = from.querySelector("#a")
236 const bEl = from.querySelector("#b")
237 const cEl = from.querySelector("#c")
238
239 morph(from, to)
240
241 expect(from.querySelector("#a")).toBe(aEl)
242 expect(from.querySelector("#b")).toBe(bEl)
243 expect(from.querySelector("#c")).toBe(cEl)
244 })
245
246 it("should handle prepending element with ID", () => {
247 const from = parseHTML(`
248 <div>
249 <div id="a">a</div>
250 <div id="b">b</div>
251 </div>
252 `)
253 const to = parseHTML(`
254 <div>
255 <div id="c">c</div>
256 <div id="a">a</div>
257 <div id="b">b</div>
258 </div>
259 `)
260
261 const aEl = from.querySelector("#a")
262 const bEl = from.querySelector("#b")
263
264 morph(from, to)
265
266 expect(from.querySelector("#a")).toBe(aEl)
267 expect(from.querySelector("#b")).toBe(bEl)
268 expect(from.children.length).toBe(3)
269 expect(from.children[0].id).toBe("c")
270 })
271
272 it("should handle changing tag name with ID preservation", () => {
273 const from = parseHTML(`
274 <div>
275 <div id="a">A</div>
276 </div>
277 `)
278 const to = parseHTML(`
279 <div>
280 <span id="a">A</span>
281 </div>
282 `)
283
284 morph(from, to)
285
286 expect(from.querySelector("#a")?.tagName).toBe("SPAN")
287 })
288 })
289
290 describe("tag name changes", () => {
291 it("should change tag name", () => {
292 const from = parseHTML("<div><b>Hello</b></div>")
293 const to = parseHTML("<div><i>Hello</i></div>")
294
295 morph(from, to)
296
297 expect(from.innerHTML).toBe("<i>Hello</i>")
298 })
299
300 it("should change tag name with IDs", () => {
301 const from = parseHTML(`
302 <div>
303 <div id="a">A</div>
304 <div id="b">B</div>
305 </div>
306 `)
307 const to = parseHTML(`
308 <div>
309 <span id="a">A</span>
310 <span id="b">B</span>
311 </div>
312 `)
313
314 morph(from, to)
315
316 expect(from.querySelector("#a")?.tagName).toBe("SPAN")
317 expect(from.querySelector("#b")?.tagName).toBe("SPAN")
318 })
319 })
320
321 describe("SVG elements", () => {
322 it("should handle SVG elements", () => {
323 const from = parseHTML(`
324 <svg>
325 <circle cx="50" cy="50" r="40"></circle>
326 </svg>
327 `)
328 const to = parseHTML(`
329 <svg>
330 <circle cx="50" cy="50" r="40"></circle>
331 <rect x="10" y="10" width="30" height="30"></rect>
332 </svg>
333 `)
334
335 morph(from, to)
336
337 expect(from.children.length).toBe(2)
338 expect(from.children[0].tagName.toLowerCase()).toBe("circle")
339 expect(from.children[1].tagName.toLowerCase()).toBe("rect")
340 })
341
342 it("should append new SVG elements", () => {
343 const from = parseHTML('<svg><circle cx="10" cy="10" r="5"></circle></svg>')
344 const to = parseHTML(`
345 <svg>
346 <circle cx="10" cy="10" r="5"></circle>
347 <circle cx="20" cy="20" r="5"></circle>
348 </svg>
349 `)
350
351 morph(from, to)
352
353 expect(from.children.length).toBe(2)
354 })
355 })
356
357 describe("data table tests", () => {
358 it("should handle complex data table morphing", () => {
359 const from = parseHTML(`
360 <table>
361 <tbody>
362 <tr><td>A</td><td>B</td></tr>
363 <tr><td>C</td><td>D</td></tr>
364 </tbody>
365 </table>
366 `)
367 const to = parseHTML(`
368 <table>
369 <tbody>
370 <tr><td>A</td><td>B</td><td>E</td></tr>
371 <tr><td>C</td><td>D</td><td>F</td></tr>
372 </tbody>
373 </table>
374 `)
375
376 morph(from, to)
377
378 const rows = from.querySelectorAll("tr")
379 expect(rows.length).toBe(2)
380 expect(rows[0].children.length).toBe(3)
381 expect(rows[0].children[2].textContent).toBe("E")
382 expect(rows[1].children[2].textContent).toBe("F")
383 })
384
385 it("should handle data table with row modifications", () => {
386 const from = parseHTML(`
387 <table>
388 <tbody>
389 <tr><td>1</td></tr>
390 <tr><td>2</td></tr>
391 <tr><td>3</td></tr>
392 </tbody>
393 </table>
394 `)
395 const to = parseHTML(`
396 <table>
397 <tbody>
398 <tr><td>1</td></tr>
399 <tr><td>2 Updated</td></tr>
400 <tr><td>3</td></tr>
401 <tr><td>4</td></tr>
402 </tbody>
403 </table>
404 `)
405
406 morph(from, to)
407
408 const rows = from.querySelectorAll("tr")
409 expect(rows.length).toBe(4)
410 expect(rows[1].textContent).toBe("2 Updated")
411 expect(rows[3].textContent).toBe("4")
412 })
413 })
414
415 describe("nested id scenarios", () => {
416 it("should handle deeply nested IDs - scenario 2", () => {
417 const from = parseHTML(`
418 <div>
419 <div id="outer">
420 <div id="a">A</div>
421 <div id="b">B</div>
422 </div>
423 </div>
424 `)
425 const to = parseHTML(`
426 <div>
427 <div id="outer">
428 <div id="b">B</div>
429 <div id="a">A</div>
430 </div>
431 </div>
432 `)
433
434 const aEl = from.querySelector("#a")
435 const bEl = from.querySelector("#b")
436
437 morph(from, to)
438
439 expect(from.querySelector("#a")).toBe(aEl)
440 expect(from.querySelector("#b")).toBe(bEl)
441 })
442
443 it("should handle deeply nested IDs - scenario 3", () => {
444 const from = parseHTML(`
445 <div>
446 <div id="outer">
447 <div>
448 <div id="a">A</div>
449 <div id="b">B</div>
450 </div>
451 </div>
452 </div>
453 `)
454 const to = parseHTML(`
455 <div>
456 <div id="outer">
457 <div>
458 <div id="b">B</div>
459 <div id="a">A</div>
460 </div>
461 </div>
462 </div>
463 `)
464
465 const aEl = from.querySelector("#a")
466 const bEl = from.querySelector("#b")
467
468 morph(from, to)
469
470 expect(from.querySelector("#a")).toBe(aEl)
471 expect(from.querySelector("#b")).toBe(bEl)
472 })
473
474 it("should handle deeply nested IDs - scenario 4", () => {
475 const from = parseHTML(`
476 <div>
477 <div id="outer">
478 <div id="inner">
479 <div id="a">A</div>
480 <div id="b">B</div>
481 </div>
482 </div>
483 </div>
484 `)
485 const to = parseHTML(`
486 <div>
487 <div id="outer">
488 <div id="inner">
489 <div id="b">B</div>
490 <div id="a">A</div>
491 </div>
492 </div>
493 </div>
494 `)
495
496 const aEl = from.querySelector("#a")
497 const bEl = from.querySelector("#b")
498
499 morph(from, to)
500
501 expect(from.querySelector("#a")).toBe(aEl)
502 expect(from.querySelector("#b")).toBe(bEl)
503 })
504
505 it("should handle deeply nested IDs - scenario 5", () => {
506 const from = parseHTML(`
507 <div>
508 <div id="a">
509 <div id="b">B</div>
510 </div>
511 </div>
512 `)
513 const to = parseHTML(`
514 <div>
515 <div id="a">A</div>
516 <div id="b">B</div>
517 </div>
518 `)
519
520 morph(from, to)
521
522 expect(from.querySelector("#a")?.textContent?.trim()).toBe("A")
523 expect(from.querySelector("#b")?.textContent).toBe("B")
524 })
525
526 it("should handle deeply nested IDs - scenario 6", () => {
527 const from = parseHTML(`
528 <div>
529 <div id="a">A</div>
530 <div id="b">B</div>
531 </div>
532 `)
533 const to = parseHTML(`
534 <div>
535 <div id="a">
536 <div id="b">B</div>
537 </div>
538 </div>
539 `)
540
541 morph(from, to)
542
543 expect(from.querySelector("#a #b")?.textContent).toBe("B")
544 })
545
546 it("should handle deeply nested IDs - scenario 7", () => {
547 const from = parseHTML(`
548 <div>
549 <div id="a">
550 <div id="b">
551 <div id="c">C</div>
552 </div>
553 </div>
554 </div>
555 `)
556 const to = parseHTML(`
557 <div>
558 <div id="a">A</div>
559 <div id="b">B</div>
560 <div id="c">C</div>
561 </div>
562 `)
563
564 morph(from, to)
565
566 expect(from.children.length).toBe(3)
567 expect(from.querySelector("#a")?.textContent?.trim()).toBe("A")
568 expect(from.querySelector("#b")?.textContent?.trim()).toBe("B")
569 expect(from.querySelector("#c")?.textContent).toBe("C")
570 })
571 })
572
573 describe("large document morphing", () => {
574 it("should handle large DOM trees efficiently", () => {
575 let fromHTML = "<div>"
576 let toHTML = "<div>"
577
578 for (let i = 0; i < 100; i++) {
579 fromHTML += `<div id="item-${i}">Item ${i}</div>`
580 toHTML += `<div id="item-${i}">Item ${i} Updated</div>`
581 }
582
583 fromHTML += "</div>"
584 toHTML += "</div>"
585
586 const from = parseHTML(fromHTML)
587 const to = parseHTML(toHTML)
588
589 const originalElements = Array.from(from.children).map((el) => el)
590
591 morph(from, to)
592
593 // All elements should be preserved
594 expect(from.children.length).toBe(100)
595 for (let i = 0; i < 100; i++) {
596 expect(from.children[i]).toBe(originalElements[i])
597 expect(from.children[i].textContent).toBe(`Item ${i} Updated`)
598 }
599 })
600 })
601})