diff --git a/.changeset/new-plants-exercise.md b/.changeset/new-plants-exercise.md new file mode 100644 index 0000000000..c059c3b4fd --- /dev/null +++ b/.changeset/new-plants-exercise.md @@ -0,0 +1,5 @@ +--- +"rrdom": patch +--- + +Fix early termination of recursive loop in diffChilren when a node mismatch is found diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index 5d9ba7cea5..a4d3d57381 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -525,6 +525,24 @@ function diffChildren( let oldChild = oldTree.firstChild; let newChild = newTree.firstChild; while (oldChild !== null && newChild !== null) { + // For mismatched node types (e.g., Element vs Text), we create a new node and insert it before the old one. + // This preserves the oldChild reference needed for the recursive diff loop, which would break if we replaced the node directly. + const isMismatch = !sameNodeType(oldChild, newChild); + + if (isMismatch) { + const newNode = createOrGetNode(newChild, replayer.mirror, rrnodeMirror); + + try { + oldTree.insertBefore(newNode, oldChild); + } catch (e) { + console.warn(e); + } + + diff(newNode, newChild, replayer, rrnodeMirror); + newChild = newChild.nextSibling; + continue; + } + diff(oldChild, newChild, replayer, rrnodeMirror); oldChild = oldChild.nextSibling; newChild = newChild.nextSibling; diff --git a/packages/rrdom/test/diff.test.ts b/packages/rrdom/test/diff.test.ts index 0b13edb49c..07046ecaba 100644 --- a/packages/rrdom/test/diff.test.ts +++ b/packages/rrdom/test/diff.test.ts @@ -12,6 +12,7 @@ import { Mirror as RRNodeMirror, RRDocument, RRMediaElement, + RRElement, printRRDom, } from '../src'; import { @@ -1156,6 +1157,77 @@ describe('diff algorithm for rrdom', () => { expect(node.childNodes[0].childNodes.length).toEqual(1); expect(mirror.getId(node.childNodes[0].childNodes[0])).toEqual(2); }); + + it('should maintain correct order when duplicate node is found causing nextSibling traversal desync', () => { + const oldList = [ + { tagName: 'STYLE', id: 1 }, + { tagName: 'STYLE', id: 2 }, + { tagName: 'BASE', id: 3 }, + { tagName: 'META', id: 4 }, + { tagName: 'META', id: 5 }, + { tagName: 'META', id: 6 }, + { tagName: 'TITLE', id: 7 }, + { tagName: 'STYLE', id: 8 }, + { tagName: 'STYLE', id: 9 }, + { tagName: 'LINK', id: 10 }, + { tagName: 'STYLE', id: 11 }, + { tagName: 'LINK', id: 12 }, + { tagName: 'NOSCRIPT', id: 13 }, + ]; + + // Duplicate STYLE#1 at index 1 + const newList = [...oldList]; + newList.splice(1, 0, { tagName: 'STYLE', id: 1 }); + + const mirror = createMirror(); + const oldHead = createTree( + { + tagName: 'head', + id: 0, + children: oldList as unknown as ElementType[], + }, + undefined, + mirror, + ) as Node; + expect(oldHead.childNodes.length).toBe(oldList.length); + + const rrdom = new RRDocument(); + const newHead = createTree( + { + tagName: 'head', + id: 0, + children: newList as unknown as ElementType[], + }, + rrdom, + ) as RRNode; + + // Add test attributes to each node in newHead + Array.from(newHead.childNodes).forEach((node, index) => { + if (node instanceof RRElement) { + node.setAttribute('data-test-id', `${newList[index].id}`); + node.setAttribute('data-test-tag', node.tagName); + } + }); + + const replayer: ReplayerHandler = { + mirror, + applyCanvas: () => {}, + applyInput: () => {}, + applyScroll: () => {}, + applyStyleSheetMutation: () => {}, + }; + + // Run diff + diff(oldHead, newHead, replayer); + + // Extract final IDs from real DOM + const finalIds = Array.from(oldHead.childNodes) + .filter((n): n is Element => n.nodeType === Node.ELEMENT_NODE) + .map((el) => el.getAttribute('data-test-id') || el.getAttribute('id')); + + // Assert the real DOM now matches the RR DOM list + expect(finalIds).toEqual(oldList.map((n) => String(n.id))); + }); }); describe('diff shadow dom', () => {