Precise DOM morphing
morphing typescript dom

Narrow active-element preservation semantics

Allow active elements and their ancestors to be moved or replaced while still skipping direct updates/replacement/removal of the active node itself when preserveActiveElement is enabled. Keep focused tests and docs aligned with the new behavior.

+35 -35
+1 -1
README.md
··· 59 59 60 60 - **`preserveChanges`**: When `true`, preserves modified form inputs during morphing. This prevents user-entered data from being overwritten. Default: `false` 61 61 62 - - **`preserveActiveElement`**: When `true`, preserves the current `document.activeElement` during morphing. This prevents focused elements from being moved, replaced or updated. Default: `false` 62 + - **`preserveActiveElement`**: When `true`, preserves the current `document.activeElement` during morphing. This prevents the focused element itself from being replaced or updated. Default: `false` 63 63 64 64 - **`beforeNodeVisited`**: Called before a node is visited during morphing. Return `false` to skip morphing this node. 65 65
+28 -29
src/morphlex.ts
··· 42 42 43 43 /** 44 44 * When `true`, preserves the active element during morphing. 45 - * This prevents focused elements from being moved, replaced or updated. 45 + * This prevents the focused element itself from being replaced or updated. 46 46 * @default false 47 47 */ 48 48 preserveActiveElement?: boolean ··· 252 252 readonly #idArrayMap: IdArrayMap = new WeakMap() 253 253 readonly #idSetMap: IdSetMap = new WeakMap() 254 254 readonly #activeElement: Element | null 255 - readonly #activeElementAncestors: WeakSet<Node> | null 256 255 readonly #options: Options 257 256 258 257 constructor(options: Options = {}, activeElement: Element | null = null) { 259 258 this.#options = options 260 259 this.#activeElement = activeElement 261 - 262 - if (this.#options.preserveActiveElement && this.#activeElement) { 263 - const ancestors = new WeakSet<Node>() 264 - let current: Node | null = this.#activeElement 265 - 266 - while (current) { 267 - ancestors.add(current) 268 - current = current.parentNode 269 - } 270 - 271 - this.#activeElementAncestors = ancestors 272 - } else { 273 - this.#activeElementAncestors = null 274 - } 275 260 } 276 261 277 262 #isPinnedActiveElement(node: Node): boolean { 278 263 return !!this.#options.preserveActiveElement && node === this.#activeElement 279 - } 280 - 281 - #containsPinnedActiveElement(node: Node): boolean { 282 - return !!this.#activeElementAncestors?.has(node) 283 264 } 284 265 285 266 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { ··· 488 469 candidateNodeTypeMap[i] = nodeType 489 470 490 471 if (nodeType === ELEMENT_NODE_TYPE) { 491 - candidateLocalNameMap[i] = (candidate as Element).localName 492 - if ((candidate as Element).id !== "") { 472 + const candidateElement = candidate as Element 473 + candidateLocalNameMap[i] = candidateElement.localName 474 + if (candidateElement.id !== "") { 493 475 candidateElementWithIdActive[i] = 1 494 476 candidateElementWithIdIndices.push(i) 495 477 } else { 496 478 candidateElementActive[i] = 1 497 479 candidateElementIndices.push(i) 498 480 } 499 - } else if (nodeType === TEXT_NODE_TYPE && candidate.textContent?.trim() === "") { 481 + } else if (isWhitespaceTextNode(candidate)) { 500 482 whitespaceNodeIndices.push(i) 501 483 } else { 502 484 candidateNodeActive[i] = 1 ··· 510 492 nodeTypeMap[i] = nodeType 511 493 512 494 if (nodeType === ELEMENT_NODE_TYPE) { 513 - localNameMap[i] = (node as Element).localName 495 + const element = node as Element 496 + localNameMap[i] = element.localName 514 497 unmatchedElementActive[i] = 1 515 498 unmatchedElementIndices.push(i) 516 - } else if (nodeType === TEXT_NODE_TYPE && node.textContent?.trim() === "") { 499 + } else if (isWhitespaceTextNode(node)) { 517 500 continue 518 501 } else { 519 502 unmatchedNodeActive[i] = 1 ··· 619 602 for (let c = 0; c < candidateElementIndices.length; c++) { 620 603 const candidateIndex = candidateElementIndices[c]! 621 604 if (!candidateElementActive[candidateIndex]) continue 622 - 623 605 const candidate = fromChildNodes[candidateIndex] as Element 606 + 624 607 if ( 625 608 localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex] && 626 609 ((name && name === candidate.getAttribute("name")) || ··· 746 729 const match = fromChildNodes[matchInd]! 747 730 const operation = op[i]! 748 731 749 - if (!shouldNotMove[matchInd] && !this.#isPinnedActiveElement(match)) { 732 + if (!shouldNotMove[matchInd]) { 750 733 moveBefore(parent, match, insertionPoint) 751 734 } 752 735 ··· 772 755 } 773 756 774 757 #replaceNode(node: ChildNode, newNode: ChildNode): void { 775 - if (this.#containsPinnedActiveElement(node)) return 758 + if (this.#isPinnedActiveElement(node)) return 776 759 777 760 const parent = node.parentNode || document 778 761 const insertionPoint = node ··· 789 772 } 790 773 791 774 #removeNode(node: ChildNode): void { 792 - if (this.#containsPinnedActiveElement(node)) return 775 + if (this.#isPinnedActiveElement(node)) return 793 776 794 777 if (this.#options.beforeNodeRemoved?.(node) ?? true) { 795 778 node.remove() ··· 863 846 array[i] = nodeList[i] as ChildNode 864 847 } 865 848 return array 849 + } 850 + 851 + function isWhitespaceTextNode(node: Node): boolean { 852 + if (node.nodeType !== TEXT_NODE_TYPE) return false 853 + 854 + const value = node.nodeValue 855 + if (!value) return true 856 + 857 + for (let i = 0; i < value.length; i++) { 858 + const code = value.charCodeAt(i) 859 + if (code === 32 || code === 9 || code === 10 || code === 13 || code === 12) continue 860 + if (code <= 127) return false 861 + return value.trim() === "" 862 + } 863 + 864 + return true 866 865 } 867 866 868 867 function isInputElement(element: Element): element is HTMLInputElement {
+6 -5
test/new/active-element.browser.test.ts
··· 61 61 from.remove() 62 62 }) 63 63 64 - test("does not move active element while reordering", () => { 64 + test("allows moving active element while reordering", () => { 65 65 const from = document.createElement("div") 66 66 from.innerHTML = '<input id="active"><p id="sibling">A</p>' 67 67 document.body.appendChild(from) ··· 75 75 morph(from, to, { preserveActiveElement: true }) 76 76 77 77 expect(from.querySelector("#active")).toBe(active) 78 + expect(from.firstElementChild?.id).toBe("sibling") 79 + expect(from.lastElementChild?.id).toBe("active") 78 80 expect(document.activeElement).toBe(active) 79 81 80 82 from.remove() 81 83 }) 82 84 83 - test("does not replace an ancestor that contains the active element", () => { 85 + test("allows replacing an ancestor that contains the active element", () => { 84 86 const host = document.createElement("div") 85 87 const from = document.createElement("div") 86 88 from.innerHTML = '<input id="active" value="hello"><span>old</span>' ··· 95 97 96 98 morph(from, to, { preserveActiveElement: true, preserveChanges: false }) 97 99 98 - expect(host.firstElementChild).toBe(from) 99 - expect(document.activeElement).toBe(active) 100 - expect(active.value).toBe("hello") 100 + expect(host.firstElementChild?.tagName).toBe("SECTION") 101 + expect((host.querySelector("#active") as HTMLInputElement).value).toBe("server") 101 102 102 103 host.remove() 103 104 })