Precise DOM morphing
morphing
typescript
dom
1import { Window } from "happy-dom"
2import type { Options } from "../src/morphlex"
3
4type BenchmarkCase = {
5 name: string
6 from: string
7 to: string
8 iterations?: number
9 weight?: number
10 options?: Options
11 setup?: (from: ChildNode, to: ChildNode) => void
12}
13
14type BenchmarkResult = {
15 name: string
16 iterations: number
17 mean: number
18 median: number
19 p95: number
20 min: number
21 max: number
22 opsPerSecond: number
23 total: number
24}
25
26type CliOptions = {
27 iterations: number
28 warmup: number
29 thorough: boolean
30 json: boolean
31 repeats: number
32}
33
34type BenchmarkSummary = {
35 weightedMedianMs: number
36 weightedP95Ms: number
37 totalMeasuredMs: number
38 trimmedMeanMs: number
39}
40
41const DEFAULT_ITERATIONS = 1500
42const DEFAULT_WARMUP = 250
43
44const window = new Window({
45 url: "http://localhost",
46})
47
48Object.assign(window, {
49 SyntaxError,
50})
51
52const globals: Record<string, unknown> = {
53 window,
54 document: window.document,
55 Node: window.Node,
56 Element: window.Element,
57 NodeList: window.NodeList,
58 DOMParser: window.DOMParser,
59 DocumentFragment: window.DocumentFragment,
60 HTMLInputElement: window.HTMLInputElement,
61 HTMLOptionElement: window.HTMLOptionElement,
62 HTMLTextAreaElement: window.HTMLTextAreaElement,
63 performance: window.performance,
64}
65
66for (const [key, value] of Object.entries(globals)) {
67 Object.assign(globalThis, { [key]: value })
68}
69
70const { morph } = await import("../src/morphlex")
71
72const noopOptions: Options = {
73 beforeNodeVisited: () => true,
74 afterNodeVisited: () => {},
75 beforeNodeAdded: () => true,
76 afterNodeAdded: () => {},
77 beforeNodeRemoved: () => true,
78 afterNodeRemoved: () => {},
79 beforeAttributeUpdated: () => true,
80 afterAttributeUpdated: () => {},
81 beforeChildrenVisited: () => true,
82 afterChildrenVisited: () => {},
83}
84
85function buildIdRelatedCards(count: number, reverseOrder: boolean, updatedText: boolean): string {
86 const indices = Array.from({ length: count }, (_, i) => i)
87 if (reverseOrder) indices.reverse()
88
89 return indices
90 .map((i) => {
91 const next = (i + 1) % count
92 return `<article data-card="${i}"><h3 id="card-${i}">Card ${i}</h3><a href="#card-${next}" name="card-link-${i}">Next</a><p>${updatedText ? `Card ${i} updated` : `Card ${i}`}</p></article>`
93 })
94 .join("")
95}
96
97function buildPartiallyReorderedList(count: number, shift: number): string {
98 const indices = Array.from({ length: count }, (_, i) => i)
99 const rotated = indices.slice(shift).concat(indices.slice(0, shift))
100 return rotated.map((i) => `<li id="row-${i}">Row ${i}</li>`).join("")
101}
102
103function buildDeepNestedIdTrees(count: number, depth: number, reverseOrder: boolean, updatedText: boolean): string {
104 const indices = Array.from({ length: count }, (_, i) => i)
105 if (reverseOrder) indices.reverse()
106
107 return indices
108 .map((i) => {
109 const next = (i + 1) % count
110 let nested = `<span id="deep-id-${i}">Node ${i}${updatedText ? " updated" : ""}</span><a href="#deep-id-${next}">Next</a>`
111 for (let d = 0; d < depth; d++) {
112 nested = `<div data-depth="${d}" data-key="${i}-${d}">${nested}</div>`
113 }
114 return `<article data-chain="${i}">${nested}</article>`
115 })
116 .join("")
117}
118
119const benchmarkCases: Array<BenchmarkCase> = [
120 {
121 name: "text-update",
122 from: "<div>Hello world</div>",
123 to: "<div>Goodbye world</div>",
124 weight: 1,
125 },
126 {
127 name: "attribute-churn",
128 from: '<div class="a" data-mode="old" aria-hidden="true">Content</div>',
129 to: '<div class="b" data-mode="new" title="fresh">Content</div>',
130 weight: 1,
131 },
132 {
133 name: "append-children",
134 from: "<ul><li>One</li></ul>",
135 to: "<ul><li>One</li><li>Two</li><li>Three</li><li>Four</li></ul>",
136 weight: 1,
137 },
138 {
139 name: "remove-children",
140 from: "<ul><li>One</li><li>Two</li><li>Three</li><li>Four</li></ul>",
141 to: "<ul><li>One</li></ul>",
142 weight: 1,
143 },
144 {
145 name: "reorder-with-ids-20",
146 from: `<ul>${Array.from({ length: 20 }, (_, i) => `<li id="item-${i}">Item ${i}</li>`).join("")}</ul>`,
147 to: `<ul>${Array.from({ length: 20 }, (_, i) => `<li id="item-${19 - i}">Item ${19 - i}</li>`).join("")}</ul>`,
148 weight: 2,
149 },
150 {
151 name: "large-list-update-200",
152 from: `<div>${Array.from({ length: 200 }, (_, i) => `<p id="row-${i}">Row ${i}</p>`).join("")}</div>`,
153 to: `<div>${Array.from({ length: 200 }, (_, i) => `<p id="row-${i}">Row ${i} updated</p>`).join("")}</div>`,
154 iterations: 400,
155 weight: 3,
156 },
157 {
158 name: "mixed-structure",
159 from: '<section><h1 id="title">Title</h1><p>Body</p><footer><a href="#">Link</a></footer></section>',
160 to: '<section><h1 id="title">Title 2</h1><p>Body updated</p><aside>Side</aside><footer><a href="/x">Link</a></footer></section>',
161 weight: 2,
162 },
163 {
164 name: "partial-reorder-with-ids-100",
165 from: `<ul>${buildPartiallyReorderedList(100, 0)}</ul>`,
166 to: `<ul>${buildPartiallyReorderedList(100, 30)}</ul>`,
167 iterations: 350,
168 weight: 3,
169 },
170 {
171 name: "idset-matching-related-cards-60",
172 from: `<section>${buildIdRelatedCards(60, false, false)}</section>`,
173 to: `<section>${buildIdRelatedCards(60, true, true)}</section>`,
174 iterations: 350,
175 weight: 3,
176 },
177 {
178 name: "deep-id-ancestry-40x8",
179 from: `<section>${buildDeepNestedIdTrees(40, 8, false, false)}</section>`,
180 to: `<section>${buildDeepNestedIdTrees(40, 8, true, true)}</section>`,
181 iterations: 300,
182 weight: 3,
183 },
184 {
185 name: "dirty-form-text-inputs-60",
186 from: `<form>${Array.from({ length: 60 }, (_, i) => `<input name="field-${i}" value="value-${i}">`).join("")}</form>`,
187 to: `<form>${Array.from({ length: 60 }, (_, i) => `<input name="field-${i}" value="new-${i}" data-next="1">`).join("")}</form>`,
188 iterations: 500,
189 weight: 1,
190 setup: (from) => {
191 const textInputs = (from as Element).querySelectorAll("input[name^='field-']")
192 for (let i = 0; i < textInputs.length; i++) {
193 const input = textInputs[i] as HTMLInputElement
194 input.value = `${input.value}-dirty`
195 }
196 },
197 },
198 {
199 name: "dirty-form-checkboxes-60",
200 from: `<form>${Array.from({ length: 60 }, (_, i) => `<input type="checkbox" name="check-${i}" ${i % 3 === 0 ? "checked" : ""}>`).join("")}</form>`,
201 to: `<form>${Array.from({ length: 60 }, (_, i) => `<input type="checkbox" name="check-${i}" ${i % 5 === 0 ? "checked" : ""}>`).join("")}</form>`,
202 iterations: 500,
203 weight: 1,
204 setup: (from) => {
205 const checkboxes = (from as Element).querySelectorAll("input[type='checkbox']")
206
207 for (let i = 0; i < checkboxes.length; i++) {
208 if (i % 4 === 0) {
209 const checkbox = checkboxes[i] as HTMLInputElement
210 checkbox.checked = !checkbox.checked
211 }
212 }
213 },
214 },
215 {
216 name: "hooks-mixed-structure",
217 from: '<section><h1 id="title">Title</h1><p>Body</p><footer><a href="#">Link</a></footer></section>',
218 to: '<section><h1 id="title">Title 2</h1><p>Body updated</p><aside>Side</aside><footer><a href="/x">Link</a></footer></section>',
219 options: noopOptions,
220 weight: 2,
221 },
222]
223
224function parseOptions(argv: Array<string>): CliOptions {
225 let iterations = DEFAULT_ITERATIONS
226 let warmup = DEFAULT_WARMUP
227 let thorough = false
228 let json = false
229 let repeats = 1
230
231 for (let i = 0; i < argv.length; i++) {
232 const arg = argv[i]
233 if (arg === "--thorough") {
234 thorough = true
235 continue
236 }
237
238 if (arg === "--json") {
239 json = true
240 continue
241 }
242
243 if (arg === "--iterations") {
244 const value = Number(argv[i + 1])
245 if (Number.isFinite(value) && value > 0) {
246 iterations = Math.floor(value)
247 i++
248 }
249 continue
250 }
251
252 if (arg === "--warmup") {
253 const value = Number(argv[i + 1])
254 if (Number.isFinite(value) && value >= 0) {
255 warmup = Math.floor(value)
256 i++
257 }
258 continue
259 }
260
261 if (arg === "--repeats") {
262 const value = Number(argv[i + 1])
263 if (Number.isFinite(value) && value > 0) {
264 repeats = Math.floor(value)
265 i++
266 }
267 }
268 }
269
270 if (thorough) {
271 iterations = Math.max(iterations, 5000)
272 warmup = Math.max(warmup, 1000)
273 }
274
275 return { iterations, warmup, thorough, json, repeats }
276}
277
278function median(numbers: Array<number>): number {
279 if (numbers.length === 0) return 0
280 const sorted = [...numbers].sort((a, b) => a - b)
281 return sorted[Math.floor(sorted.length / 2)] ?? 0
282}
283
284function createElement(html: string): ChildNode {
285 const template = document.createElement("template")
286 template.innerHTML = html
287 const node = template.content.firstChild
288 if (!node) throw new Error("Invalid benchmark fixture")
289 return node
290}
291
292function runCase(testCase: BenchmarkCase, options: CliOptions): BenchmarkResult {
293 const fromTemplate = createElement(testCase.from)
294 const toTemplate = createElement(testCase.to)
295 const iterations = testCase.iterations ?? options.iterations
296 const times: Array<number> = []
297 const morphOptions = testCase.options ?? {}
298
299 for (let i = 0; i < options.warmup; i++) {
300 const from = fromTemplate.cloneNode(true) as ChildNode
301 const to = toTemplate.cloneNode(true) as ChildNode
302 testCase.setup?.(from, to)
303 morph(from, to, morphOptions)
304 }
305
306 for (let i = 0; i < iterations; i++) {
307 const from = fromTemplate.cloneNode(true) as ChildNode
308 const to = toTemplate.cloneNode(true) as ChildNode
309 testCase.setup?.(from, to)
310 const start = performance.now()
311 morph(from, to, morphOptions)
312 const end = performance.now()
313 times.push(end - start)
314 }
315
316 times.sort((a, b) => a - b)
317 let total = 0
318 for (let i = 0; i < times.length; i++) total += times[i]!
319
320 const mean = total / times.length
321 const median = times[Math.floor(times.length / 2)] ?? 0
322 const p95 = times[Math.floor(times.length * 0.95)] ?? 0
323 const min = times[0] ?? 0
324 const max = times[times.length - 1] ?? 0
325 const opsPerSecond = mean > 0 ? 1000 / mean : 0
326
327 return {
328 name: testCase.name,
329 iterations,
330 mean,
331 median,
332 p95,
333 min,
334 max,
335 opsPerSecond,
336 total,
337 }
338}
339
340function summarizeResults(results: Array<BenchmarkResult>): BenchmarkSummary {
341 let totalWeight = 0
342 let weightedMedianMs = 0
343 let weightedP95Ms = 0
344 let totalMeasuredMs = 0
345 let weightedTrimmedMeanMs = 0
346
347 for (let i = 0; i < results.length; i++) {
348 const result = results[i]!
349 const testCase = benchmarkCases[i]!
350 const weight = testCase.weight ?? 1
351 const trimmedMean = Math.min(result.p95, result.mean)
352
353 totalWeight += weight
354 weightedMedianMs += result.median * weight
355 weightedP95Ms += result.p95 * weight
356 weightedTrimmedMeanMs += trimmedMean * weight
357 totalMeasuredMs += result.total
358 }
359
360 if (totalWeight === 0) {
361 return {
362 weightedMedianMs: 0,
363 weightedP95Ms: 0,
364 totalMeasuredMs,
365 trimmedMeanMs: 0,
366 }
367 }
368
369 return {
370 weightedMedianMs: weightedMedianMs / totalWeight,
371 weightedP95Ms: weightedP95Ms / totalWeight,
372 totalMeasuredMs,
373 trimmedMeanMs: weightedTrimmedMeanMs / totalWeight,
374 }
375}
376
377function aggregateResults(runs: Array<Array<BenchmarkResult>>): Array<BenchmarkResult> {
378 if (runs.length === 0) return []
379
380 const caseCount = runs[0]!.length
381 const aggregated: Array<BenchmarkResult> = []
382
383 for (let caseIndex = 0; caseIndex < caseCount; caseIndex++) {
384 const samples = runs.map((run) => run[caseIndex]!)
385 const first = samples[0]!
386
387 aggregated.push({
388 name: first.name,
389 iterations: first.iterations,
390 mean: median(samples.map((sample) => sample.mean)),
391 median: median(samples.map((sample) => sample.median)),
392 p95: median(samples.map((sample) => sample.p95)),
393 min: median(samples.map((sample) => sample.min)),
394 max: median(samples.map((sample) => sample.max)),
395 opsPerSecond: median(samples.map((sample) => sample.opsPerSecond)),
396 total: median(samples.map((sample) => sample.total)),
397 })
398 }
399
400 return aggregated
401}
402
403function printTable(results: Array<BenchmarkResult>, options: CliOptions): void {
404 const rows = results.map((result) => {
405 const testCase = benchmarkCases.find((test) => test.name === result.name)
406 return {
407 benchmark: result.name,
408 weight: String(testCase?.weight ?? 1),
409 iterations: String(result.iterations),
410 mean: `${result.mean.toFixed(4)}ms`,
411 median: `${result.median.toFixed(4)}ms`,
412 p95: `${result.p95.toFixed(4)}ms`,
413 trimmedMean: `${Math.min(result.p95, result.mean).toFixed(4)}ms`,
414 ops: result.opsPerSecond.toFixed(1),
415 }
416 })
417
418 const repeatLabel = options.repeats > 1 ? ` x${options.repeats} (median across runs)` : ""
419 console.log(`Morphlex benchmark${options.thorough ? " (thorough)" : ""}${repeatLabel}`)
420 console.table(rows)
421
422 const summary = summarizeResults(results)
423 console.log(`Weighted median: ${summary.weightedMedianMs.toFixed(4)}ms`)
424 console.log(`Weighted p95: ${summary.weightedP95Ms.toFixed(4)}ms`)
425 console.log(`Weighted trimmed mean: ${summary.trimmedMeanMs.toFixed(4)}ms`)
426 console.log(`Total measured time: ${summary.totalMeasuredMs.toFixed(2)}ms`)
427}
428
429function main(): void {
430 const options = parseOptions(process.argv.slice(2))
431 const runs: Array<Array<BenchmarkResult>> = []
432
433 for (let repeat = 0; repeat < options.repeats; repeat++) {
434 const runResults: Array<BenchmarkResult> = []
435
436 for (let i = 0; i < benchmarkCases.length; i++) {
437 runResults.push(runCase(benchmarkCases[i]!, options))
438 }
439
440 runs.push(runResults)
441 }
442
443 const results = aggregateResults(runs)
444
445 if (options.json) {
446 console.log(JSON.stringify({ options, summary: summarizeResults(results), results }, null, 2))
447 return
448 }
449
450 printTable(results, options)
451}
452
453main()