diff --git a/packages/rum/src/domain/record/assembly.ts b/packages/rum/src/domain/record/assembly.ts index 747d467a3e..ec76affda4 100644 --- a/packages/rum/src/domain/record/assembly.ts +++ b/packages/rum/src/domain/record/assembly.ts @@ -1,10 +1,12 @@ +import type { TimeStamp } from '@datadog/browser-core' import { timeStampNow } from '@datadog/browser-core' import type { BrowserIncrementalData, BrowserIncrementalSnapshotRecord } from '../../types' import { RecordType } from '../../types' export function assembleIncrementalSnapshot( source: Data['source'], - data: Omit + data: Omit, + timestamp: TimeStamp = timeStampNow() ): BrowserIncrementalSnapshotRecord { return { data: { @@ -12,6 +14,6 @@ export function assembleIncrementalSnapshot ...data, } as Data, type: RecordType.IncrementalSnapshot, - timestamp: timeStampNow(), + timestamp, } } diff --git a/packages/rum/src/domain/record/index.ts b/packages/rum/src/domain/record/index.ts index 4fd827cea3..aa832d2930 100644 --- a/packages/rum/src/domain/record/index.ts +++ b/packages/rum/src/domain/record/index.ts @@ -2,6 +2,6 @@ export { takeFullSnapshot, takeNodeSnapshot } from './internalApi' export { record } from './record' export type { SerializationMetric, SerializationStats } from './serialization' export { createSerializationStats, aggregateSerializationStats } from './serialization' -export { serializeNode, serializeDocument } from './serialization' +export { serializeNode } from './serialization' export { createElementsScrollPositions } from './elementsScrollPositions' export type { ShadowRootsController } from './shadowRootsController' diff --git a/packages/rum/src/domain/record/serialization/index.ts b/packages/rum/src/domain/record/serialization/index.ts index d3892ab490..789f2ae647 100644 --- a/packages/rum/src/domain/record/serialization/index.ts +++ b/packages/rum/src/domain/record/serialization/index.ts @@ -1,6 +1,8 @@ export { createRootInsertionCursor } from './insertionCursor' export { getElementInputValue } from './serializationUtils' -export { serializeDocument } from './serializeDocument' +export { serializeFullSnapshot } from './serializeFullSnapshot' +export { serializeFullSnapshotAsChange } from './serializeFullSnapshotAsChange' +export { serializeMutations } from './serializeMutations' export { serializeNode } from './serializeNode' export { serializeNodeAsChange } from './serializeNodeAsChange' export { serializeAttribute } from './serializeAttribute' diff --git a/packages/rum/src/domain/record/serialization/serializeDocument.ts b/packages/rum/src/domain/record/serialization/serializeDocument.ts deleted file mode 100644 index 4b8f19a81e..0000000000 --- a/packages/rum/src/domain/record/serialization/serializeDocument.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { DocumentNode, SerializedNodeWithId } from '../../../types' -import { serializeNode } from './serializeNode' -import type { SerializationTransaction } from './serializationTransaction' - -export function serializeDocument( - document: Document, - transaction: SerializationTransaction -): DocumentNode & SerializedNodeWithId { - const defaultPrivacyLevel = transaction.scope.configuration.defaultPrivacyLevel - const serializedNode = serializeNode(document, defaultPrivacyLevel, transaction) - - // We are sure that Documents are never ignored, so this function never returns null - return serializedNode as DocumentNode & SerializedNodeWithId -} diff --git a/packages/rum/src/domain/record/serialization/serializeFullSnapshot.ts b/packages/rum/src/domain/record/serialization/serializeFullSnapshot.ts new file mode 100644 index 0000000000..598da499af --- /dev/null +++ b/packages/rum/src/domain/record/serialization/serializeFullSnapshot.ts @@ -0,0 +1,38 @@ +import { getScrollX, getScrollY } from '@datadog/browser-rum-core' +import type { TimeStamp } from '@datadog/browser-core' +import type { BrowserFullSnapshotRecord } from '../../../types' +import { RecordType } from '../../../types' +import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' +import type { RecordingScope } from '../recordingScope' +import { serializeNode } from './serializeNode' +import { serializeInTransaction } from './serializationTransaction' +import type { SerializationKind, SerializationTransaction } from './serializationTransaction' + +export function serializeFullSnapshot( + timestamp: TimeStamp, + kind: SerializationKind, + document: Document, + emitRecord: EmitRecordCallback, + emitStats: EmitStatsCallback, + scope: RecordingScope +): void { + serializeInTransaction(kind, emitRecord, emitStats, scope, (transaction: SerializationTransaction) => { + const defaultPrivacyLevel = transaction.scope.configuration.defaultPrivacyLevel + + // We are sure that Documents are never ignored, so this function never returns null. + const node = serializeNode(document, defaultPrivacyLevel, transaction)! + + const record: BrowserFullSnapshotRecord = { + data: { + node, + initialOffset: { + left: getScrollX(), + top: getScrollY(), + }, + }, + type: RecordType.FullSnapshot, + timestamp, + } + transaction.add(record) + }) +} diff --git a/packages/rum/src/domain/record/serialization/serializeFullSnapshotAsChange.ts b/packages/rum/src/domain/record/serialization/serializeFullSnapshotAsChange.ts new file mode 100644 index 0000000000..ff04ff6324 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/serializeFullSnapshotAsChange.ts @@ -0,0 +1,33 @@ +import type { TimeStamp } from '@datadog/browser-core' +import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' +import type { RecordingScope } from '../recordingScope' +import { serializeChangesInTransaction } from './serializationTransaction' +import type { ChangeSerializationTransaction, SerializationKind } from './serializationTransaction' +import { serializeNodeAsChange } from './serializeNodeAsChange' +import { createRootInsertionCursor } from './insertionCursor' + +export function serializeFullSnapshotAsChange( + timestamp: TimeStamp, + kind: SerializationKind, + document: Document, + emitRecord: EmitRecordCallback, + emitStats: EmitStatsCallback, + scope: RecordingScope +): void { + scope.resetIds() + serializeChangesInTransaction( + kind, + emitRecord, + emitStats, + scope, + timestamp, + (transaction: ChangeSerializationTransaction) => { + serializeNodeAsChange( + createRootInsertionCursor(scope.nodeIds), + document, + scope.configuration.defaultPrivacyLevel, + transaction + ) + } + ) +} diff --git a/packages/rum/src/domain/record/serialization/serializeMutations.spec.ts b/packages/rum/src/domain/record/serialization/serializeMutations.spec.ts new file mode 100644 index 0000000000..8d44b2d363 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/serializeMutations.spec.ts @@ -0,0 +1,113 @@ +import type { RecordingScope } from '../recordingScope' +import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper' +import { idsAreAssignedForNodeAndAncestors, sortAddedAndMovedNodes } from './serializeMutations' + +describe('idsAreAssignedForNodeAndAncestors', () => { + let scope: RecordingScope + + beforeEach(() => { + scope = createRecordingScopeForTesting() + }) + + it('returns false for DOM Nodes that have not been assigned an id', () => { + expect(idsAreAssignedForNodeAndAncestors(document.createElement('div'), scope.nodeIds)).toBe(false) + }) + + it('returns true for DOM Nodes that have been assigned an id', () => { + const node = document.createElement('div') + scope.nodeIds.getOrInsert(node) + expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) + }) + + it('returns false for DOM Nodes when an ancestor has not been assigned an id', () => { + const node = document.createElement('div') + scope.nodeIds.getOrInsert(node) + + const parent = document.createElement('div') + parent.appendChild(node) + scope.nodeIds.getOrInsert(parent) + + const grandparent = document.createElement('div') + grandparent.appendChild(parent) + + expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(false) + }) + + it('returns true for DOM Nodes when all ancestors have been assigned an id', () => { + const node = document.createElement('div') + scope.nodeIds.getOrInsert(node) + + const parent = document.createElement('div') + parent.appendChild(node) + scope.nodeIds.getOrInsert(parent) + + const grandparent = document.createElement('div') + grandparent.appendChild(parent) + scope.nodeIds.getOrInsert(grandparent) + + expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) + }) + + it('returns true for DOM Nodes in shadow subtrees', () => { + const node = document.createElement('div') + scope.nodeIds.getOrInsert(node) + + const parent = document.createElement('div') + parent.appendChild(node) + scope.nodeIds.getOrInsert(parent) + + const grandparent = document.createElement('div') + const shadowRoot = grandparent.attachShadow({ mode: 'open' }) + shadowRoot.appendChild(parent) + scope.nodeIds.getOrInsert(grandparent) + + expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) + }) +}) + +describe('sortAddedAndMovedNodes', () => { + let parent: Node + let a: Node + let aa: Node + let b: Node + let c: Node + let d: Node + + beforeEach(() => { + // Create a tree like this: + // parent + // / | \ \ + // a b c d + // | + // aa + a = document.createElement('a') + aa = document.createElement('aa') + b = document.createElement('b') + c = document.createElement('c') + d = document.createElement('d') + parent = document.createElement('parent') + parent.appendChild(a) + a.appendChild(aa) + parent.appendChild(b) + parent.appendChild(c) + parent.appendChild(d) + }) + + it('sorts siblings in reverse order', () => { + const nodes = [c, b, d, a] + sortAddedAndMovedNodes(nodes) + expect(nodes).toEqual([d, c, b, a]) + }) + + it('sorts parents', () => { + const nodes = [a, parent, aa] + sortAddedAndMovedNodes(nodes) + expect(nodes).toEqual([parent, a, aa]) + }) + + it('sorts parents first then siblings', () => { + const nodes = [c, aa, b, parent, d, a] + sortAddedAndMovedNodes(nodes) + expect(nodes).toEqual([parent, d, c, b, a, aa]) + }) +}) diff --git a/packages/rum/src/domain/record/serialization/serializeMutations.ts b/packages/rum/src/domain/record/serialization/serializeMutations.ts new file mode 100644 index 0000000000..284db5ba25 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/serializeMutations.ts @@ -0,0 +1,388 @@ +import type { + NodePrivacyLevelCache, + RumMutationRecord, + RumChildListMutationRecord, + RumCharacterDataMutationRecord, + RumAttributesMutationRecord, +} from '@datadog/browser-rum-core' +import { + isNodeShadowHost, + getParentNode, + forEachChildNodes, + getNodePrivacyLevel, + getTextContent, + NodePrivacyLevel, + isNodeShadowRoot, +} from '@datadog/browser-rum-core' +import type { TimeStamp } from '@datadog/browser-core' +import { IncrementalSource } from '../../../types' +import type { + BrowserMutationData, + AddedNodeMutation, + AttributeMutation, + RemovedNodeMutation, + TextMutation, +} from '../../../types' +import type { RecordingScope } from '../recordingScope' +import type { RemoveShadowRootCallBack } from '../shadowRootsController' +import { assembleIncrementalSnapshot } from '../assembly' +import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' +import type { NodeId, NodeIds } from '../itemIds' +import type { SerializationTransaction } from './serializationTransaction' +import { SerializationKind, serializeInTransaction } from './serializationTransaction' +import { serializeNode } from './serializeNode' +import { serializeAttribute } from './serializeAttribute' +import { getElementInputValue } from './serializationUtils' + +export type NodeWithSerializedNode = Node & { __brand: 'NodeWithSerializedNode' } +type WithSerializedTarget = T & { target: NodeWithSerializedNode } + +export function serializeMutations( + timestamp: TimeStamp, + mutations: RumMutationRecord[], + emitRecord: EmitRecordCallback, + emitStats: EmitStatsCallback, + scope: RecordingScope +): void { + serializeInTransaction( + SerializationKind.INCREMENTAL_SNAPSHOT, + emitRecord, + emitStats, + scope, + (transaction: SerializationTransaction) => processMutations(timestamp, mutations, transaction) + ) +} + +function processMutations( + timestamp: TimeStamp, + mutations: RumMutationRecord[], + transaction: SerializationTransaction +): void { + const nodePrivacyLevelCache: NodePrivacyLevelCache = new Map() + + mutations + .filter((mutation): mutation is RumChildListMutationRecord => mutation.type === 'childList') + .forEach((mutation) => { + mutation.removedNodes.forEach((removedNode) => { + traverseRemovedShadowDom(removedNode, transaction.scope.shadowRootsController.removeShadowRoot) + }) + }) + + // 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 + // part of a mutation occurring in a parent Node + // * should be hidden or ignored + const filteredMutations = mutations.filter( + (mutation): mutation is WithSerializedTarget => + mutation.target.isConnected && + idsAreAssignedForNodeAndAncestors(mutation.target, transaction.scope.nodeIds) && + getNodePrivacyLevel( + mutation.target, + transaction.scope.configuration.defaultPrivacyLevel, + nodePrivacyLevelCache + ) !== NodePrivacyLevel.HIDDEN + ) + + const { adds, removes, hasBeenSerialized } = processChildListMutations( + filteredMutations.filter( + (mutation): mutation is WithSerializedTarget => mutation.type === 'childList' + ), + nodePrivacyLevelCache, + transaction + ) + + const texts = processCharacterDataMutations( + filteredMutations.filter( + (mutation): mutation is WithSerializedTarget => + mutation.type === 'characterData' && !hasBeenSerialized(mutation.target) + ), + nodePrivacyLevelCache, + transaction + ) + + const attributes = processAttributesMutations( + filteredMutations.filter( + (mutation): mutation is WithSerializedTarget => + mutation.type === 'attributes' && !hasBeenSerialized(mutation.target) + ), + nodePrivacyLevelCache, + transaction + ) + + if (!texts.length && !attributes.length && !removes.length && !adds.length) { + return + } + + const record = assembleIncrementalSnapshot( + IncrementalSource.Mutation, + { + adds, + removes, + texts, + attributes, + }, + timestamp + ) + transaction.add(record) +} + +function processChildListMutations( + mutations: Array>, + nodePrivacyLevelCache: NodePrivacyLevelCache, + transaction: SerializationTransaction +) { + // First, we iterate over mutations to collect: + // + // * nodes that have been added in the document and not removed by a subsequent mutation + // * nodes that have been removed from the document but were not added in a previous mutation + // + // For this second category, we also collect their previous parent (mutation.target) because we'll + // need it to emit a 'remove' mutation. + // + // Those two categories may overlap: if a node moved from a position to another, it is reported as + // two mutation records, one with a "removedNodes" and the other with "addedNodes". In this case, + // the node will be in both sets. + const addedAndMovedNodes = new Set() + const removedNodes = new Map() + for (const mutation of mutations) { + mutation.addedNodes.forEach((node) => { + addedAndMovedNodes.add(node) + }) + mutation.removedNodes.forEach((node) => { + if (!addedAndMovedNodes.has(node)) { + removedNodes.set(node, mutation.target) + } + addedAndMovedNodes.delete(node) + }) + } + + // Then, we sort nodes that are still in the document by topological order, for two reasons: + // + // * We will serialize each added nodes with their descendants. We don't want to serialize a node + // twice, so we need to iterate over the parent nodes first and skip any node that is contained in + // a precedent node. + // + // * To emit "add" mutations, we need references to the parent and potential next sibling of each + // added node. So we need to iterate over the parent nodes first, and when multiple nodes are + // siblings, we want to iterate from last to first. This will ensure that any "next" node is + // already serialized and have an id. + const sortedAddedAndMovedNodes = Array.from(addedAndMovedNodes) + sortAddedAndMovedNodes(sortedAddedAndMovedNodes) + + // Then, we iterate over our sorted node sets to emit mutations. We collect the newly serialized + // node ids in a set to be able to skip subsequent related mutations. + transaction.serializedNodeIds = new Set() + + const addedNodeMutations: AddedNodeMutation[] = [] + for (const node of sortedAddedAndMovedNodes) { + if (hasBeenSerialized(node)) { + continue + } + + const parentNodePrivacyLevel = getNodePrivacyLevel( + node.parentNode!, + transaction.scope.configuration.defaultPrivacyLevel, + nodePrivacyLevelCache + ) + if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) { + continue + } + + const serializedNode = serializeNode(node, parentNodePrivacyLevel, transaction) + if (!serializedNode) { + continue + } + + const parentNode = getParentNode(node)! + addedNodeMutations.push({ + nextId: getNextSibling(node), + parentId: transaction.scope.nodeIds.get(parentNode)!, + node: serializedNode, + }) + } + // Finally, we emit remove mutations. + const removedNodeMutations: RemovedNodeMutation[] = [] + removedNodes.forEach((parent, node) => { + const parentId = transaction.scope.nodeIds.get(parent) + const id = transaction.scope.nodeIds.get(node) + if (parentId !== undefined && id !== undefined) { + removedNodeMutations.push({ parentId, id }) + } + }) + + return { adds: addedNodeMutations, removes: removedNodeMutations, hasBeenSerialized } + + function hasBeenSerialized(node: Node) { + const id = transaction.scope.nodeIds.get(node) + return id !== undefined && transaction.serializedNodeIds?.has(id) + } + + function getNextSibling(node: Node): null | number { + let nextSibling = node.nextSibling + while (nextSibling) { + const id = transaction.scope.nodeIds.get(nextSibling) + if (id !== undefined) { + return id + } + nextSibling = nextSibling.nextSibling + } + + return null + } +} + +function processCharacterDataMutations( + mutations: Array>, + nodePrivacyLevelCache: NodePrivacyLevelCache, + transaction: SerializationTransaction +) { + const textMutations: TextMutation[] = [] + + // Deduplicate mutations based on their target node + const handledNodes = new Set() + const filteredMutations = mutations.filter((mutation) => { + if (handledNodes.has(mutation.target)) { + return false + } + handledNodes.add(mutation.target) + return true + }) + + // Emit mutations + for (const mutation of filteredMutations) { + const value = mutation.target.textContent + if (value === mutation.oldValue) { + continue + } + + const id = transaction.scope.nodeIds.get(mutation.target) + if (id === undefined) { + continue + } + + const parentNodePrivacyLevel = getNodePrivacyLevel( + getParentNode(mutation.target)!, + transaction.scope.configuration.defaultPrivacyLevel, + nodePrivacyLevelCache + ) + if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) { + continue + } + + textMutations.push({ + id, + value: getTextContent(mutation.target, parentNodePrivacyLevel) ?? null, + }) + } + + return textMutations +} + +function processAttributesMutations( + mutations: Array>, + nodePrivacyLevelCache: NodePrivacyLevelCache, + transaction: SerializationTransaction +) { + const attributeMutations: AttributeMutation[] = [] + + // Deduplicate mutations based on their target node and changed attribute + const handledElements = new Map>() + const filteredMutations = mutations.filter((mutation) => { + const handledAttributes = handledElements.get(mutation.target) + if (handledAttributes && handledAttributes.has(mutation.attributeName!)) { + return false + } + if (!handledAttributes) { + handledElements.set(mutation.target, new Set([mutation.attributeName!])) + } else { + handledAttributes.add(mutation.attributeName!) + } + return true + }) + + // Emit mutations + const emittedMutations = new Map() + for (const mutation of filteredMutations) { + const uncensoredValue = mutation.target.getAttribute(mutation.attributeName!) + if (uncensoredValue === mutation.oldValue) { + continue + } + + const id = transaction.scope.nodeIds.get(mutation.target) + if (id === undefined) { + continue + } + + const privacyLevel = getNodePrivacyLevel( + mutation.target, + transaction.scope.configuration.defaultPrivacyLevel, + nodePrivacyLevelCache + ) + const attributeValue = serializeAttribute( + mutation.target, + privacyLevel, + mutation.attributeName!, + transaction.scope.configuration + ) + + let transformedValue: string | null + if (mutation.attributeName === 'value') { + const inputValue = getElementInputValue(mutation.target, privacyLevel) + if (inputValue === undefined) { + continue + } + transformedValue = inputValue + } else if (typeof attributeValue === 'string') { + transformedValue = attributeValue + } else { + transformedValue = null + } + + let emittedMutation = emittedMutations.get(mutation.target) + if (!emittedMutation) { + emittedMutation = { id, attributes: {} } + attributeMutations.push(emittedMutation) + emittedMutations.set(mutation.target, emittedMutation) + } + + emittedMutation.attributes[mutation.attributeName!] = transformedValue + } + + return attributeMutations +} + +export function sortAddedAndMovedNodes(nodes: Node[]) { + nodes.sort((a, b) => { + const position = a.compareDocumentPosition(b) + /* eslint-disable no-bitwise */ + if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) { + return -1 + } else if (position & Node.DOCUMENT_POSITION_CONTAINS) { + return 1 + } else if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return 1 + } else if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return -1 + } + /* eslint-enable no-bitwise */ + return 0 + }) +} + +function traverseRemovedShadowDom(removedNode: Node, shadowDomRemovedCallback: RemoveShadowRootCallBack) { + if (isNodeShadowHost(removedNode)) { + shadowDomRemovedCallback(removedNode.shadowRoot) + } + forEachChildNodes(removedNode, (childNode) => traverseRemovedShadowDom(childNode, shadowDomRemovedCallback)) +} + +export function idsAreAssignedForNodeAndAncestors(node: Node, nodeIds: NodeIds): node is NodeWithSerializedNode { + let current: Node | null = node + while (current) { + if (nodeIds.get(current) === undefined && !isNodeShadowRoot(current)) { + return false + } + current = getParentNode(current) + } + return true +} diff --git a/packages/rum/src/domain/record/startFullSnapshots.ts b/packages/rum/src/domain/record/startFullSnapshots.ts index e10d7de96f..6d50e61243 100644 --- a/packages/rum/src/domain/record/startFullSnapshots.ts +++ b/packages/rum/src/domain/record/startFullSnapshots.ts @@ -1,30 +1,31 @@ -import { LifeCycleEventType, getScrollX, getScrollY, getViewportDimension } from '@datadog/browser-rum-core' +import { LifeCycleEventType, getViewportDimension } from '@datadog/browser-rum-core' import type { LifeCycle } from '@datadog/browser-rum-core' import { ExperimentalFeature, isExperimentalFeatureEnabled, timeStampNow } from '@datadog/browser-core' import type { TimeStamp } from '@datadog/browser-core' -import type { BrowserFullSnapshotRecord } from '../../types' import { RecordType } from '../../types' -import type { ChangeSerializationTransaction, SerializationTransaction } from './serialization' -import { - createRootInsertionCursor, - serializeChangesInTransaction, - serializeDocument, - serializeInTransaction, - serializeNodeAsChange, - SerializationKind, -} from './serialization' +import { SerializationKind, serializeFullSnapshotAsChange, serializeFullSnapshot } from './serialization' import { getVisualViewport } from './viewports' import type { RecordingScope } from './recordingScope' import type { EmitRecordCallback, EmitStatsCallback } from './record.types' +export type SerializeFullSnapshotCallback = ( + timestamp: TimeStamp, + kind: SerializationKind, + document: Document, + emitRecord: EmitRecordCallback, + emitStats: EmitStatsCallback, + scope: RecordingScope +) => void + export function startFullSnapshots( lifeCycle: LifeCycle, emitRecord: EmitRecordCallback, emitStats: EmitStatsCallback, flushMutations: () => void, - scope: RecordingScope + scope: RecordingScope, + serialize: SerializeFullSnapshotCallback = defaultSerializeFullSnapshotCallback() ) { - takeFullSnapshot(timeStampNow(), SerializationKind.INITIAL_FULL_SNAPSHOT, emitRecord, emitStats, scope) + takeFullSnapshot(timeStampNow(), SerializationKind.INITIAL_FULL_SNAPSHOT, emitRecord, emitStats, scope, serialize) const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => { flushMutations() @@ -33,7 +34,8 @@ export function startFullSnapshots( SerializationKind.SUBSEQUENT_FULL_SNAPSHOT, emitRecord, emitStats, - scope + scope, + serialize ) }) @@ -47,7 +49,8 @@ export function takeFullSnapshot( kind: SerializationKind, emitRecord: EmitRecordCallback, emitStats: EmitStatsCallback, - scope: RecordingScope + scope: RecordingScope, + serialize: SerializeFullSnapshotCallback = defaultSerializeFullSnapshotCallback() ): void { const { width, height } = getViewportDimension() emitRecord({ @@ -68,28 +71,7 @@ export function takeFullSnapshot( timestamp, }) - if (isExperimentalFeatureEnabled(ExperimentalFeature.USE_CHANGE_RECORDS)) { - scope.resetIds() - serializeChangesInTransaction( - kind, - emitRecord, - emitStats, - scope, - timestamp, - (transaction: ChangeSerializationTransaction) => { - serializeNodeAsChange( - createRootInsertionCursor(scope.nodeIds), - document, - scope.configuration.defaultPrivacyLevel, - transaction - ) - } - ) - } else { - serializeInTransaction(kind, emitRecord, emitStats, scope, (transaction: SerializationTransaction) => { - transaction.add(serializeFullSnapshotRecord(timestamp, transaction)) - }) - } + serialize(timestamp, kind, document, emitRecord, emitStats, scope) if (window.visualViewport) { emitRecord({ @@ -100,19 +82,8 @@ export function takeFullSnapshot( } } -function serializeFullSnapshotRecord( - timestamp: TimeStamp, - transaction: SerializationTransaction -): BrowserFullSnapshotRecord { - return { - data: { - node: serializeDocument(document, transaction), - initialOffset: { - left: getScrollX(), - top: getScrollY(), - }, - }, - type: RecordType.FullSnapshot, - timestamp, - } +function defaultSerializeFullSnapshotCallback(): SerializeFullSnapshotCallback { + return isExperimentalFeatureEnabled(ExperimentalFeature.USE_CHANGE_RECORDS) + ? serializeFullSnapshotAsChange + : serializeFullSnapshot } diff --git a/packages/rum/src/domain/record/test/serialization.specHelper.ts b/packages/rum/src/domain/record/test/serialization.specHelper.ts index 34bfb7a6b6..a1bd7f0f64 100644 --- a/packages/rum/src/domain/record/test/serialization.specHelper.ts +++ b/packages/rum/src/domain/record/test/serialization.specHelper.ts @@ -1,7 +1,9 @@ +import type { TimeStamp } from '@datadog/browser-core' import { noop, timeStampNow } from '@datadog/browser-core' import { RecordType } from '../../../types' import type { BrowserChangeRecord, + BrowserFullSnapshotRecord, BrowserRecord, DocumentNode, ElementNode, @@ -18,8 +20,7 @@ import type { import { serializeNode, SerializationKind, - serializeDocument, - serializeInTransaction, + serializeFullSnapshot, updateSerializationStats, serializeChangesInTransaction, createRootInsertionCursor, @@ -56,19 +57,15 @@ export function createSerializationTransactionForTesting({ } export function takeFullSnapshotForTesting(scope: RecordingScope): DocumentNode & SerializedNodeWithId { - let node: (DocumentNode & SerializedNodeWithId) | null + let node: DocumentNode & SerializedNodeWithId + const emitRecord = (record: BrowserRecord) => { + // Tests want to assert against the serialized node representation of the document, + // not the record that would contain it if we emitted it, so we just extract it here. + const fullSnapshotRecord = record as BrowserFullSnapshotRecord + node = fullSnapshotRecord.data.node as DocumentNode & SerializedNodeWithId + } - serializeInTransaction( - SerializationKind.INITIAL_FULL_SNAPSHOT, - noop, - noop, - scope, - (transaction: SerializationTransaction): void => { - // Tests want to assert against the serialized node representation of the document, - // not the record that would contain it if we emitted it, so don't bother emitting. - node = serializeDocument(document, transaction) - } - ) + serializeFullSnapshot(0 as TimeStamp, SerializationKind.INITIAL_FULL_SNAPSHOT, document, emitRecord, noop, scope) return node! } diff --git a/packages/rum/src/domain/record/trackers/trackMutation.spec.ts b/packages/rum/src/domain/record/trackers/trackMutation.spec.ts index c49d9c8c5b..9d7f4db699 100644 --- a/packages/rum/src/domain/record/trackers/trackMutation.spec.ts +++ b/packages/rum/src/domain/record/trackers/trackMutation.spec.ts @@ -12,6 +12,8 @@ import type { Attributes, BrowserIncrementalSnapshotRecord, BrowserMutationPayload, + DocumentNode, + SerializedNodeWithId, } from '../../../types' import { NodeType } from '../../../types' import type { RecordingScope } from '../recordingScope' @@ -20,7 +22,8 @@ import type { AddShadowRootCallBack, RemoveShadowRootCallBack } from '../shadowR import { appendElement, appendText } from '../../../../../rum-core/test' import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' import { takeFullSnapshotForTesting } from '../test/serialization.specHelper' -import { idsAreAssignedForNodeAndAncestors, sortAddedAndMovedNodes, trackMutation } from './trackMutation' +import { serializeMutations } from '../serialization' +import { trackMutation } from './trackMutation' import type { MutationTracker } from './trackMutation' describe('trackMutation', () => { @@ -48,28 +51,49 @@ describe('trackMutation', () => { }) } - function startMutationCollection(scope: RecordingScope): MutationTracker { - const mutationTracker = trackMutation(document, emitRecordCallback, emitStatsCallback, scope) - registerCleanupTask(() => { - mutationTracker.stop() - }) - return mutationTracker - } - function getLatestMutationPayload(): BrowserMutationPayload { const latestRecord = emitRecordCallback.calls.mostRecent()?.args[0] as BrowserIncrementalSnapshotRecord return latestRecord.data as BrowserMutationPayload } - describe('childList mutation records', () => { - it('emits a mutation when a node is appended to a known node', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) + function recordMutation( + mutation: () => void, + options: { + mutationBeforeTrackingStarts?: () => void + scope?: RecordingScope + skipFlush?: boolean + } = {} + ): { + mutationTracker: MutationTracker + serializedDocument: DocumentNode & SerializedNodeWithId + } { + const scope = options.scope || getRecordingScope() + + const serializedDocument = takeFullSnapshotForTesting(scope) + + if (options.mutationBeforeTrackingStarts) { + options.mutationBeforeTrackingStarts() + } + + const mutationTracker = trackMutation(document, emitRecordCallback, emitStatsCallback, scope, serializeMutations) + registerCleanupTask(() => { + mutationTracker.stop() + }) + + mutation() - appendElement('
', sandbox) + if (!options.skipFlush) { mutationTracker.flush() + } + + return { mutationTracker, serializedDocument } + } + describe('childList mutation records', () => { + it('emits a mutation when a node is appended to a known node', () => { + const { serializedDocument } = recordMutation(() => { + appendElement('
', sandbox) + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument) @@ -83,14 +107,78 @@ describe('trackMutation', () => { }) }) - it('emits serialization stats with mutations', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) + it('emits add node mutations in the expected order', () => { + const a = appendElement('', sandbox) + const aa = appendElement('', a) + const b = appendElement('', sandbox) + const bb = appendElement('', b) + const c = appendElement('', sandbox) + const cc = appendElement('', c) + + const { serializedDocument } = recordMutation(() => { + const ab = document.createElement('ab') + const ac = document.createElement('ac') + const ba = document.createElement('ba') + const bc = document.createElement('bc') + const ca = document.createElement('ca') + const cb = document.createElement('cb') + + cc.before(cb) + aa.after(ac) + bb.before(ba) + aa.after(ab) + cb.before(ca) + bb.after(bc) + }) + expect(emitRecordCallback).toHaveBeenCalledTimes(1) + + const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument) + const cb = expectNewNode({ type: NodeType.Element, tagName: 'cb' }) + const ca = expectNewNode({ type: NodeType.Element, tagName: 'ca' }) + const bc = expectNewNode({ type: NodeType.Element, tagName: 'bc' }) + const ba = expectNewNode({ type: NodeType.Element, tagName: 'ba' }) + const ac = expectNewNode({ type: NodeType.Element, tagName: 'ac' }) + const ab = expectNewNode({ type: NodeType.Element, tagName: 'ab' }) + validate(getLatestMutationPayload(), { + adds: [ + { + parent: expectInitialNode({ tag: 'c' }), + node: cb, + next: expectInitialNode({ tag: 'cc' }), + }, + { + parent: expectInitialNode({ tag: 'c' }), + node: ca, + next: cb, + }, + { + parent: expectInitialNode({ tag: 'b' }), + node: bc, + }, + { + parent: expectInitialNode({ tag: 'b' }), + node: ba, + next: expectInitialNode({ tag: 'bb' }), + }, + { + parent: expectInitialNode({ tag: 'a' }), + node: ac, + }, + { + parent: expectInitialNode({ tag: 'a' }), + node: ab, + next: ac, + }, + ], + }) + }) + it('emits serialization stats with mutations', () => { const cssText = 'body { width: 100%; }' - appendElement(``, sandbox) - mutationTracker.flush() + + const { serializedDocument } = recordMutation(() => { + appendElement(``, sandbox) + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) @@ -115,11 +203,12 @@ describe('trackMutation', () => { }) it('processes mutations asynchronously', async () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - startMutationCollection(scope) - - appendElement('
', sandbox) + recordMutation( + () => { + appendElement('
', sandbox) + }, + { skipFlush: true } + ) expect(emitRecordCallback).not.toHaveBeenCalled() @@ -127,28 +216,40 @@ describe('trackMutation', () => { }) it('does not emit a mutation when a node is appended to a unknown node', () => { - const scope = getRecordingScope() - - // Here, we don't call takeFullSnapshotForTesting(), so the sandbox is 'unknown'. - const mutationTracker = startMutationCollection(scope) + const unknownNode = document.createElement('div') + registerCleanupTask(() => { + unknownNode.remove() + }) - appendElement('
', sandbox) - mutationTracker.flush() + recordMutation( + () => { + appendElement('
', unknownNode) + }, + { + mutationBeforeTrackingStarts() { + // Append the node after the full snapshot, but before tracking starts, + // rendering it 'unknown'. + sandbox.appendChild(unknownNode) + }, + } + ) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('emits buffered mutation records on flush', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - appendElement('
', sandbox) + const { mutationTracker } = recordMutation( + () => { + appendElement('
', sandbox) + }, + { + skipFlush: true, + } + ) expect(emitRecordCallback).toHaveBeenCalledTimes(0) mutationTracker.flush() - expect(emitRecordCallback).toHaveBeenCalledTimes(1) }) @@ -156,13 +257,10 @@ describe('trackMutation', () => { it('attribute mutations', () => { const element = appendElement('
', sandbox) - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - element.setAttribute('foo', 'bar') - sandbox.remove() - mutationTracker.flush() + recordMutation(() => { + element.setAttribute('foo', 'bar') + sandbox.remove() + }) expect(getLatestMutationPayload().attributes).toEqual([]) }) @@ -170,25 +268,19 @@ describe('trackMutation', () => { it('text mutations', () => { const textNode = appendText('text', sandbox) - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'bar' - sandbox.remove() - mutationTracker.flush() + recordMutation(() => { + textNode.data = 'bar' + sandbox.remove() + }) expect(getLatestMutationPayload().texts).toEqual([]) }) it('add mutations', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - appendElement('

', sandbox) - sandbox.remove() - mutationTracker.flush() + recordMutation(() => { + appendElement('

', sandbox) + sandbox.remove() + }) expect(getLatestMutationPayload().adds).toEqual([]) }) @@ -196,13 +288,10 @@ describe('trackMutation', () => { it('remove mutations', () => { const element = appendElement('
', sandbox) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - element.remove() - sandbox.remove() - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + element.remove() + sandbox.remove() + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -225,15 +314,11 @@ describe('trackMutation', () => { it('attribute mutations', () => { const element = appendElement('
', sandbox) - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - element.remove() - sandbox.appendChild(element) - - element.setAttribute('foo', 'bar') - mutationTracker.flush() + recordMutation(() => { + element.remove() + sandbox.appendChild(element) + element.setAttribute('foo', 'bar') + }) expect(getLatestMutationPayload().attributes).toEqual([]) }) @@ -241,15 +326,11 @@ describe('trackMutation', () => { it('text mutations', () => { const textNode = appendText('foo', sandbox) - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - textNode.remove() - sandbox.appendChild(textNode) - - textNode.data = 'bar' - mutationTracker.flush() + recordMutation(() => { + textNode.remove() + sandbox.appendChild(textNode) + textNode.data = 'bar' + }) expect(getLatestMutationPayload().texts).toEqual([]) }) @@ -258,17 +339,14 @@ describe('trackMutation', () => { const child = appendElement('', sandbox) const parent = child.parentElement! - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - // Generate a mutation on 'child' - child.remove() - parent.appendChild(child) - // Generate a mutation on 'parent' - parent.remove() - sandbox.appendChild(parent) - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + // Generate a mutation on 'child' + child.remove() + parent.appendChild(child) + // Generate a mutation on 'parent' + parent.remove() + sandbox.appendChild(parent) + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) @@ -295,14 +373,10 @@ describe('trackMutation', () => { }) it('remove mutations', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - const child = appendElement('', sandbox) - - child.remove() - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + const child = appendElement('', sandbox) + child.remove() + }) const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -317,16 +391,11 @@ describe('trackMutation', () => { }) it('emits only an "add" mutation when adding, removing then re-adding a child', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - const element = appendElement('', sandbox) - - element.remove() - sandbox.appendChild(element) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + const element = appendElement('', sandbox) + element.remove() + sandbox.appendChild(element) + }) const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -342,14 +411,10 @@ describe('trackMutation', () => { it('emits an "add" and a "remove" mutation when moving a node', () => { const a = appendElement('', sandbox) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - // Moves 'a' after 'b' - sandbox.appendChild(a) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + // Moves 'a' after 'b' + sandbox.appendChild(a) + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -378,14 +443,10 @@ describe('trackMutation', () => { const a = span.nextElementSibling! const b = a.nextElementSibling! - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - a.appendChild(span) - b.appendChild(span) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + a.appendChild(span) + b.appendChild(span) + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -405,13 +466,9 @@ describe('trackMutation', () => { }) it('keep nodes order when adding multiple sibling nodes', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - appendElement('', sandbox) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + appendElement('', sandbox) + }) const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument) const c = expectNewNode({ type: NodeType.Element, tagName: 'c' }) @@ -438,12 +495,12 @@ describe('trackMutation', () => { }) it('respects the default privacy level setting', () => { - const scope = getRecordingScope(DefaultPrivacyLevel.MASK) - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.innerText = 'foo bar' - mutationTracker.flush() + const { serializedDocument } = recordMutation( + () => { + sandbox.innerText = 'foo bar' + }, + { scope: getRecordingScope(DefaultPrivacyLevel.MASK) } + ) const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -461,14 +518,12 @@ describe('trackMutation', () => { describe('for shadow DOM', () => { it('should call addShadowRoot when host is added', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - const host = appendElement('
', sandbox) - const shadowRoot = host.attachShadow({ mode: 'open' }) - appendElement('', shadowRoot) - mutationTracker.flush() + let shadowRoot: ShadowRoot + const { serializedDocument } = recordMutation(() => { + const host = appendElement('
', sandbox) + shadowRoot = host.attachShadow({ mode: 'open' }) + appendElement('', shadowRoot) + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument) @@ -484,7 +539,7 @@ describe('trackMutation', () => { }, ], }) - expect(addShadowRootSpy).toHaveBeenCalledOnceWith(shadowRoot, jasmine.anything()) + expect(addShadowRootSpy).toHaveBeenCalledOnceWith(shadowRoot!, jasmine.anything()) expect(removeShadowRootSpy).not.toHaveBeenCalled() }) @@ -493,12 +548,10 @@ describe('trackMutation', () => { const shadowRoot = host.attachShadow({ mode: 'open' }) appendElement('', shadowRoot) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) + const { serializedDocument } = recordMutation(() => { + host.remove() + }) - host.remove() - mutationTracker.flush() expect(emitRecordCallback).toHaveBeenCalledTimes(1) expect(addShadowRootSpy).toHaveBeenCalledTimes(1) @@ -520,12 +573,10 @@ describe('trackMutation', () => { const shadowRoot = host.attachShadow({ mode: 'open' }) appendElement('', shadowRoot) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) + const { serializedDocument } = recordMutation(() => { + host.parentElement!.remove() + }) - host.parentElement!.remove() - mutationTracker.flush() expect(emitRecordCallback).toHaveBeenCalledTimes(1) expect(addShadowRootSpy).toHaveBeenCalledTimes(1) @@ -548,12 +599,10 @@ describe('trackMutation', () => { const childHost = appendElement('', parentHost.querySelector('p')!) const childShadowRoot = childHost.attachShadow({ mode: 'open' }) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) + const { serializedDocument } = recordMutation(() => { + parentHost.remove() + }) - parentHost.remove() - mutationTracker.flush() expect(emitRecordCallback).toHaveBeenCalledTimes(1) expect(addShadowRootSpy).toHaveBeenCalledTimes(2) @@ -585,12 +634,9 @@ describe('trackMutation', () => { }) it('emits a mutation when a text node is changed', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'bar' - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + textNode.data = 'bar' + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) @@ -608,37 +654,31 @@ describe('trackMutation', () => { it('emits a mutation when an empty text node is changed', () => { textNode.data = '' - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'bar' - mutationTracker.flush() + recordMutation(() => { + textNode.data = 'bar' + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) }) it('does not emit a mutation when a text node keeps the same value', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'bar' - textNode.data = 'foo' - mutationTracker.flush() + recordMutation(() => { + textNode.data = 'bar' + textNode.data = 'foo' + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('respects the default privacy level setting', () => { const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - - scope.configuration.defaultPrivacyLevel = DefaultPrivacyLevel.MASK - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'foo bar' - mutationTracker.flush() + const { serializedDocument } = recordMutation( + () => { + scope.configuration.defaultPrivacyLevel = DefaultPrivacyLevel.MASK + textNode.data = 'foo bar' + }, + { scope } + ) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -655,12 +695,12 @@ describe('trackMutation', () => { sandbox.setAttribute('data-dd-privacy', 'allow') const div = appendElement('
foo 81
', sandbox) - const scope = getRecordingScope(DefaultPrivacyLevel.MASK) - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - div.firstChild!.textContent = 'bazz 7' - mutationTracker.flush() + const { serializedDocument } = recordMutation( + () => { + div.firstChild!.textContent = 'bazz 7' + }, + { scope: getRecordingScope(DefaultPrivacyLevel.MASK) } + ) expect(emitRecordCallback).toHaveBeenCalledTimes(1) @@ -678,12 +718,9 @@ describe('trackMutation', () => { describe('attributes mutations', () => { it('emits a mutation when an attribute is changed', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.setAttribute('foo', 'bar') - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.setAttribute('foo', 'bar') + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) @@ -699,12 +736,9 @@ describe('trackMutation', () => { }) it('emits a mutation with an empty string when an attribute is changed to an empty string', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.setAttribute('foo', '') - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.setAttribute('foo', '') + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -720,12 +754,9 @@ describe('trackMutation', () => { it('emits a mutation with `null` when an attribute is removed', () => { sandbox.setAttribute('foo', 'bar') - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.removeAttribute('foo') - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.removeAttribute('foo') + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -741,25 +772,19 @@ describe('trackMutation', () => { it('does not emit a mutation when an attribute keeps the same value', () => { sandbox.setAttribute('foo', 'bar') - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.setAttribute('foo', 'biz') - sandbox.setAttribute('foo', 'bar') - mutationTracker.flush() + recordMutation(() => { + sandbox.setAttribute('foo', 'biz') + sandbox.setAttribute('foo', 'bar') + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('reuse the same mutation when multiple attributes are changed', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.setAttribute('foo1', 'biz') - sandbox.setAttribute('foo2', 'bar') - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.setAttribute('foo1', 'biz') + sandbox.setAttribute('foo2', 'bar') + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -773,12 +798,12 @@ describe('trackMutation', () => { }) it('respects the default privacy level setting', () => { - const scope = getRecordingScope(DefaultPrivacyLevel.MASK) - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.setAttribute('data-foo', 'biz') - mutationTracker.flush() + const { serializedDocument } = recordMutation( + () => { + sandbox.setAttribute('data-foo', 'biz') + }, + { scope: getRecordingScope(DefaultPrivacyLevel.MASK) } + ) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -800,13 +825,9 @@ describe('trackMutation', () => { }) it('skips ignored nodes when looking for the next id', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.insertBefore(document.createElement('a'), ignoredElement) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.insertBefore(document.createElement('a'), ignoredElement) + }) const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -823,54 +844,36 @@ describe('trackMutation', () => { it('when adding an ignored node', () => { ignoredElement.remove() - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.appendChild(ignoredElement) - - mutationTracker.flush() + recordMutation(() => { + sandbox.appendChild(ignoredElement) + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('when changing the attributes of an ignored node', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - ignoredElement.setAttribute('foo', 'bar') - - mutationTracker.flush() + recordMutation(() => { + ignoredElement.setAttribute('foo', 'bar') + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('when adding a new child node', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - appendElement("'function foo() {}'", ignoredElement) - - mutationTracker.flush() + recordMutation(() => { + appendElement("'function foo() {}'", ignoredElement) + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('when mutating a known child node', () => { const textNode = appendText('function foo() {}', sandbox) - - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - ignoredElement.appendChild(textNode) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'function bar() {}' - - mutationTracker.flush() + recordMutation(() => { + textNode.data = 'function bar() {}' + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) @@ -878,13 +881,9 @@ describe('trackMutation', () => { it('when adding a known child node', () => { const textNode = appendText('function foo() {}', sandbox) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - ignoredElement.appendChild(textNode) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + ignoredElement.appendChild(textNode) + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -900,12 +899,9 @@ describe('trackMutation', () => { it('when moving an ignored node', () => { const script = appendElement('', sandbox) - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.appendChild(script) - mutationTracker.flush() + recordMutation(() => { + sandbox.appendChild(script) + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) @@ -919,43 +915,29 @@ describe('trackMutation', () => { }) it('does not emit attribute mutations on hidden nodes', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - hiddenElement.setAttribute('foo', 'bar') - - mutationTracker.flush() + recordMutation(() => { + hiddenElement.setAttribute('foo', 'bar') + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) describe('does not emit mutations occurring in hidden node', () => { it('when adding a new node', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - appendElement('function foo() {}', hiddenElement) - - mutationTracker.flush() + recordMutation(() => { + appendElement('function foo() {}', hiddenElement) + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('when mutating a known child node', () => { const textNode = appendText('function foo() {}', sandbox) - - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - hiddenElement.appendChild(textNode) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'function bar() {}' - - mutationTracker.flush() + recordMutation(() => { + textNode.data = 'function bar() {}' + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) @@ -963,13 +945,9 @@ describe('trackMutation', () => { it('when moving a known node into an hidden node', () => { const textNode = appendText('function foo() {}', sandbox) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - hiddenElement.appendChild(textNode) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + hiddenElement.appendChild(textNode) + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -1054,12 +1032,9 @@ describe('trackMutation', () => { sandbox.setAttribute(PRIVACY_ATTR_NAME, privacyAttributeValue) } - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.appendChild(input) - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.appendChild(input) + }) const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -1086,12 +1061,9 @@ describe('trackMutation', () => { } sandbox.appendChild(input) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - input.setAttribute('value', 'bar') - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + input.setAttribute('value', 'bar') + }) if (expectedAttributesMutation) { const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) @@ -1106,113 +1078,3 @@ describe('trackMutation', () => { } }) }) - -describe('sortAddedAndMovedNodes', () => { - let parent: Node - let a: Node - let aa: Node - let b: Node - let c: Node - let d: Node - - beforeEach(() => { - // Create a tree like this: - // parent - // / | \ \ - // a b c d - // | - // aa - a = document.createElement('a') - aa = document.createElement('aa') - b = document.createElement('b') - c = document.createElement('c') - d = document.createElement('d') - parent = document.createElement('parent') - parent.appendChild(a) - a.appendChild(aa) - parent.appendChild(b) - parent.appendChild(c) - parent.appendChild(d) - }) - - it('sorts siblings in reverse order', () => { - const nodes = [c, b, d, a] - sortAddedAndMovedNodes(nodes) - expect(nodes).toEqual([d, c, b, a]) - }) - - it('sorts parents', () => { - const nodes = [a, parent, aa] - sortAddedAndMovedNodes(nodes) - expect(nodes).toEqual([parent, a, aa]) - }) - - it('sorts parents first then siblings', () => { - const nodes = [c, aa, b, parent, d, a] - sortAddedAndMovedNodes(nodes) - expect(nodes).toEqual([parent, d, c, b, a, aa]) - }) -}) - -describe('idsAreAssignedForNodeAndAncestors', () => { - let scope: RecordingScope - - beforeEach(() => { - scope = createRecordingScopeForTesting() - }) - - it('returns false for DOM Nodes that have not been assigned an id', () => { - expect(idsAreAssignedForNodeAndAncestors(document.createElement('div'), scope.nodeIds)).toBe(false) - }) - - it('returns true for DOM Nodes that have been assigned an id', () => { - const node = document.createElement('div') - scope.nodeIds.getOrInsert(node) - expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) - }) - - it('returns false for DOM Nodes when an ancestor has not been assigned an id', () => { - const node = document.createElement('div') - scope.nodeIds.getOrInsert(node) - - const parent = document.createElement('div') - parent.appendChild(node) - scope.nodeIds.getOrInsert(parent) - - const grandparent = document.createElement('div') - grandparent.appendChild(parent) - - expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(false) - }) - - it('returns true for DOM Nodes when all ancestors have been assigned an id', () => { - const node = document.createElement('div') - scope.nodeIds.getOrInsert(node) - - const parent = document.createElement('div') - parent.appendChild(node) - scope.nodeIds.getOrInsert(parent) - - const grandparent = document.createElement('div') - grandparent.appendChild(parent) - scope.nodeIds.getOrInsert(grandparent) - - expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) - }) - - it('returns true for DOM Nodes in shadow subtrees', () => { - const node = document.createElement('div') - scope.nodeIds.getOrInsert(node) - - const parent = document.createElement('div') - parent.appendChild(node) - scope.nodeIds.getOrInsert(parent) - - const grandparent = document.createElement('div') - const shadowRoot = grandparent.attachShadow({ mode: 'open' }) - shadowRoot.appendChild(parent) - scope.nodeIds.getOrInsert(grandparent) - - expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) - }) -}) diff --git a/packages/rum/src/domain/record/trackers/trackMutation.ts b/packages/rum/src/domain/record/trackers/trackMutation.ts index e8194d6d28..29f9e48432 100644 --- a/packages/rum/src/domain/record/trackers/trackMutation.ts +++ b/packages/rum/src/domain/record/trackers/trackMutation.ts @@ -1,50 +1,23 @@ -import { monitor, noop } from '@datadog/browser-core' -import type { - NodePrivacyLevelCache, - RumMutationRecord, - RumChildListMutationRecord, - RumCharacterDataMutationRecord, - RumAttributesMutationRecord, -} from '@datadog/browser-rum-core' -import { - isNodeShadowHost, - getMutationObserverConstructor, - getParentNode, - forEachChildNodes, - getNodePrivacyLevel, - getTextContent, - NodePrivacyLevel, - isNodeShadowRoot, -} from '@datadog/browser-rum-core' -import { IncrementalSource } from '../../../types' -import type { - BrowserMutationData, - AddedNodeMutation, - AttributeMutation, - RemovedNodeMutation, - TextMutation, -} from '../../../types' +import type { TimeStamp } from '@datadog/browser-core' +import { monitor, noop, timeStampNow } from '@datadog/browser-core' +import type { RumMutationRecord } from '@datadog/browser-rum-core' +import { getMutationObserverConstructor } from '@datadog/browser-rum-core' import type { RecordingScope } from '../recordingScope' -import type { SerializationTransaction } from '../serialization' -import { - getElementInputValue, - serializeAttribute, - serializeInTransaction, - serializeNode, - SerializationKind, -} from '../serialization' import { createMutationBatch } from '../mutationBatch' -import type { RemoveShadowRootCallBack } from '../shadowRootsController' -import { assembleIncrementalSnapshot } from '../assembly' import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' -import type { NodeId, NodeIds } from '../itemIds' +import { serializeMutations } from '../serialization' import type { Tracker } from './tracker.types' -export type NodeWithSerializedNode = Node & { __brand: 'NodeWithSerializedNode' } -type WithSerializedTarget = T & { target: NodeWithSerializedNode } - export type MutationTracker = Tracker & { flush: () => void } +export type SerializeMutationsCallback = ( + timestamp: TimeStamp, + mutations: RumMutationRecord[], + emitRecord: EmitRecordCallback, + emitStats: EmitStatsCallback, + scope: RecordingScope +) => void + /** * Buffers and aggregate mutations generated by a MutationObserver into MutationPayload */ @@ -52,7 +25,8 @@ export function trackMutation( target: Node, emitRecord: EmitRecordCallback, emitStats: EmitStatsCallback, - scope: RecordingScope + scope: RecordingScope, + serialize: SerializeMutationsCallback = defaultSerializeMutationsCallback() ): MutationTracker { const MutationObserver = getMutationObserverConstructor() if (!MutationObserver) { @@ -60,13 +34,12 @@ export function trackMutation( } const mutationBatch = createMutationBatch((mutations) => { - serializeInTransaction( - SerializationKind.INCREMENTAL_SNAPSHOT, + serialize( + timeStampNow(), + mutations.concat(observer.takeRecords() as RumMutationRecord[]), emitRecord, emitStats, - scope, - (transaction: SerializationTransaction) => - processMutations(mutations.concat(observer.takeRecords() as RumMutationRecord[]), transaction) + scope ) }) @@ -92,329 +65,6 @@ export function trackMutation( } } -function processMutations(mutations: RumMutationRecord[], transaction: SerializationTransaction): void { - const nodePrivacyLevelCache: NodePrivacyLevelCache = new Map() - - mutations - .filter((mutation): mutation is RumChildListMutationRecord => mutation.type === 'childList') - .forEach((mutation) => { - mutation.removedNodes.forEach((removedNode) => { - traverseRemovedShadowDom(removedNode, transaction.scope.shadowRootsController.removeShadowRoot) - }) - }) - - // 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 - // part of a mutation occurring in a parent Node - // * should be hidden or ignored - const filteredMutations = mutations.filter( - (mutation): mutation is WithSerializedTarget => - mutation.target.isConnected && - idsAreAssignedForNodeAndAncestors(mutation.target, transaction.scope.nodeIds) && - getNodePrivacyLevel( - mutation.target, - transaction.scope.configuration.defaultPrivacyLevel, - nodePrivacyLevelCache - ) !== NodePrivacyLevel.HIDDEN - ) - - const { adds, removes, hasBeenSerialized } = processChildListMutations( - filteredMutations.filter( - (mutation): mutation is WithSerializedTarget => mutation.type === 'childList' - ), - nodePrivacyLevelCache, - transaction - ) - - const texts = processCharacterDataMutations( - filteredMutations.filter( - (mutation): mutation is WithSerializedTarget => - mutation.type === 'characterData' && !hasBeenSerialized(mutation.target) - ), - nodePrivacyLevelCache, - transaction - ) - - const attributes = processAttributesMutations( - filteredMutations.filter( - (mutation): mutation is WithSerializedTarget => - mutation.type === 'attributes' && !hasBeenSerialized(mutation.target) - ), - nodePrivacyLevelCache, - transaction - ) - - if (!texts.length && !attributes.length && !removes.length && !adds.length) { - return - } - - transaction.add( - assembleIncrementalSnapshot(IncrementalSource.Mutation, { - adds, - removes, - texts, - attributes, - }) - ) -} - -function processChildListMutations( - mutations: Array>, - nodePrivacyLevelCache: NodePrivacyLevelCache, - transaction: SerializationTransaction -) { - // First, we iterate over mutations to collect: - // - // * nodes that have been added in the document and not removed by a subsequent mutation - // * nodes that have been removed from the document but were not added in a previous mutation - // - // For this second category, we also collect their previous parent (mutation.target) because we'll - // need it to emit a 'remove' mutation. - // - // Those two categories may overlap: if a node moved from a position to another, it is reported as - // two mutation records, one with a "removedNodes" and the other with "addedNodes". In this case, - // the node will be in both sets. - const addedAndMovedNodes = new Set() - const removedNodes = new Map() - for (const mutation of mutations) { - mutation.addedNodes.forEach((node) => { - addedAndMovedNodes.add(node) - }) - mutation.removedNodes.forEach((node) => { - if (!addedAndMovedNodes.has(node)) { - removedNodes.set(node, mutation.target) - } - addedAndMovedNodes.delete(node) - }) - } - - // Then, we sort nodes that are still in the document by topological order, for two reasons: - // - // * We will serialize each added nodes with their descendants. We don't want to serialize a node - // twice, so we need to iterate over the parent nodes first and skip any node that is contained in - // a precedent node. - // - // * To emit "add" mutations, we need references to the parent and potential next sibling of each - // added node. So we need to iterate over the parent nodes first, and when multiple nodes are - // siblings, we want to iterate from last to first. This will ensure that any "next" node is - // already serialized and have an id. - const sortedAddedAndMovedNodes = Array.from(addedAndMovedNodes) - sortAddedAndMovedNodes(sortedAddedAndMovedNodes) - - // Then, we iterate over our sorted node sets to emit mutations. We collect the newly serialized - // node ids in a set to be able to skip subsequent related mutations. - transaction.serializedNodeIds = new Set() - - const addedNodeMutations: AddedNodeMutation[] = [] - for (const node of sortedAddedAndMovedNodes) { - if (hasBeenSerialized(node)) { - continue - } - - const parentNodePrivacyLevel = getNodePrivacyLevel( - node.parentNode!, - transaction.scope.configuration.defaultPrivacyLevel, - nodePrivacyLevelCache - ) - if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) { - continue - } - - const serializedNode = serializeNode(node, parentNodePrivacyLevel, transaction) - if (!serializedNode) { - continue - } - - const parentNode = getParentNode(node)! - addedNodeMutations.push({ - nextId: getNextSibling(node), - parentId: transaction.scope.nodeIds.get(parentNode)!, - node: serializedNode, - }) - } - // Finally, we emit remove mutations. - const removedNodeMutations: RemovedNodeMutation[] = [] - removedNodes.forEach((parent, node) => { - const parentId = transaction.scope.nodeIds.get(parent) - const id = transaction.scope.nodeIds.get(node) - if (parentId !== undefined && id !== undefined) { - removedNodeMutations.push({ parentId, id }) - } - }) - - return { adds: addedNodeMutations, removes: removedNodeMutations, hasBeenSerialized } - - function hasBeenSerialized(node: Node) { - const id = transaction.scope.nodeIds.get(node) - return id !== undefined && transaction.serializedNodeIds?.has(id) - } - - function getNextSibling(node: Node): null | number { - let nextSibling = node.nextSibling - while (nextSibling) { - const id = transaction.scope.nodeIds.get(nextSibling) - if (id !== undefined) { - return id - } - nextSibling = nextSibling.nextSibling - } - - return null - } -} - -function processCharacterDataMutations( - mutations: Array>, - nodePrivacyLevelCache: NodePrivacyLevelCache, - transaction: SerializationTransaction -) { - const textMutations: TextMutation[] = [] - - // Deduplicate mutations based on their target node - const handledNodes = new Set() - const filteredMutations = mutations.filter((mutation) => { - if (handledNodes.has(mutation.target)) { - return false - } - handledNodes.add(mutation.target) - return true - }) - - // Emit mutations - for (const mutation of filteredMutations) { - const value = mutation.target.textContent - if (value === mutation.oldValue) { - continue - } - - const id = transaction.scope.nodeIds.get(mutation.target) - if (id === undefined) { - continue - } - - const parentNodePrivacyLevel = getNodePrivacyLevel( - getParentNode(mutation.target)!, - transaction.scope.configuration.defaultPrivacyLevel, - nodePrivacyLevelCache - ) - if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) { - continue - } - - textMutations.push({ - id, - value: getTextContent(mutation.target, parentNodePrivacyLevel) ?? null, - }) - } - - return textMutations -} - -function processAttributesMutations( - mutations: Array>, - nodePrivacyLevelCache: NodePrivacyLevelCache, - transaction: SerializationTransaction -) { - const attributeMutations: AttributeMutation[] = [] - - // Deduplicate mutations based on their target node and changed attribute - const handledElements = new Map>() - const filteredMutations = mutations.filter((mutation) => { - const handledAttributes = handledElements.get(mutation.target) - if (handledAttributes && handledAttributes.has(mutation.attributeName!)) { - return false - } - if (!handledAttributes) { - handledElements.set(mutation.target, new Set([mutation.attributeName!])) - } else { - handledAttributes.add(mutation.attributeName!) - } - return true - }) - - // Emit mutations - const emittedMutations = new Map() - for (const mutation of filteredMutations) { - const uncensoredValue = mutation.target.getAttribute(mutation.attributeName!) - if (uncensoredValue === mutation.oldValue) { - continue - } - - const id = transaction.scope.nodeIds.get(mutation.target) - if (id === undefined) { - continue - } - - const privacyLevel = getNodePrivacyLevel( - mutation.target, - transaction.scope.configuration.defaultPrivacyLevel, - nodePrivacyLevelCache - ) - const attributeValue = serializeAttribute( - mutation.target, - privacyLevel, - mutation.attributeName!, - transaction.scope.configuration - ) - - let transformedValue: string | null - if (mutation.attributeName === 'value') { - const inputValue = getElementInputValue(mutation.target, privacyLevel) - if (inputValue === undefined) { - continue - } - transformedValue = inputValue - } else if (typeof attributeValue === 'string') { - transformedValue = attributeValue - } else { - transformedValue = null - } - - let emittedMutation = emittedMutations.get(mutation.target) - if (!emittedMutation) { - emittedMutation = { id, attributes: {} } - attributeMutations.push(emittedMutation) - emittedMutations.set(mutation.target, emittedMutation) - } - - emittedMutation.attributes[mutation.attributeName!] = transformedValue - } - - return attributeMutations -} - -export function sortAddedAndMovedNodes(nodes: Node[]) { - nodes.sort((a, b) => { - const position = a.compareDocumentPosition(b) - /* eslint-disable no-bitwise */ - if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) { - return -1 - } else if (position & Node.DOCUMENT_POSITION_CONTAINS) { - return 1 - } else if (position & Node.DOCUMENT_POSITION_FOLLOWING) { - return 1 - } else if (position & Node.DOCUMENT_POSITION_PRECEDING) { - return -1 - } - /* eslint-enable no-bitwise */ - return 0 - }) -} - -function traverseRemovedShadowDom(removedNode: Node, shadowDomRemovedCallback: RemoveShadowRootCallBack) { - if (isNodeShadowHost(removedNode)) { - shadowDomRemovedCallback(removedNode.shadowRoot) - } - forEachChildNodes(removedNode, (childNode) => traverseRemovedShadowDom(childNode, shadowDomRemovedCallback)) -} - -export function idsAreAssignedForNodeAndAncestors(node: Node, nodeIds: NodeIds): node is NodeWithSerializedNode { - let current: Node | null = node - while (current) { - if (nodeIds.get(current) === undefined && !isNodeShadowRoot(current)) { - return false - } - current = getParentNode(current) - } - return true +function defaultSerializeMutationsCallback(): SerializeMutationsCallback { + return serializeMutations }