Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: mutation Failed to execute 'insertBefore' on 'Node': Only one doctype on document allowed #1112

Merged
merged 2 commits into from
Feb 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions packages/rrweb/src/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,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 @@ -1553,15 +1577,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();
});
});