Precise DOM morphing
morphing typescript dom

Optimize active-element pinning checks

Precompute the active element ancestor chain so replacement and removal guards use O(1) membership checks while still protecting focused descendants during morphing.

+43 -2
+21 -2
src/morphlex.ts
··· 259 259 readonly #idArrayMap: IdArrayMap = new WeakMap() 260 260 readonly #idSetMap: IdSetMap = new WeakMap() 261 261 readonly #activeElement: Element | null 262 + readonly #activeElementAncestors: WeakSet<Node> | null 262 263 readonly #options: Options 263 264 264 265 constructor(options: Options = {}, activeElement: Element | null = null) { 265 266 this.#options = options 266 267 this.#activeElement = activeElement 268 + 269 + if (this.#options.preserveActiveElement && this.#activeElement) { 270 + const ancestors = new WeakSet<Node>() 271 + let current: Node | null = this.#activeElement 272 + 273 + while (current) { 274 + ancestors.add(current) 275 + current = current.parentNode 276 + } 277 + 278 + this.#activeElementAncestors = ancestors 279 + } else { 280 + this.#activeElementAncestors = null 281 + } 267 282 } 268 283 269 284 #isPinnedActiveElement(node: Node): boolean { 270 285 return !!this.#options.preserveActiveElement && node === this.#activeElement 286 + } 287 + 288 + #containsPinnedActiveElement(node: Node): boolean { 289 + return !!this.#activeElementAncestors?.has(node) 271 290 } 272 291 273 292 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { ··· 703 722 } 704 723 705 724 #replaceNode(node: ChildNode, newNode: ChildNode): void { 706 - if (this.#isPinnedActiveElement(node)) return 725 + if (this.#containsPinnedActiveElement(node)) return 707 726 708 727 const parent = node.parentNode || document 709 728 const insertionPoint = node ··· 720 739 } 721 740 722 741 #removeNode(node: ChildNode): void { 723 - if (this.#isPinnedActiveElement(node)) return 742 + if (this.#containsPinnedActiveElement(node)) return 724 743 725 744 if (this.#options.beforeNodeRemoved?.(node) ?? true) { 726 745 node.remove()
+22
test/new/active-element.browser.test.ts
··· 79 79 80 80 from.remove() 81 81 }) 82 + 83 + test("does not replace an ancestor that contains the active element", () => { 84 + const host = document.createElement("div") 85 + const from = document.createElement("div") 86 + from.innerHTML = '<input id="active" value="hello"><span>old</span>' 87 + host.appendChild(from) 88 + document.body.appendChild(host) 89 + 90 + const active = from.querySelector("#active") as HTMLInputElement 91 + active.focus() 92 + 93 + const to = document.createElement("section") 94 + to.innerHTML = '<input id="active" value="server"><span>new</span>' 95 + 96 + morph(from, to, { preserveActiveElement: true, preserveChanges: false }) 97 + 98 + expect(host.firstElementChild).toBe(from) 99 + expect(document.activeElement).toBe(active) 100 + expect(active.value).toBe("hello") 101 + 102 + host.remove() 103 + }) 82 104 })