Skip to content

Commit

Permalink
fix: mutation Failed to execute 'insertBefore' on 'Node': Only one do…
Browse files Browse the repository at this point in the history
…ctype on document allowed (#1112)

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
  • Loading branch information
YunFeng0817 and Juice10 authored Feb 9, 2023
1 parent 0627d4d commit 174b9ac
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 9 deletions.
33 changes: 24 additions & 9 deletions packages/rrweb/src/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
/**
Expand Down
121 changes: 121 additions & 0 deletions packages/rrweb/test/events/document-replacement.ts
Original file line number Diff line number Diff line change
@@ -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;
22 changes: 22 additions & 0 deletions packages/rrweb/test/replayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
});
});

0 comments on commit 174b9ac

Please sign in to comment.