diff --git a/packages/rum-recorder/src/boot/recorder.ts b/packages/rum-recorder/src/boot/recorder.ts index 83e6713e75..21dd2843cb 100644 --- a/packages/rum-recorder/src/boot/recorder.ts +++ b/packages/rum-recorder/src/boot/recorder.ts @@ -27,7 +27,6 @@ export function startRecording( const { stop: stopRecording, takeFullSnapshot } = record({ emit: addRawRecord, - useNewMutationObserver: configuration.isEnabled('new-mutation-observer'), }) lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, takeFullSnapshot) diff --git a/packages/rum-recorder/src/domain/rrweb-snapshot/index.ts b/packages/rum-recorder/src/domain/rrweb-snapshot/index.ts index 45d7f12154..7dff7a4794 100644 --- a/packages/rum-recorder/src/domain/rrweb-snapshot/index.ts +++ b/packages/rum-recorder/src/domain/rrweb-snapshot/index.ts @@ -1,5 +1,5 @@ -import { serializeNodeWithId, transformAttribute, snapshot } from './snapshot' +import { serializeNodeWithId, transformAttribute, serializeDocument } from './snapshot' export * from './types' export * from './serializationUtils' -export { snapshot, serializeNodeWithId, transformAttribute } +export { serializeDocument, serializeNodeWithId, transformAttribute } diff --git a/packages/rum-recorder/src/domain/rrweb-snapshot/snapshot.spec.ts b/packages/rum-recorder/src/domain/rrweb-snapshot/snapshot.spec.ts index 4a5b687192..7f350d8a74 100644 --- a/packages/rum-recorder/src/domain/rrweb-snapshot/snapshot.spec.ts +++ b/packages/rum-recorder/src/domain/rrweb-snapshot/snapshot.spec.ts @@ -80,7 +80,6 @@ describe('serializeNodeWithId', () => { describe('ignores some nodes', () => { const defaultOptions = { doc: document, - skipChild: false, map: {}, } diff --git a/packages/rum-recorder/src/domain/rrweb-snapshot/snapshot.ts b/packages/rum-recorder/src/domain/rrweb-snapshot/snapshot.ts index c38d0a7a08..ef30b74744 100644 --- a/packages/rum-recorder/src/domain/rrweb-snapshot/snapshot.ts +++ b/packages/rum-recorder/src/domain/rrweb-snapshot/snapshot.ts @@ -1,6 +1,6 @@ import { nodeShouldBeHidden } from '../privacy' import { PRIVACY_ATTR_NAME, PRIVACY_ATTR_VALUE_HIDDEN } from '../../constants' -import { SerializedNode, SerializedNodeWithId, NodeType, Attributes, INode, IdNodeMap } from './types' +import { SerializedNode, SerializedNodeWithId, NodeType, Attributes, IdNodeMap } from './types' import { getSerializedNodeId, hasSerializedNode, IGNORED_NODE_ID, setSerializedNode } from './serializationUtils' const tagNameRegex = /[^a-z1-6-_]/ @@ -10,11 +10,6 @@ function genId(): number { return nextId++ } -export function cleanupSnapshot() { - // allow a new recording to start numbering nodes from scratch - nextId = 1 -} - function getValidTagName(tagName: string): string { const processedTagName = tagName.toLowerCase().trim() @@ -358,15 +353,14 @@ function nodeShouldBeIgnored(sn: SerializedNode): boolean { } export function serializeNodeWithId( - n: Node | INode, + n: Node, options: { doc: Document map: IdNodeMap - skipChild: boolean preserveWhiteSpace?: boolean } ): SerializedNodeWithId | null { - const { doc, map, skipChild = false } = options + const { doc, map } = options let { preserveWhiteSpace = true } = options const serializedNode = serializeNode(n, { doc, @@ -398,10 +392,10 @@ export function serializeNodeWithId( if (id === IGNORED_NODE_ID) { return null } - map[id] = n as INode - let recordChild = !skipChild + map[id] = true + let recordChild = true if (serializedNode.type === NodeType.Element) { - recordChild = recordChild && !serializedNode.shouldBeHidden + recordChild = !serializedNode.shouldBeHidden // this property was not needed in replay side delete serializedNode.shouldBeHidden } @@ -417,7 +411,6 @@ export function serializeNodeWithId( const serializedChildNode = serializeNodeWithId(childN, { doc, map, - skipChild, preserveWhiteSpace, }) if (serializedChildNode) { @@ -428,14 +421,10 @@ export function serializeNodeWithId( return serializedNodeWithId } -export function snapshot(n: Document): [SerializedNodeWithId | null, IdNodeMap] { - const idNodeMap: IdNodeMap = {} - return [ - serializeNodeWithId(n, { - doc: n, - map: idNodeMap, - skipChild: false, - }), - idNodeMap, - ] +export function serializeDocument(n: Document): SerializedNodeWithId { + // We are sure that Documents are never ignored, so this function never returns null + return serializeNodeWithId(n, { + doc: n, + map: {}, + })! } diff --git a/packages/rum-recorder/src/domain/rrweb-snapshot/types.ts b/packages/rum-recorder/src/domain/rrweb-snapshot/types.ts index aa1a62c889..b66336f8da 100644 --- a/packages/rum-recorder/src/domain/rrweb-snapshot/types.ts +++ b/packages/rum-recorder/src/domain/rrweb-snapshot/types.ts @@ -51,10 +51,6 @@ export type SerializedNode = DocumentNode | DocumentTypeNode | ElementNode | Tex export type SerializedNodeWithId = SerializedNode & { id: number } -export interface INode extends Node { - __sn: SerializedNodeWithId -} - export type IdNodeMap = { - [key: number]: INode + [key: number]: true } diff --git a/packages/rum-recorder/src/domain/rrweb/mutation.spec.ts b/packages/rum-recorder/src/domain/rrweb/mutation.spec.ts deleted file mode 100644 index ddd3bc4a29..0000000000 --- a/packages/rum-recorder/src/domain/rrweb/mutation.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { isIE } from '../../../../core/test/specHelper' -import { createMutationPayloadValidator } from '../../../test/utils' -import { snapshot, NodeType } from '../rrweb-snapshot' -import { MutationObserverWrapper, MutationController } from './mutation' -import { MutationCallBack } from './types' - -describe('MutationObserverWrapper', () => { - let sandbox: HTMLElement - let mutationCallbackSpy: jasmine.Spy - let mutationController: MutationController - let mutationObserverWrapper: MutationObserverWrapper - - beforeEach(() => { - if (isIE()) { - pending('IE not supported') - } - - sandbox = document.createElement('div') - sandbox.id = 'sandbox' - document.body.appendChild(sandbox) - - mutationCallbackSpy = jasmine.createSpy() - mutationController = new MutationController() - - mutationObserverWrapper = new MutationObserverWrapper(mutationController, mutationCallbackSpy) - }) - - afterEach(() => { - mutationObserverWrapper.stop() - sandbox.remove() - }) - - it('generates a mutation when a node is appended to a known node', () => { - const serializedDocument = snapshot(document)[0]! - - sandbox.appendChild(document.createElement('div')) - - mutationController.flush() - - expect(mutationCallbackSpy).toHaveBeenCalledTimes(1) - - const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument) - validate(mutationCallbackSpy.calls.mostRecent().args[0], { - adds: [ - { - parent: expectInitialNode({ idAttribute: 'sandbox' }), - node: expectNewNode({ type: NodeType.Element, tagName: 'div' }), - }, - ], - }) - }) - - it('does not generate a mutation when a node is appended to a unknown node', () => { - sandbox.appendChild(document.createElement('div')) - - mutationController.flush() - - expect(mutationCallbackSpy).not.toHaveBeenCalled() - }) - - it('emits buffered mutation records on flush', () => { - snapshot(document) - - sandbox.appendChild(document.createElement('div')) - - expect(mutationCallbackSpy).toHaveBeenCalledTimes(0) - - mutationController.flush() - - expect(mutationCallbackSpy).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/rum-recorder/src/domain/rrweb/mutation.ts b/packages/rum-recorder/src/domain/rrweb/mutation.ts deleted file mode 100644 index 4a497c4ecf..0000000000 --- a/packages/rum-recorder/src/domain/rrweb/mutation.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { monitor } from '@datadog/browser-core' -import { - hasSerializedNode, - IGNORED_NODE_ID, - INode, - nodeIsIgnored, - serializeNodeWithId, - transformAttribute, -} from '../rrweb-snapshot' -import { nodeOrAncestorsShouldBeHidden } from '../privacy' -import { - AddedNodeMutation, - AttributeCursor, - MutationCallBack, - MutationRecord, - RemovedNodeMutation, - TextCursor, -} from './types' -import { forEach, isAncestorRemoved, mirror } from './utils' - -interface DoubleLinkedListNode { - previous: DoubleLinkedListNode | null - next: DoubleLinkedListNode | null - value: NodeInLinkedList -} -export type NodeInLinkedList = Node & { - __ln: DoubleLinkedListNode -} - -function isNodeInLinkedList(n: Node | NodeInLinkedList): n is NodeInLinkedList { - return '__ln' in n -} -class DoubleLinkedList { - public length = 0 - public head: DoubleLinkedListNode | null = null - - public get(position: number) { - if (position >= this.length) { - throw new Error('Position outside of list range') - } - - let current = this.head - for (let index = 0; index < position; index += 1) { - current = current?.next || null - } - return current - } - - public addNode(n: Node) { - const node: DoubleLinkedListNode = { - next: null, - previous: null, - value: n as NodeInLinkedList, - } - ;(n as NodeInLinkedList).__ln = node - if (n.previousSibling && isNodeInLinkedList(n.previousSibling)) { - const current = n.previousSibling.__ln.next - node.next = current - node.previous = n.previousSibling.__ln - n.previousSibling.__ln.next = node - if (current) { - current.previous = node - } - } else if (n.nextSibling && isNodeInLinkedList(n.nextSibling)) { - const current = n.nextSibling.__ln.previous - node.previous = current - node.next = n.nextSibling.__ln - n.nextSibling.__ln.previous = node - if (current) { - current.next = node - } - } else { - if (this.head) { - this.head.previous = node - } - node.next = this.head - this.head = node - } - this.length += 1 - } - - public removeNode(n: NodeInLinkedList) { - const current = n.__ln - if (!this.head) { - return - } - - if (!current.previous) { - this.head = current.next - if (this.head) { - this.head.previous = null - } - } else { - current.previous.next = current.next - if (current.next) { - current.next.previous = current.previous - } - } - if (n.__ln) { - delete (n as any).__ln - } - this.length -= 1 - } -} - -const moveKey = (id: number, parentId: number) => `${id}@${parentId}` -function isINode(n: Node | INode): n is INode { - return '__sn' in n -} - -/** - * Controls how mutations are processed, allowing to flush pending mutations. - */ -export class MutationController { - private flushListener?: () => void - - public flush() { - this.flushListener?.() - } - - public onFlush(listener: () => void) { - this.flushListener = listener - } -} - -/** - * Buffers and aggregate mutations generated by a MutationObserver into MutationCallbackParam - */ -export class MutationObserverWrapper { - private observer: MutationObserver - private texts: TextCursor[] = [] - private attributes: AttributeCursor[] = [] - private removes: RemovedNodeMutation[] = [] - private mapRemoves: Node[] = [] - - private movedMap: Record = {} - - /** - * the browser MutationObserver emits multiple mutations after - * a delay for performance reasons, making tracing added nodes hard - * in our `processMutations` callback function. - * For example, if we append an element el_1 into body, and then append - * another element el_2 into el_1, these two mutations may be passed to the - * callback function together when the two operations were done. - * Generally we need to trace child nodes of newly added nodes, but in this - * case if we count el_2 as el_1's child node in the first mutation record, - * then we will count el_2 again in the second mutation record which was - * duplicated. - * To avoid of duplicate counting added nodes, we use a Set to store - * added nodes and its child nodes during iterate mutation records. Then - * collect added nodes from the Set which have no duplicate copy. But - * this also causes newly added nodes will not be serialized with id ASAP, - * which means all the id related calculation should be lazy too. - */ - private addedSet = new Set() - private movedSet = new Set() - private droppedSet = new Set() - - public constructor(private controller: MutationController, private emissionCallback: MutationCallBack) { - this.observer = new MutationObserver(monitor(this.processMutations)) - this.observer.observe(document, { - attributeOldValue: true, - attributes: true, - characterData: true, - characterDataOldValue: true, - childList: true, - subtree: true, - }) - this.controller.onFlush(() => this.processMutations(this.observer.takeRecords())) - } - - public stop() { - this.observer.disconnect() - } - - private processMutations = (mutations: MutationRecord[]) => { - mutations.forEach(this.processMutation) - this.emit() - } - - private emit = () => { - // delay any modification of the mirror until this function - // so that the mirror for takeFullSnapshot doesn't get mutated while it's event is being processed - - const adds: AddedNodeMutation[] = [] - - /** - * Sometimes child node may be pushed before its newly added - * parent, so we init a queue to store these nodes. - */ - const addList = new DoubleLinkedList() - const getNextId = (n: Node): number | null => { - let ns: Node | null = n - let nextId: number | null = IGNORED_NODE_ID - while (nextId === IGNORED_NODE_ID) { - ns = ns && ns.nextSibling - nextId = ns && mirror.getId((ns as unknown) as INode) - } - if (nextId === -1 && nodeOrAncestorsShouldBeHidden(n.nextSibling)) { - nextId = null - } - return nextId - } - const pushAdd = (n: Node) => { - if (!n.parentNode) { - return - } - const parentId = mirror.getId((n.parentNode as Node) as INode) - const nextId = getNextId(n) - if (parentId === -1 || nextId === -1) { - return addList.addNode(n) - } - const sn = serializeNodeWithId(n, { - doc: document, - map: mirror.map, - skipChild: true, - }) - if (sn) { - adds.push({ - nextId, - parentId, - node: sn, - }) - } - } - - while (this.mapRemoves.length) { - mirror.removeNodeFromMap(this.mapRemoves.shift() as INode) - } - - this.movedSet.forEach((n) => { - if (isParentRemoved(this.removes, n) && !this.movedSet.has(n.parentNode!)) { - return - } - pushAdd(n) - }) - - this.addedSet.forEach((n) => { - if (!isAncestorInSet(this.droppedSet, n) && !isParentRemoved(this.removes, n)) { - pushAdd(n) - } else if (isAncestorInSet(this.movedSet, n)) { - pushAdd(n) - } else { - this.droppedSet.add(n) - } - }) - - let candidate: DoubleLinkedListNode | null = null - while (addList.length) { - let node: DoubleLinkedListNode | null = null - if (candidate) { - const parentId = mirror.getId((candidate.value.parentNode as Node) as INode) - const nextId = getNextId(candidate.value) - if (parentId !== -1 && nextId !== -1) { - node = candidate - } - } - if (!node) { - for (let index = addList.length - 1; index >= 0; index -= 1) { - const nodeCandidate = addList.get(index)! - const parentId = mirror.getId((nodeCandidate.value.parentNode as Node) as INode) - const nextId = getNextId(nodeCandidate.value) - if (parentId !== -1 && nextId !== -1) { - node = nodeCandidate - break - } - } - } - if (!node) { - /** - * If all nodes in queue could not find a serialized parent, - * it may be a bug or corner case. We need to escape the - * dead while loop at once. - */ - break - } - candidate = node.previous - addList.removeNode(node.value) - pushAdd(node.value) - } - - const payload = { - adds, - attributes: this.attributes - .map((attribute) => ({ - attributes: attribute.attributes, - id: mirror.getId(attribute.node as INode), - })) - // attribute mutation's id was not in the mirror map means the target node has been removed - .filter((attribute) => mirror.has(attribute.id)), - removes: this.removes, - texts: this.texts - .map((text) => ({ - id: mirror.getId(text.node as INode), - value: text.value, - })) - // text mutation's id was not in the mirror map means the target node has been removed - .filter((text) => mirror.has(text.id)), - } - // payload may be empty if the mutations happened in some blocked elements - if (!payload.texts.length && !payload.attributes.length && !payload.removes.length && !payload.adds.length) { - return - } - - // reset - this.texts = [] - this.attributes = [] - this.removes = [] - this.addedSet = new Set() - this.movedSet = new Set() - this.droppedSet = new Set() - this.movedMap = {} - - this.emissionCallback(payload) - } - - private processMutation = (m: MutationRecord) => { - if (hasSerializedNode(m.target) && nodeIsIgnored(m.target)) { - return - } - switch (m.type) { - case 'characterData': { - const value = m.target.textContent - if (!nodeOrAncestorsShouldBeHidden(m.target) && value !== m.oldValue) { - this.texts.push({ - value, - node: m.target, - }) - } - break - } - case 'attributes': { - const value = (m.target as HTMLElement).getAttribute(m.attributeName!) - if (nodeOrAncestorsShouldBeHidden(m.target) || value === m.oldValue) { - return - } - let item: AttributeCursor | undefined = this.attributes.find((a) => a.node === m.target) - if (!item) { - item = { - attributes: {}, - node: m.target, - } - this.attributes.push(item) - } - // overwrite attribute if the mutations was triggered in same time - item.attributes[m.attributeName!] = transformAttribute(document, m.attributeName!, value!) - break - } - case 'childList': { - forEach(m.addedNodes, (n: Node) => this.genAdds(n, m.target)) - forEach(m.removedNodes, (n: Node) => { - const nodeId = mirror.getId(n as INode) - const parentId = mirror.getId(m.target as INode) - if ( - nodeOrAncestorsShouldBeHidden(n) || - nodeOrAncestorsShouldBeHidden(m.target) || - (hasSerializedNode(n) && nodeIsIgnored(n)) - ) { - return - } - // removed node has not been serialized yet, just remove it from the Set - if (this.addedSet.has(n)) { - deepDelete(this.addedSet, n) - this.droppedSet.add(n) - } else if (this.addedSet.has(m.target) && nodeId === -1) { - /** - * If target was newly added and removed child node was - * not serialized, it means the child node has been removed - * before callback fired, so we can ignore it because - * newly added node will be serialized without child nodes. - * TODO: verify this - */ - } else if (isAncestorRemoved(m.target as INode)) { - /** - * If parent id was not in the mirror map any more, it - * means the parent node has already been removed. So - * the node is also removed which we do not need to track - * and replay. - */ - } else if (this.movedSet.has(n) && this.movedMap[moveKey(nodeId, parentId)]) { - deepDelete(this.movedSet, n) - } else { - this.removes.push({ - parentId, - id: nodeId, - }) - } - this.mapRemoves.push(n) - }) - break - } - default: - break - } - } - - private genAdds = (n: Node | INode, target?: Node | INode) => { - if (nodeOrAncestorsShouldBeHidden(n)) { - return - } - if (isINode(n)) { - if (nodeIsIgnored(n)) { - return - } - this.movedSet.add(n) - let targetId: number | null = null - if (target && isINode(target)) { - targetId = target.__sn.id - } - if (targetId) { - this.movedMap[moveKey(n.__sn.id, targetId)] = true - } - } else { - this.addedSet.add(n) - this.droppedSet.delete(n) - } - forEach(n.childNodes, (childN: ChildNode) => this.genAdds(childN)) - } -} - -/** - * Some utils to handle the mutation observer DOM records. - * It should be more clear to extend the native data structure - * like Set and Map, but currently Typescript does not support - * that. - */ -function deepDelete(addsSet: Set, n: Node) { - addsSet.delete(n) - forEach(n.childNodes, (childN: ChildNode) => deepDelete(addsSet, childN)) -} - -function isParentRemoved(removes: RemovedNodeMutation[], n: Node): boolean { - const { parentNode } = n - if (!parentNode) { - return false - } - const parentId = mirror.getId((parentNode as Node) as INode) - if (removes.some((r) => r.id === parentId)) { - return true - } - return isParentRemoved(removes, parentNode) -} - -function isAncestorInSet(set: Set, n: Node): boolean { - const { parentNode } = n - if (!parentNode) { - return false - } - if (set.has(parentNode)) { - return true - } - return isAncestorInSet(set, parentNode) -} diff --git a/packages/rum-recorder/src/domain/rrweb/mutationObserver.spec.ts b/packages/rum-recorder/src/domain/rrweb/mutationObserver.spec.ts index b6f4ed965e..c74a740943 100644 --- a/packages/rum-recorder/src/domain/rrweb/mutationObserver.spec.ts +++ b/packages/rum-recorder/src/domain/rrweb/mutationObserver.spec.ts @@ -1,8 +1,7 @@ import { isIE } from '../../../../core/test/specHelper' import { collectAsyncCalls, createMutationPayloadValidator } from '../../../test/utils' -import { snapshot, NodeType } from '../rrweb-snapshot' -import { MutationController } from './mutation' -import { sortAddedAndMovedNodes, startMutationObserver } from './mutationObserver' +import { serializeDocument, NodeType } from '../rrweb-snapshot' +import { sortAddedAndMovedNodes, startMutationObserver, MutationController } from './mutationObserver' import { MutationCallBack } from './types' describe('startMutationCollection', () => { @@ -39,7 +38,7 @@ describe('startMutationCollection', () => { describe('childList mutation records', () => { it('emits a mutation when a node is appended to a known node', () => { - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, mutationCallbackSpy, getLatestMutationPayload } = startMutationCollection() sandbox.appendChild(document.createElement('div')) @@ -59,7 +58,7 @@ describe('startMutationCollection', () => { }) it('processes mutations asynchronously', (done) => { - snapshot(document) + serializeDocument(document) const { mutationCallbackSpy } = startMutationCollection() const { waitAsyncCalls: waitMutationCallbackCalls, @@ -76,7 +75,7 @@ describe('startMutationCollection', () => { }) it('does not emit a mutation when a node is appended to a unknown node', () => { - // Here, we don't call snapshot(), so the sandbox is 'unknown'. + // Here, we don't call serializeDocument(), so the sandbox is 'unknown'. const { mutationController, mutationCallbackSpy } = startMutationCollection() sandbox.appendChild(document.createElement('div')) @@ -86,7 +85,7 @@ describe('startMutationCollection', () => { }) it('emits buffered mutation records on flush', () => { - snapshot(document) + serializeDocument(document) const { mutationController, mutationCallbackSpy } = startMutationCollection() sandbox.appendChild(document.createElement('div')) @@ -102,7 +101,7 @@ describe('startMutationCollection', () => { it('attribute mutations', () => { const element = document.createElement('div') sandbox.appendChild(element) - snapshot(document) + serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -116,7 +115,7 @@ describe('startMutationCollection', () => { it('text mutations', () => { const textNode = document.createTextNode('foo') sandbox.appendChild(textNode) - snapshot(document) + serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -128,7 +127,7 @@ describe('startMutationCollection', () => { }) it('add mutations', () => { - snapshot(document) + serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -142,7 +141,7 @@ describe('startMutationCollection', () => { it('remove mutations', () => { const element = document.createElement('div') sandbox.appendChild(element) - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -171,7 +170,7 @@ describe('startMutationCollection', () => { it('attribute mutations', () => { const element = document.createElement('div') sandbox.appendChild(element) - snapshot(document) + serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -187,7 +186,7 @@ describe('startMutationCollection', () => { it('text mutations', () => { const textNode = document.createTextNode('foo') sandbox.appendChild(textNode) - snapshot(document) + serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -205,7 +204,7 @@ describe('startMutationCollection', () => { const child = document.createElement('b') sandbox.appendChild(parent) parent.appendChild(child) - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -242,7 +241,7 @@ describe('startMutationCollection', () => { }) it('remove mutations', () => { - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -268,7 +267,7 @@ describe('startMutationCollection', () => { it('emits only an "add" mutation when adding, removing then re-adding a child', () => { const element = document.createElement('a') - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -294,7 +293,7 @@ describe('startMutationCollection', () => { const elementB = document.createElement('b') sandbox.appendChild(elementA) sandbox.appendChild(elementB) - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -327,7 +326,7 @@ describe('startMutationCollection', () => { sandbox.appendChild(element) sandbox.appendChild(container1) sandbox.appendChild(container2) - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -354,7 +353,7 @@ describe('startMutationCollection', () => { }) it('keep nodes order when adding multiple sibling nodes', () => { - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -398,7 +397,7 @@ describe('startMutationCollection', () => { }) it('emits a mutation when a text node is changed', () => { - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, mutationCallbackSpy, getLatestMutationPayload } = startMutationCollection() textNode.data = 'bar' @@ -418,7 +417,7 @@ describe('startMutationCollection', () => { }) it('does not emit a mutation when a text node keeps the same value', () => { - snapshot(document) + serializeDocument(document) const { mutationController, mutationCallbackSpy } = startMutationCollection() textNode.data = 'bar' @@ -431,7 +430,7 @@ describe('startMutationCollection', () => { describe('attributes mutations', () => { it('emits a mutation when an attribute is changed', () => { - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, mutationCallbackSpy, getLatestMutationPayload } = startMutationCollection() sandbox.setAttribute('foo', 'bar') @@ -452,7 +451,7 @@ describe('startMutationCollection', () => { it('does not emit a mutation when an attribute keeps the same value', () => { sandbox.setAttribute('foo', 'bar') - snapshot(document) + serializeDocument(document) const { mutationController, mutationCallbackSpy } = startMutationCollection() sandbox.setAttribute('foo', 'biz') @@ -463,7 +462,7 @@ describe('startMutationCollection', () => { }) it('reuse the same mutation when multiple attributes are changed', () => { - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() sandbox.setAttribute('foo1', 'biz') @@ -491,7 +490,7 @@ describe('startMutationCollection', () => { }) it('skips ignored nodes when looking for the next id', () => { - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -513,7 +512,7 @@ describe('startMutationCollection', () => { describe('does not emit mutations occurring in ignored node', () => { it('when adding an ignored node', () => { ignoredElement.remove() - snapshot(document) + serializeDocument(document) const { mutationController, mutationCallbackSpy } = startMutationCollection() @@ -525,7 +524,7 @@ describe('startMutationCollection', () => { }) it('when changing the attributes of an ignored node', () => { - snapshot(document) + serializeDocument(document) const { mutationController, mutationCallbackSpy } = startMutationCollection() @@ -537,7 +536,7 @@ describe('startMutationCollection', () => { }) it('when adding a new child node', () => { - snapshot(document) + serializeDocument(document) const { mutationController, mutationCallbackSpy } = startMutationCollection() @@ -551,7 +550,7 @@ describe('startMutationCollection', () => { it('when mutating a known child node', () => { const textNode = document.createTextNode('function foo() {}') sandbox.appendChild(textNode) - snapshot(document) + serializeDocument(document) ignoredElement.appendChild(textNode) const { mutationController, mutationCallbackSpy } = startMutationCollection() @@ -566,7 +565,7 @@ describe('startMutationCollection', () => { it('when adding a known child node', () => { const textNode = document.createTextNode('function foo() {}') sandbox.appendChild(textNode) - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() @@ -596,7 +595,7 @@ describe('startMutationCollection', () => { }) it('does not emit attribute mutations on hidden nodes', () => { - snapshot(document) + serializeDocument(document) const { mutationController, mutationCallbackSpy } = startMutationCollection() @@ -609,7 +608,7 @@ describe('startMutationCollection', () => { describe('does not emit mutations occurring in hidden node', () => { it('when adding a new node', () => { - snapshot(document) + serializeDocument(document) const { mutationController, mutationCallbackSpy } = startMutationCollection() @@ -623,7 +622,7 @@ describe('startMutationCollection', () => { it('when mutating a known child node', () => { const textNode = document.createTextNode('function foo() {}') sandbox.appendChild(textNode) - snapshot(document) + serializeDocument(document) hiddenElement.appendChild(textNode) const { mutationController, mutationCallbackSpy } = startMutationCollection() @@ -638,7 +637,7 @@ describe('startMutationCollection', () => { it('when moving a known node into an hidden node', () => { const textNode = document.createTextNode('function foo() {}') sandbox.appendChild(textNode) - const serializedDocument = snapshot(document)[0]! + const serializedDocument = serializeDocument(document) const { mutationController, getLatestMutationPayload } = startMutationCollection() diff --git a/packages/rum-recorder/src/domain/rrweb/mutationObserver.ts b/packages/rum-recorder/src/domain/rrweb/mutationObserver.ts index 863fbad593..7d71c92cab 100644 --- a/packages/rum-recorder/src/domain/rrweb/mutationObserver.ts +++ b/packages/rum-recorder/src/domain/rrweb/mutationObserver.ts @@ -22,7 +22,6 @@ import { TextMutation, } from './types' import { forEach } from './utils' -import { MutationController } from './mutation' import { createMutationBatch } from './mutationBatch' type WithSerializedTarget = T & { target: NodeWithSerializedNode } @@ -55,6 +54,21 @@ export function startMutationObserver(controller: MutationController, mutationCa } } +/** + * Controls how mutations are processed, allowing to flush pending mutations. + */ +export class MutationController { + private flushListener?: () => void + + public flush() { + this.flushListener?.() + } + + public onFlush(listener: () => void) { + this.flushListener = listener + } +} + function processMutations(mutations: MutationRecord[], mutationCallback: MutationCallBack) { // Discard any mutation with a 'target' node that: // * isn't injected in the current document or isn't known/serialized yet: those nodes are likely @@ -149,7 +163,7 @@ function processChildListMutations(mutations: Array mutationObserverWrapper.stop() +function initMutationObserver(mutationController: MutationController, cb: MutationCallBack) { + return startMutationObserver(mutationController, cb).stop } function initMoveObserver(cb: MousemoveCallBack): ListenerHandler { diff --git a/packages/rum-recorder/src/domain/rrweb/record.spec.ts b/packages/rum-recorder/src/domain/rrweb/record.spec.ts index ad66033952..0f4da7fc4f 100644 --- a/packages/rum-recorder/src/domain/rrweb/record.spec.ts +++ b/packages/rum-recorder/src/domain/rrweb/record.spec.ts @@ -1,15 +1,6 @@ import { Clock, createNewEvent, isIE } from '../../../../core/test/specHelper' -import { collectAsyncCalls, createMutationPayloadValidator } from '../../../test/utils' -import { - RecordType, - IncrementalSource, - MutationData, - FullSnapshotRecord, - RawRecord, - IncrementalSnapshotRecord, - FocusRecord, -} from '../../types' -import { NodeType } from '../rrweb-snapshot/types' +import { collectAsyncCalls } from '../../../test/utils' +import { RecordType, IncrementalSource, RawRecord, IncrementalSnapshotRecord, FocusRecord } from '../../types' import { record } from './record' import { RecordAPI } from './types' @@ -37,78 +28,6 @@ describe('record', () => { recordApi?.stop() }) - it('records full snapshots and DOM mutations', (done) => { - // TODO: remove when new-mutation-observer is enabled - startRecording() - - const p = document.createElement('p') - const span = document.createElement('span') - - setTimeout(() => { - sandbox.appendChild(p) - p.appendChild(span) - sandbox.removeChild(document.querySelector('input')!) - }, 0) - - setTimeout(() => { - span.innerText = 'test' - recordApi.takeFullSnapshot() - }, 10) - - setTimeout(() => { - p.removeChild(span) - sandbox.appendChild(span) - }, 10) - - waitEmitCalls(9, () => { - const records = getEmittedRecords() - expect(records[0].type).toBe(RecordType.Meta) - expect(records[1].type).toBe(RecordType.Focus) - - expect(records[2].type).toBe(RecordType.FullSnapshot) - - expect(records[3].type).toBe(RecordType.IncrementalSnapshot) - - const { validate: validateMutationPayload, expectNewNode, expectInitialNode } = createMutationPayloadValidator( - (records[2] as FullSnapshotRecord).data.node - ) - - const p = expectNewNode({ type: NodeType.Element, tagName: 'p' }) - const span = expectNewNode({ type: NodeType.Element, tagName: 'span' }) - const text = expectNewNode({ type: NodeType.Text, textContent: 'test' }) - const sandbox = expectInitialNode({ idAttribute: 'sandbox' }) - - validateMutationPayload((records[3] as IncrementalSnapshotRecord).data as MutationData, { - adds: [ - { parent: sandbox, node: p }, - { parent: p, node: span }, - ], - removes: [{ node: expectInitialNode({ tag: 'input' }), parent: sandbox }], - }) - - expect(records[4].type).toBe(RecordType.IncrementalSnapshot) - validateMutationPayload((records[4] as IncrementalSnapshotRecord).data as MutationData, { - adds: [{ parent: span, node: text }], - }) - - expect(records[5].type).toBe(RecordType.Meta) - expect(records[6].type).toBe(RecordType.Focus) - - expect(records[7].type).toBe(RecordType.FullSnapshot) - - expect(records[8].type).toBe(RecordType.IncrementalSnapshot) - validateMutationPayload((records[8] as IncrementalSnapshotRecord).data as MutationData, { - adds: [ - { parent: sandbox, node: span }, - { parent: span, node: text }, - ], - removes: [{ parent: p, node: span }], - }) - - expectNoExtraEmitCalls(done) - }) - }) - it('captures stylesheet rules', (done) => { const styleElement = document.createElement('style') sandbox.appendChild(styleElement) @@ -263,7 +182,6 @@ describe('record', () => { function startRecording() { recordApi = record({ emit: emitSpy, - useNewMutationObserver: false, }) } @@ -276,6 +194,5 @@ function createDOMSandbox() { const sandbox = document.createElement('div') sandbox.id = 'sandbox' document.body.appendChild(sandbox) - sandbox.appendChild(document.createElement('input')) return sandbox } diff --git a/packages/rum-recorder/src/domain/rrweb/record.ts b/packages/rum-recorder/src/domain/rrweb/record.ts index 6370a6b741..629f5fac5a 100644 --- a/packages/rum-recorder/src/domain/rrweb/record.ts +++ b/packages/rum-recorder/src/domain/rrweb/record.ts @@ -1,13 +1,13 @@ import { runOnReadyState } from '@datadog/browser-core' -import { snapshot } from '../rrweb-snapshot' +import { serializeDocument } from '../rrweb-snapshot' import { RecordType } from '../../types' import { initObservers } from './observer' import { IncrementalSource, ListenerHandler, RecordAPI, RecordOptions } from './types' -import { getWindowHeight, getWindowWidth, mirror } from './utils' -import { MutationController } from './mutation' +import { getWindowHeight, getWindowWidth } from './utils' +import { MutationController } from './mutationObserver' export function record(options: RecordOptions): RecordAPI { - const { emit, useNewMutationObserver } = options + const { emit } = options // runtime checks for user options if (!emit) { throw new Error('emit function is required') @@ -34,16 +34,9 @@ export function record(options: RecordOptions): RecordAPI { type: RecordType.Focus, }) - const [node, idNodeMap] = snapshot(document) - - if (!node) { - return console.warn('Failed to snapshot the document') - } - - mirror.map = idNodeMap emit({ data: { - node, + node: serializeDocument(document), initialOffset: { left: window.pageXOffset !== undefined @@ -71,7 +64,6 @@ export function record(options: RecordOptions): RecordAPI { handlers.push( initObservers({ - useNewMutationObserver, mutationController, inputCb: (v) => emit({ diff --git a/packages/rum-recorder/src/domain/rrweb/types.ts b/packages/rum-recorder/src/domain/rrweb/types.ts index eceaa6d1de..3b94e0e789 100644 --- a/packages/rum-recorder/src/domain/rrweb/types.ts +++ b/packages/rum-recorder/src/domain/rrweb/types.ts @@ -1,6 +1,6 @@ -import { IdNodeMap, INode, SerializedNodeWithId } from '../rrweb-snapshot/types' +import { SerializedNodeWithId } from '../rrweb-snapshot/types' import { FocusRecord, RawRecord } from '../../types' -import { MutationController } from './mutation' +import { MutationController } from './mutationObserver' export enum IncrementalSource { Mutation = 0, @@ -62,7 +62,6 @@ export type IncrementalData = export interface RecordOptions { emit?: (record: RawRecord) => void - useNewMutationObserver: boolean } export interface RecordAPI { @@ -71,7 +70,6 @@ export interface RecordAPI { } export interface ObserverParam { - useNewMutationObserver: boolean mutationController: MutationController mutationCb: MutationCallBack mousemoveCb: MousemoveCallBack @@ -241,13 +239,5 @@ export type MediaInteractionCallback = (p: MediaInteractionParam) => void export type FocusCallback = (data: FocusRecord['data']) => void -export interface Mirror { - map: IdNodeMap - getId: (n: INode) => number - getNode: (id: number) => INode | null - removeNodeFromMap: (n: INode) => void - has: (id: number) => boolean -} - export type ListenerHandler = () => void export type HookResetter = () => void diff --git a/packages/rum-recorder/src/domain/rrweb/utils.ts b/packages/rum-recorder/src/domain/rrweb/utils.ts index a13c62576a..d0db8bd38e 100644 --- a/packages/rum-recorder/src/domain/rrweb/utils.ts +++ b/packages/rum-recorder/src/domain/rrweb/utils.ts @@ -1,30 +1,4 @@ -import { INode } from '../rrweb-snapshot' -import { HookResetter, Mirror } from './types' - -export const mirror: Mirror = { - map: {}, - getId(n) { - // if n is not a serialized INode, use -1 as its id. - if (!n.__sn) { - return -1 - } - return n.__sn.id - }, - getNode(id) { - return mirror.map[id] || null - }, - // TODO: use a weakmap to get rid of manually memory management - removeNodeFromMap(n) { - const id = n.__sn && n.__sn.id - delete mirror.map[id] - if (n.childNodes) { - forEach(n.childNodes, (child: ChildNode) => mirror.removeNodeFromMap((child as Node) as INode)) - } - }, - has(id) { - return mirror.map.hasOwnProperty(id) - }, -} +import { HookResetter } from './types' export function hookSetter( target: T, @@ -64,21 +38,6 @@ export function getWindowWidth(): number { ) } -export function isAncestorRemoved(target: INode): boolean { - const id = mirror.getId(target) - if (!mirror.has(id)) { - return true - } - if (target.parentNode && target.parentNode.nodeType === target.DOCUMENT_NODE) { - return false - } - // if the root is not document, it means the node is not in the DOM tree anymore - if (!target.parentNode) { - return true - } - return isAncestorRemoved((target.parentNode as unknown) as INode) -} - export function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { return Boolean((event as TouchEvent).changedTouches) } diff --git a/test/e2e/scenario/recorder.scenario.ts b/test/e2e/scenario/recorder.scenario.ts index 919447c660..6a9c6a056c 100644 --- a/test/e2e/scenario/recorder.scenario.ts +++ b/test/e2e/scenario/recorder.scenario.ts @@ -268,7 +268,7 @@ describe('recorder', () => { expect(findAllIncrementalSnapshots(segment, IncrementalSource.Mutation)).toEqual([]) }) - createTest('record DOM node movement 1 (old mutation observer)') + createTest('record DOM node movement 1') .withSetup(bundleSetup) .withRumRecorder() .withBody( @@ -291,79 +291,6 @@ describe('recorder', () => { await flushEvents() - const { validate, expectInitialNode } = createMutationPayloadValidatorFromSegment(getFirstSegment(events)) - - validate({ - adds: [ - { - parent: expectInitialNode({ tag: 'div' }), - node: expectInitialNode({ tag: 'span' }), - }, - { - next: expectInitialNode({ tag: 'i' }), - parent: expectInitialNode({ tag: 'span' }), - node: expectInitialNode({ text: 'c' }), - }, - { - next: expectInitialNode({ text: 'g' }), - parent: expectInitialNode({ tag: 'span' }), - node: expectInitialNode({ tag: 'i' }), - }, - { - next: expectInitialNode({ tag: 'b' }), - parent: expectInitialNode({ tag: 'i' }), - node: expectInitialNode({ text: 'd' }), - }, - { - next: expectInitialNode({ text: 'f' }), - parent: expectInitialNode({ tag: 'i' }), - node: expectInitialNode({ tag: 'b' }), - }, - { - parent: expectInitialNode({ tag: 'b' }), - node: expectInitialNode({ text: 'e' }), - }, - { - parent: expectInitialNode({ tag: 'i' }), - node: expectInitialNode({ text: 'f' }), - }, - { - parent: expectInitialNode({ tag: 'span' }), - node: expectInitialNode({ text: 'g' }), - }, - ], - removes: [ - { - parent: expectInitialNode({ tag: 'body' }), - node: expectInitialNode({ tag: 'span' }), - }, - ], - }) - }) - - createTest('record DOM node movement 1 (new mutation observer)') - .withSetup(bundleSetup) - .withRumRecorder({ enableExperimentalFeatures: ['new-mutation-observer'] }) - .withBody( - // prettier-ignore - html` -
a

b
- cdefg - ` - ) - .run(async ({ events }) => { - await browserExecute(() => { - const div = document.querySelector('div')! - const p = document.querySelector('p')! - const span = document.querySelector('span')! - document.body.removeChild(span) - p.appendChild(span) - p.removeChild(span) - div.appendChild(span) - }) - - await flushEvents() - const { validate, expectInitialNode } = createMutationPayloadValidatorFromSegment(getFirstSegment(events)) validate({ adds: [ @@ -389,7 +316,7 @@ describe('recorder', () => { }) }) - createTest('record DOM node movement 2 (old mutation observer)') + createTest('record DOM node movement 2') .withSetup(bundleSetup) .withRumRecorder() .withBody( @@ -414,82 +341,6 @@ describe('recorder', () => { const div = expectNewNode({ type: NodeType.Element, tagName: 'div' }) - validate({ - adds: [ - { - next: expectInitialNode({ tag: 'i' }), - parent: expectInitialNode({ tag: 'span' }), - node: expectInitialNode({ text: 'c' }), - }, - { - next: expectInitialNode({ text: 'g' }), - parent: expectInitialNode({ tag: 'span' }), - node: expectInitialNode({ tag: 'i' }), - }, - { - next: expectInitialNode({ tag: 'b' }), - parent: expectInitialNode({ tag: 'i' }), - node: expectInitialNode({ text: 'd' }), - }, - { - next: expectInitialNode({ text: 'f' }), - parent: expectInitialNode({ tag: 'i' }), - node: expectInitialNode({ tag: 'b' }), - }, - { - parent: expectInitialNode({ tag: 'b' }), - node: expectInitialNode({ text: 'e' }), - }, - { - parent: expectInitialNode({ tag: 'i' }), - node: expectInitialNode({ text: 'f' }), - }, - { - parent: expectInitialNode({ tag: 'span' }), - node: expectInitialNode({ text: 'g' }), - }, - { - parent: expectInitialNode({ tag: 'body' }), - node: div, - }, - { - parent: div, - node: expectInitialNode({ tag: 'span' }), - }, - ], - removes: [ - { - parent: expectInitialNode({ tag: 'body' }), - node: expectInitialNode({ tag: 'span' }), - }, - ], - }) - }) - createTest('record DOM node movement 2 (new mutation observer)') - .withSetup(bundleSetup) - .withRumRecorder({ enableExperimentalFeatures: ['new-mutation-observer'] }) - .withBody( - // prettier-ignore - html` - cdefg - ` - ) - .run(async ({ events }) => { - await browserExecute(() => { - const div = document.createElement('div') - const span = document.querySelector('span')! - document.body.appendChild(div) - div.appendChild(span) - }) - - await flushEvents() - - const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidatorFromSegment( - getFirstSegment(events) - ) - - const div = expectNewNode({ type: NodeType.Element, tagName: 'div' }) - validate({ adds: [ {