Precise DOM morphing
morphing typescript dom

Guard text node writes and add browser heatmap profiler

+247 -1
+244
benchmark/heatmap.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Morphlex Heatmap Profiling</title> 7 + <style> 8 + * { 9 + margin: 0; 10 + padding: 0; 11 + box-sizing: border-box; 12 + } 13 + 14 + body { 15 + font-family: 16 + system-ui, 17 + -apple-system, 18 + sans-serif; 19 + background: linear-gradient(135deg, #0f172a 0%, #1e293b 60%, #334155 100%); 20 + min-height: 100vh; 21 + padding: 2rem; 22 + color: #e2e8f0; 23 + } 24 + 25 + .container { 26 + max-width: 900px; 27 + margin: 0 auto; 28 + } 29 + 30 + h1 { 31 + font-size: 2rem; 32 + margin-bottom: 0.5rem; 33 + } 34 + 35 + .subtitle { 36 + color: #cbd5e1; 37 + margin-bottom: 1.5rem; 38 + } 39 + 40 + .card { 41 + background: rgba(15, 23, 42, 0.65); 42 + border: 1px solid rgba(148, 163, 184, 0.25); 43 + border-radius: 12px; 44 + padding: 1.25rem; 45 + margin-bottom: 1rem; 46 + } 47 + 48 + .controls { 49 + display: flex; 50 + gap: 0.75rem; 51 + flex-wrap: wrap; 52 + align-items: center; 53 + } 54 + 55 + label { 56 + display: flex; 57 + gap: 0.5rem; 58 + align-items: center; 59 + font-weight: 600; 60 + } 61 + 62 + select, 63 + input, 64 + button { 65 + padding: 0.5rem 0.75rem; 66 + border-radius: 8px; 67 + border: 1px solid #64748b; 68 + background: #0f172a; 69 + color: #e2e8f0; 70 + } 71 + 72 + button { 73 + cursor: pointer; 74 + font-weight: 600; 75 + background: #0369a1; 76 + border-color: #0284c7; 77 + } 78 + 79 + button:disabled { 80 + opacity: 0.6; 81 + cursor: not-allowed; 82 + } 83 + 84 + pre { 85 + white-space: pre-wrap; 86 + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; 87 + font-size: 0.85rem; 88 + line-height: 1.5; 89 + color: #93c5fd; 90 + } 91 + 92 + #sandbox { 93 + display: none; 94 + } 95 + </style> 96 + </head> 97 + <body> 98 + <div class="container"> 99 + <h1>Morphlex Heatmap Profiling</h1> 100 + <p class="subtitle"> 101 + Use this page with Chrome/Edge DevTools Performance to get a flame chart (heatmap) for known hotspot scenarios. 102 + </p> 103 + 104 + <div class="card"> 105 + <div class="controls"> 106 + <label> 107 + Scenario 108 + <select id="scenario"></select> 109 + </label> 110 + <label> 111 + Iterations 112 + <input id="iterations" type="number" value="300" min="10" step="10" /> 113 + </label> 114 + <button id="run">Run Profile Workload</button> 115 + </div> 116 + </div> 117 + 118 + <div class="card"> 119 + <pre id="status">Open DevTools -> Performance, press Record, then click "Run Profile Workload".</pre> 120 + </div> 121 + </div> 122 + 123 + <div id="sandbox"></div> 124 + 125 + <script type="module"> 126 + import { morph } from "../dist/morphlex.js" 127 + 128 + function buildIdRelatedCards(count, reverseOrder, updatedText) { 129 + const indices = Array.from({ length: count }, (_, i) => i) 130 + if (reverseOrder) indices.reverse() 131 + return indices 132 + .map((i) => { 133 + const next = (i + 1) % count 134 + 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>` 135 + }) 136 + .join("") 137 + } 138 + 139 + function buildDeepNestedIdTrees(count, depth, reverseOrder, updatedText) { 140 + const indices = Array.from({ length: count }, (_, i) => i) 141 + if (reverseOrder) indices.reverse() 142 + return indices 143 + .map((i) => { 144 + const next = (i + 1) % count 145 + let nested = `<span id="deep-id-${i}">Node ${i}${updatedText ? " updated" : ""}</span><a href="#deep-id-${next}">Next</a>` 146 + for (let d = 0; d < depth; d++) { 147 + nested = `<div data-depth="${d}" data-key="${i}-${d}">${nested}</div>` 148 + } 149 + return `<article data-chain="${i}">${nested}</article>` 150 + }) 151 + .join("") 152 + } 153 + 154 + const scenarios = [ 155 + { 156 + name: "idset-matching-related-cards-60", 157 + from: `<section>${buildIdRelatedCards(60, false, false)}</section>`, 158 + to: `<section>${buildIdRelatedCards(60, true, true)}</section>`, 159 + }, 160 + { 161 + name: "deep-id-ancestry-40x8", 162 + from: `<section>${buildDeepNestedIdTrees(40, 8, false, false)}</section>`, 163 + to: `<section>${buildDeepNestedIdTrees(40, 8, true, true)}</section>`, 164 + }, 165 + { 166 + name: "dirty-form-text-inputs-60", 167 + from: `<form>${Array.from({ length: 60 }, (_, i) => `<input name="field-${i}" value="value-${i}">`).join("")}</form>`, 168 + to: `<form>${Array.from({ length: 60 }, (_, i) => `<input name="field-${i}" value="new-${i}" data-next="1">`).join("")}</form>`, 169 + setup: (from) => { 170 + const textInputs = from.querySelectorAll("input[name^='field-']") 171 + for (let i = 0; i < textInputs.length; i++) { 172 + const input = textInputs[i] 173 + input.value = `${input.value}-dirty` 174 + } 175 + }, 176 + }, 177 + ] 178 + 179 + const scenarioSelect = document.getElementById("scenario") 180 + const iterationsInput = document.getElementById("iterations") 181 + const runButton = document.getElementById("run") 182 + const status = document.getElementById("status") 183 + const sandbox = document.getElementById("sandbox") 184 + 185 + for (let i = 0; i < scenarios.length; i++) { 186 + const option = document.createElement("option") 187 + option.value = String(i) 188 + option.textContent = scenarios[i].name 189 + scenarioSelect.appendChild(option) 190 + } 191 + 192 + function createElement(html) { 193 + const template = document.createElement("template") 194 + template.innerHTML = html 195 + return template.content.firstChild 196 + } 197 + 198 + async function runScenario(scenario, iterations) { 199 + const workload = new Array(iterations) 200 + sandbox.textContent = "" 201 + 202 + for (let i = 0; i < iterations; i++) { 203 + const from = createElement(scenario.from) 204 + const to = createElement(scenario.to) 205 + scenario.setup?.(from) 206 + sandbox.appendChild(from) 207 + workload[i] = { from, to } 208 + } 209 + 210 + performance.clearMarks() 211 + performance.clearMeasures() 212 + performance.mark("morphlex-profile-start") 213 + 214 + for (let i = 0; i < iterations; i++) { 215 + const entry = workload[i] 216 + morph(entry.from, entry.to) 217 + 218 + if (i % 50 === 0) { 219 + await new Promise((resolve) => setTimeout(resolve, 0)) 220 + } 221 + } 222 + 223 + performance.mark("morphlex-profile-end") 224 + performance.measure("morphlex-profile-workload", "morphlex-profile-start", "morphlex-profile-end") 225 + return performance.getEntriesByName("morphlex-profile-workload")[0]?.duration ?? 0 226 + } 227 + 228 + runButton.addEventListener("click", async () => { 229 + const scenarioIndex = Number(scenarioSelect.value) 230 + const iterations = Number(iterationsInput.value) 231 + if (!Number.isFinite(iterations) || iterations <= 0) return 232 + 233 + const scenario = scenarios[scenarioIndex] 234 + runButton.disabled = true 235 + status.textContent = `Running ${scenario.name}...` 236 + 237 + const totalDuration = await runScenario(scenario, iterations) 238 + 239 + status.textContent = `${scenario.name}\niterations: ${iterations}\nworkload duration: ${totalDuration.toFixed(2)}ms\n\nPrebuild and setup run before morph marks. In DevTools, select the range between morphlex-profile-start and morphlex-profile-end, then filter for \"visitChildNodes\", \"forEachDescendantElementWithId\", and \"isEqualNode\".` 240 + runButton.disabled = false 241 + }) 242 + </script> 243 + </body> 244 + </html>
+3 -1
src/morphlex.ts
··· 362 362 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 363 363 364 364 if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) { 365 - from.nodeValue = to.nodeValue 365 + if (from.nodeValue !== to.nodeValue) { 366 + from.nodeValue = to.nodeValue 367 + } 366 368 } else { 367 369 this.#replaceNode(from, to) 368 370 }