Precise DOM morphing
morphing
typescript
dom
1/*
2 * These tests were inspired by idiomorph.
3 * Here's their license:
4 *
5 * Zero-Clause BSD
6 * =============
7 *
8 * Permission to use, copy, modify, and/or distribute this software for
9 * any purpose with or without fee is hereby granted.
10 *
11 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
12 * WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
13 * OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
14 * FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
15 * DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
16 * AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
17 * OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18 */
19
20import { describe, it, expect, beforeEach, afterEach } from "vitest"
21import { morph, morphInner } from "../src/morphlex"
22
23describe("Idiomorph-style tests", () => {
24 let container: HTMLElement
25
26 beforeEach(() => {
27 container = document.createElement("div")
28 document.body.appendChild(container)
29 })
30
31 afterEach(() => {
32 if (container && container.parentNode) {
33 container.parentNode.removeChild(container)
34 }
35 })
36
37 function parseHTML(html: string): HTMLElement {
38 const tmp = document.createElement("div")
39 tmp.innerHTML = html.trim()
40 return tmp.firstChild as HTMLElement
41 }
42
43 describe("basic morphing with different content types", () => {
44 it("should morph with single node", () => {
45 const initial = parseHTML("<button>Foo</button>")
46 container.appendChild(initial)
47
48 const final = document.createElement("button")
49 final.textContent = "Bar"
50
51 morph(initial, final)
52
53 expect(initial.textContent).toBe("Bar")
54 })
55
56 it("should morph with string", () => {
57 const initial = parseHTML("<button>Foo</button>")
58 container.appendChild(initial)
59
60 morph(initial, "<button>Bar</button>")
61
62 expect(initial.textContent).toBe("Bar")
63 })
64 })
65
66 describe("morphInner functionality", () => {
67 it("should morph innerHTML with string", () => {
68 const div = parseHTML("<div><span>Old</span></div>")
69 container.appendChild(div)
70
71 morphInner(div, "<div><span>New</span></div>")
72
73 expect(div.innerHTML).toBe("<span>New</span>")
74 })
75
76 it("should morph innerHTML with element", () => {
77 const div = parseHTML("<div><span>Old</span></div>")
78 container.appendChild(div)
79
80 const newDiv = document.createElement("div")
81 const newSpan = document.createElement("span")
82 newSpan.textContent = "New"
83 newDiv.appendChild(newSpan)
84
85 morphInner(div, newDiv)
86
87 expect(div.innerHTML).toBe("<span>New</span>")
88 })
89
90 it("should clear children when morphing to empty", () => {
91 const div = parseHTML("<div><span>Old</span></div>")
92 container.appendChild(div)
93
94 morphInner(div, "<div></div>")
95
96 expect(div.innerHTML).toBe("")
97 })
98
99 it("should add multiple children", () => {
100 const div = parseHTML("<div></div>")
101 container.appendChild(div)
102
103 morphInner(div, "<div><i>A</i><b>B</b></div>")
104
105 expect(div.innerHTML).toBe("<i>A</i><b>B</b>")
106 })
107 })
108
109 describe("special elements", () => {
110 it("should handle numeric IDs", () => {
111 const initial = parseHTML('<div id="123">Old</div>')
112 const final = parseHTML('<div id="123">New</div>')
113
114 morph(initial, final)
115
116 expect(initial.textContent).toBe("New")
117 })
118 })
119
120 describe("complex scenarios", () => {
121 it("should not build ID in new content parent into persistent id set", () => {
122 const initial = parseHTML('<div id="a"><div id="b">B</div></div>')
123 container.appendChild(initial)
124
125 const finalSrc = parseHTML('<div id="b">B Updated</div>')
126
127 morph(initial, finalSrc)
128
129 expect(initial.textContent).toBe("B Updated")
130 })
131
132 it("should handle soft match abortion on two future soft matches", () => {
133 const initial = parseHTML(`
134 <div>
135 <span>A</span>
136 <span>B</span>
137 <span>C</span>
138 </div>
139 `)
140 container.appendChild(initial)
141
142 const final = parseHTML(`
143 <div>
144 <span>X</span>
145 <span>B</span>
146 <span>C</span>
147 </div>
148 `)
149
150 morph(initial, final)
151
152 expect(initial.children[0].textContent).toBe("X")
153 expect(initial.children[1].textContent).toBe("B")
154 expect(initial.children[2].textContent).toBe("C")
155 })
156 })
157
158 describe("edge cases", () => {
159 it("should preserve elements during complex morphing", () => {
160 const parent = parseHTML(`
161 <div>
162 <div id="outer">
163 <div id="inner">
164 <span id="a">A</span>
165 <span id="b">B</span>
166 <span id="c">C</span>
167 </div>
168 </div>
169 </div>
170 `)
171 container.appendChild(parent)
172
173 const aEl = parent.querySelector("#a")
174 const bEl = parent.querySelector("#b")
175 const cEl = parent.querySelector("#c")
176
177 const final = parseHTML(`
178 <div>
179 <div id="outer">
180 <div id="inner">
181 <span id="c">C Modified</span>
182 <span id="a">A Modified</span>
183 <span id="b">B Modified</span>
184 </div>
185 </div>
186 </div>
187 `)
188
189 morph(parent, final)
190
191 // Elements should be preserved
192 expect(parent.querySelector("#a")).toBe(aEl)
193 expect(parent.querySelector("#b")).toBe(bEl)
194 expect(parent.querySelector("#c")).toBe(cEl)
195
196 // Content should be updated
197 expect(aEl?.textContent).toBe("A Modified")
198 expect(bEl?.textContent).toBe("B Modified")
199 expect(cEl?.textContent).toBe("C Modified")
200 })
201
202 it("should handle deeply nested structure changes", () => {
203 const parent = parseHTML(`
204 <div>
205 <section id="sec1">
206 <article id="art1">
207 <p id="p1">Paragraph 1</p>
208 <p id="p2">Paragraph 2</p>
209 </article>
210 </section>
211 </div>
212 `)
213 container.appendChild(parent)
214
215 const final = parseHTML(`
216 <div>
217 <section id="sec1">
218 <article id="art1">
219 <p id="p2">Paragraph 2 Updated</p>
220 <p id="p1">Paragraph 1 Updated</p>
221 </article>
222 </section>
223 </div>
224 `)
225
226 morph(parent, final)
227
228 expect(parent.querySelector("#p1")?.textContent).toBe("Paragraph 1 Updated")
229 expect(parent.querySelector("#p2")?.textContent).toBe("Paragraph 2 Updated")
230 })
231
232 it("should handle attribute changes on nested elements", () => {
233 const parent = parseHTML(`
234 <div>
235 <button id="btn1" class="old">Click</button>
236 </div>
237 `)
238 container.appendChild(parent)
239
240 const final = parseHTML(`
241 <div>
242 <button id="btn1" class="new" disabled>Click</button>
243 </div>
244 `)
245
246 morph(parent, final)
247
248 const button = parent.querySelector("#btn1") as HTMLButtonElement
249 expect(button.className).toBe("new")
250 expect(button.disabled).toBe(true)
251 })
252
253 it("should handle mixed content morphing", () => {
254 const parent = parseHTML(`
255 <div>
256 Text node
257 <span>Span</span>
258 More text
259 </div>
260 `)
261 container.appendChild(parent)
262
263 const final = parseHTML(`
264 <div>
265 Updated text
266 <span>Updated span</span>
267 Final text
268 </div>
269 `)
270
271 morph(parent, final)
272
273 expect(parent.textContent?.replace(/\s+/g, " ").trim()).toBe("Updated text Updated span Final text")
274 })
275 })
276
277 describe("id preservation", () => {
278 it("should preserve elements with matching IDs across different positions", () => {
279 const parent = parseHTML(`
280 <ul>
281 <li id="item-1">Item 1</li>
282 <li id="item-2">Item 2</li>
283 <li id="item-3">Item 3</li>
284 </ul>
285 `)
286 container.appendChild(parent)
287
288 const item1 = parent.querySelector("#item-1")
289 const item2 = parent.querySelector("#item-2")
290 const item3 = parent.querySelector("#item-3")
291
292 const final = parseHTML(`
293 <ul>
294 <li id="item-3">Item 3</li>
295 <li id="item-1">Item 1</li>
296 <li id="item-2">Item 2</li>
297 </ul>
298 `)
299
300 morph(parent, final)
301
302 expect(parent.querySelector("#item-1")).toBe(item1)
303 expect(parent.querySelector("#item-2")).toBe(item2)
304 expect(parent.querySelector("#item-3")).toBe(item3)
305 })
306
307 it("should handle ID changes correctly", () => {
308 const parent = parseHTML(`
309 <div>
310 <span id="old-id">Content</span>
311 </div>
312 `)
313 container.appendChild(parent)
314
315 const final = parseHTML(`
316 <div>
317 <span id="new-id">Content</span>
318 </div>
319 `)
320
321 morph(parent, final)
322
323 expect(parent.querySelector("#old-id")).toBeNull()
324 expect(parent.querySelector("#new-id")).toBeTruthy()
325 expect(parent.querySelector("#new-id")?.textContent).toBe("Content")
326 })
327 })
328
329 describe("performance scenarios", () => {
330 it("should handle large lists efficiently", () => {
331 let fromHTML = "<ul>"
332 for (let i = 0; i < 50; i++) {
333 fromHTML += `<li id="item-${i}">Item ${i}</li>`
334 }
335 fromHTML += "</ul>"
336
337 let toHTML = "<ul>"
338 for (let i = 0; i < 50; i++) {
339 toHTML += `<li id="item-${i}">Item ${i} Updated</li>`
340 }
341 toHTML += "</ul>"
342
343 const from = parseHTML(fromHTML)
344 const to = parseHTML(toHTML)
345 container.appendChild(from)
346
347 const originalElements = Array.from(from.children).map((el) => el)
348
349 morph(from, to)
350
351 // All elements should be preserved
352 expect(from.children.length).toBe(50)
353 for (let i = 0; i < 50; i++) {
354 expect(from.children[i]).toBe(originalElements[i])
355 expect(from.children[i].textContent).toBe(`Item ${i} Updated`)
356 }
357 })
358
359 it("should handle reordering large lists", () => {
360 let fromHTML = "<ul>"
361 for (let i = 0; i < 20; i++) {
362 fromHTML += `<li id="item-${i}">Item ${i}</li>`
363 }
364 fromHTML += "</ul>"
365
366 let toHTML = "<ul>"
367 for (let i = 19; i >= 0; i--) {
368 toHTML += `<li id="item-${i}">Item ${i}</li>`
369 }
370 toHTML += "</ul>"
371
372 const from = parseHTML(fromHTML)
373 const to = parseHTML(toHTML)
374 container.appendChild(from)
375
376 const elementMap = new Map()
377 for (let i = 0; i < 20; i++) {
378 elementMap.set(`item-${i}`, from.querySelector(`#item-${i}`))
379 }
380
381 morph(from, to)
382
383 // All elements should be preserved
384 for (let i = 0; i < 20; i++) {
385 expect(from.querySelector(`#item-${i}`)).toBe(elementMap.get(`item-${i}`))
386 }
387 })
388 })
389})