Precise DOM morphing
morphing typescript dom
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Separate elements with IDs into their own matching pool

Elements with IDs can only match by their ID - they are semantically
distinct entities. Previously, if an element with an ID failed to match
in the ID matching phase, it could still incorrectly match in the
heuristics or tagName phases.

This change separates elements with IDs into a dedicated `idElements`
pool at build time, so they:

1. Skip the expensive `isEqualNode` check entirely
2. Only participate in ID matching
3. Get removed if no ID match is found

The idSet matching phase (for elements matched by descendant IDs) now
correctly iterates over `candidateElements` since elements with idSets
may not have IDs themselves.

This is both a correctness fix and a performance optimization.

+42 -28
+42 -28
src/morphlex.ts
··· 28 28 29 29 const candidateNodes: Set<number> = new Set() 30 30 const candidateElements: Set<number> = new Set() 31 + const candidateElementsWithIds: Set<number> = new Set() 31 32 const unmatchedNodes: Set<number> = new Set() 32 33 const unmatchedElements: Set<number> = new Set() 33 34 const whitespaceNodes: Set<number> = new Set() ··· 424 425 425 426 candidateNodes.clear() 426 427 candidateElements.clear() 428 + candidateElementsWithIds.clear() 427 429 unmatchedNodes.clear() 428 430 unmatchedElements.clear() 429 431 whitespaceNodes.clear() ··· 443 445 444 446 if (nodeType === ELEMENT_NODE_TYPE) { 445 447 candidateLocalNameMap[i] = (candidate as Element).localName 446 - candidateElements.add(i) 448 + if ((candidate as Element).id !== "") { 449 + candidateElementsWithIds.add(i) 450 + } else { 451 + candidateElements.add(i) 452 + } 447 453 } else if (nodeType === TEXT_NODE_TYPE && candidate.textContent?.trim() === "") { 448 454 whitespaceNodes.add(i) 449 455 } else { ··· 486 492 } 487 493 } 488 494 489 - // Match by exact id or idSet 495 + // Match by exact id 490 496 for (const unmatchedIndex of unmatchedElements) { 491 497 const element = toChildNodes[unmatchedIndex] as Element 498 + const id = element.id 492 499 493 - const id = element.id 500 + if (id === "") continue 501 + 502 + for (const candidateIndex of candidateElementsWithIds) { 503 + const candidate = fromChildNodes[candidateIndex] as Element 504 + 505 + if (localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex] && id === candidate.id) { 506 + matches[unmatchedIndex] = candidateIndex 507 + op[unmatchedIndex] = Operation.SameElement 508 + seq[candidateIndex] = unmatchedIndex 509 + candidateElementsWithIds.delete(candidateIndex) 510 + unmatchedElements.delete(unmatchedIndex) 511 + break 512 + } 513 + } 514 + } 515 + 516 + // Match by idArray (to) against idSet (from) 517 + // Elements with idSets may not have IDs themselves, so we check candidateElements 518 + for (const unmatchedIndex of unmatchedElements) { 519 + const element = toChildNodes[unmatchedIndex] as Element 494 520 const idArray = this.#idArrayMap.get(element) 495 521 496 - if (id === "" && !idArray) continue 522 + if (!idArray) continue 497 523 498 524 candidateLoop: for (const candidateIndex of candidateElements) { 499 525 const candidate = fromChildNodes[candidateIndex] as Element 500 526 501 527 if (localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex]) { 502 - // Match by exact id 503 - if (id !== "" && id === candidate.id) { 504 - matches[unmatchedIndex] = candidateIndex 505 - op[unmatchedIndex] = Operation.SameElement 506 - seq[candidateIndex] = unmatchedIndex 507 - candidateElements.delete(candidateIndex) 508 - unmatchedElements.delete(unmatchedIndex) 509 - break candidateLoop 510 - } 511 - 512 - // Match by idArray (to) against idSet (from) 513 - if (idArray) { 514 - const candidateIdSet = this.#idSetMap.get(candidate) 515 - if (candidateIdSet) { 516 - for (let i = 0; i < idArray.length; i++) { 517 - const arrayId = idArray[i]! 518 - if (candidateIdSet.has(arrayId)) { 519 - matches[unmatchedIndex] = candidateIndex 520 - op[unmatchedIndex] = Operation.SameElement 521 - seq[candidateIndex] = unmatchedIndex 522 - candidateElements.delete(candidateIndex) 523 - unmatchedElements.delete(unmatchedIndex) 524 - break candidateLoop 525 - } 528 + const candidateIdSet = this.#idSetMap.get(candidate) 529 + if (candidateIdSet) { 530 + for (let i = 0; i < idArray.length; i++) { 531 + const arrayId = idArray[i]! 532 + if (candidateIdSet.has(arrayId)) { 533 + matches[unmatchedIndex] = candidateIndex 534 + op[unmatchedIndex] = Operation.SameElement 535 + seq[candidateIndex] = unmatchedIndex 536 + candidateElements.delete(candidateIndex) 537 + unmatchedElements.delete(unmatchedIndex) 538 + break candidateLoop 526 539 } 527 540 } 528 541 } ··· 618 631 for (const i of candidateNodes) this.#removeNode(fromChildNodes[i]!) 619 632 for (const i of whitespaceNodes) this.#removeNode(fromChildNodes[i]!) 620 633 for (const i of candidateElements) this.#removeNode(fromChildNodes[i]!) 634 + for (const i of candidateElementsWithIds) this.#removeNode(fromChildNodes[i]!) 621 635 622 636 // Find LIS - these nodes don't need to move 623 637 // matches already contains the fromChildNodes indices, so we can use it directly