Precise DOM morphing
morphing typescript dom
at main 389 lines 9.9 kB view raw
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})