diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index edb2b9cb9b..1b1f6b44fa 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1532,6 +1532,30 @@ export class Replayer { parent.removeChild(c as Node & RRNode); } } + } else if (parentSn?.type === NodeType.Document) { + /** + * Sometimes the document object is changed or reopened and the MutationObserver is disconnected, so the removal of child elements can't be detected and recorded. + * After the change of document, we may get another mutation which adds a new doctype or a HTML element, while the old one still exists in the dom. + * So, we need to remove the old one first to avoid collision. + */ + const parentDoc = parent as Document | RRDocument; + /** + * To detect the exist of the old doctype before adding a new doctype. + * We need to remove the old doctype before adding the new one. Otherwise, code will throw "mutation Failed to execute 'insertBefore' on 'Node': Only one doctype on document allowed". + */ + if ( + mutation.node.type === NodeType.DocumentType && + parentDoc.childNodes[0]?.nodeType === Node.DOCUMENT_TYPE_NODE + ) + parentDoc.removeChild(parentDoc.childNodes[0] as Node & RRNode); + /** + * To detect the exist of the old HTML element before adding a new HTML element. + * The reason is similar to the above. One document only allows exactly one DocType and one HTML Element. + */ + if (target.nodeName === 'HTML' && parentDoc.documentElement) + parentDoc.removeChild( + parentDoc.documentElement as HTMLElement & RRNode, + ); } if (previous && previous.nextSibling && previous.nextSibling.parentNode) { @@ -1546,15 +1570,6 @@ export class Replayer { ? (parent as TNode).insertBefore(target as TNode, next as TNode) : (parent as TNode).insertBefore(target as TNode, null); } else { - /** - * Sometimes the document changes and the MutationObserver is disconnected, so the removal of child elements can't be detected and recorded. After the change of document, we may get another mutation which adds a new html element, while the old html element still exists in the dom, and we need to remove the old html element first to avoid collision. - */ - if (parent === targetDoc) { - while (targetDoc.firstChild) { - (targetDoc as TNode).removeChild(targetDoc.firstChild as TNode); - } - } - (parent as TNode).appendChild(target as TNode); } /** diff --git a/packages/rrweb/test/events/document-replacement.ts b/packages/rrweb/test/events/document-replacement.ts new file mode 100644 index 0000000000..8b79e7dc9b --- /dev/null +++ b/packages/rrweb/test/events/document-replacement.ts @@ -0,0 +1,121 @@ +import { EventType, IncrementalSource } from '@rrweb/types'; +import type { eventWithTime } from '@rrweb/types'; + +const now = Date.now(); +const events: eventWithTime[] = [ + { + type: EventType.DomContentLoaded, + data: {}, + timestamp: now, + }, + { + type: EventType.Load, + data: {}, + timestamp: now + 100, + }, + { + type: EventType.Meta, + data: { + href: 'http://localhost', + width: 1200, + height: 500, + }, + timestamp: now + 100, + }, + // full snapshot: + { + data: { + node: { + id: 1, + type: 0, + childNodes: [ + { id: 2, name: 'html', type: 1, publicId: '', systemId: '' }, + { + id: 3, + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + id: 4, + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + }, + { + id: 5, + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [], + }, + ], + }, + ], + }, + initialOffset: { top: 0, left: 0 }, + }, + type: EventType.FullSnapshot, + timestamp: now + 100, + }, + // mutation that replace the old document + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + texts: [], + attributes: [], + removes: [], + adds: [ + { + parentId: 1, + nextId: null, + node: { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [], + id: 6, + }, + }, + { + parentId: 6, + nextId: null, + node: { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [], + id: 7, + }, + }, + { + parentId: 1, + nextId: 6, + node: { + type: 1, + name: 'html', + publicId: '', + systemId: '', + id: 8, + }, + }, + { + parentId: 6, + nextId: 7, + node: { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + id: 9, + }, + }, + ], + }, + timestamp: now + 500, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index 900f0eeb16..71b85956ea 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -20,6 +20,7 @@ import StyleSheetTextMutation from './events/style-sheet-text-mutation'; import canvasInIframe from './events/canvas-in-iframe'; import adoptedStyleSheet from './events/adopted-style-sheet'; import adoptedStyleSheetModification from './events/adopted-style-sheet-modification'; +import documentReplacementEvents from './events/document-replacement'; import { ReplayerEvents } from '@rrweb/types'; interface ISuite { @@ -991,4 +992,25 @@ describe('replayer', function () { await page.evaluate('replayer.pause(630);'); await check600ms(); }); + + it('should replay document replacement events without warnings or errors', async () => { + await page.evaluate( + `events = ${JSON.stringify(documentReplacementEvents)}`, + ); + const warningThrown = jest.fn(); + page.on('console', warningThrown); + const errorThrown = jest.fn(); + page.on('pageerror', errorThrown); + await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events); + replayer.play(500); + `); + await waitForRAF(page); + + // No warnings should be logged. + expect(warningThrown).not.toHaveBeenCalled(); + // No errors should be thrown. + expect(errorThrown).not.toHaveBeenCalled(); + }); });