diff --git a/packages/rum/src/domain/record/serialization/conversions/changeConverter.ts b/packages/rum/src/domain/record/serialization/conversions/changeConverter.ts new file mode 100644 index 0000000000..9cb8580f90 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/changeConverter.ts @@ -0,0 +1,269 @@ +import type { + AddDocTypeNodeChange, + AddElementNodeChange, + AddNodeChange, + AddStyleSheetChange, + AddTextNodeChange, + AttachedStyleSheetsChange, + AttributeChange, + BrowserChangeRecord, + BrowserFullSnapshotRecord, + BrowserIncrementalSnapshotRecord, + MediaPlaybackStateChange, + RemoveNodeChange, + ScrollPositionChange, + SizeChange, + TextChange, +} from '../../../../types' +import { ChangeType } from '../../../../types' +import type { NodeId, StyleSheetId } from '../../itemIds' +import type { V1RenderOptions } from './renderOptions' +import type { StringTable } from './stringTable' +import { createStringTable } from './stringTable' +import type { VDocument } from './vDocument' +import { createVDocument } from './vDocument' +import type { VNode } from './vNode' + +export interface ChangeConverter { + convert( + record: BrowserChangeRecord, + options?: Partial + ): BrowserFullSnapshotRecord | BrowserIncrementalSnapshotRecord + + document: VDocument + stringTable: StringTable +} + +export function createChangeConverter(): ChangeConverter { + const self: ChangeConverter = { + convert( + record: BrowserChangeRecord, + options: Partial = {} + ): BrowserFullSnapshotRecord | BrowserIncrementalSnapshotRecord { + applyChangeToVDOM(record, self.document, self.stringTable) + return self.document.render({ timestamp: record.timestamp, ...options }) + }, + + document: createVDocument(), + stringTable: createStringTable(), + } + + return self +} + +function applyChangeToVDOM(record: BrowserChangeRecord, document: VDocument, stringTable: StringTable): void { + document.mutations.clear() + + for (const change of record.data) { + switch (change[0]) { + case ChangeType.AddString: { + for (let i = 1; i < change.length; i++) { + stringTable.add(change[i] as string) + } + break + } + + case ChangeType.AddNode: { + for (let i = 1; i < change.length; i++) { + applyAddNodeChange(change[i] as AddNodeChange, document, stringTable) + } + break + } + + case ChangeType.RemoveNode: { + for (let i = 1; i < change.length; i++) { + applyRemoveNodeChange(change[i], document) + } + break + } + + case ChangeType.Attribute: { + for (let i = 1; i < change.length; i++) { + applyAttributeChange(change[i] as AttributeChange, document, stringTable) + } + break + } + + case ChangeType.Text: { + for (let i = 1; i < change.length; i++) { + applyTextChange(change[i] as TextChange, document, stringTable) + } + break + } + + case ChangeType.ScrollPosition: { + for (let i = 1; i < change.length; i++) { + applyScrollPositionChange(change[i] as ScrollPositionChange, document) + } + break + } + + case ChangeType.Size: { + for (let i = 1; i < change.length; i++) { + applySizeChange(change[i] as SizeChange, document) + } + break + } + + case ChangeType.AddStyleSheet: { + for (let i = 1; i < change.length; i++) { + applyAddStyleSheetChange(change[i] as AddStyleSheetChange, document, stringTable) + } + break + } + + case ChangeType.AttachedStyleSheets: { + for (let i = 1; i < change.length; i++) { + applyAttachedStyleSheetsChange(change[i] as AttachedStyleSheetsChange, document) + } + break + } + + case ChangeType.MediaPlaybackState: { + for (let i = 1; i < change.length; i++) { + applyMediaPlaybackStateChange(change[i] as MediaPlaybackStateChange, document) + } + break + } + } + } +} + +function applyAddNodeChange(addedNode: AddNodeChange, document: VDocument, stringTable: StringTable): void { + const nodeName = stringTable.decode(addedNode[1]) + + let node: VNode + switch (nodeName) { + case '#cdata-section': + node = document.createNode({ kind: '#cdata-section' }) + break + + case '#doctype': { + const [, , name, publicId, systemId] = addedNode as AddDocTypeNodeChange + node = document.createNode({ + kind: '#doctype', + name: stringTable.decode(name), + publicId: stringTable.decode(publicId), + systemId: stringTable.decode(systemId), + }) + break + } + + case '#document': + node = document.createNode({ kind: '#document' }) + break + + case '#document-fragment': + node = document.createNode({ kind: '#document-fragment' }) + break + + case '#shadow-root': + node = document.createNode({ kind: '#shadow-root' }) + break + + case '#text': { + const [, , textContent] = addedNode as AddTextNodeChange + node = document.createNode({ + kind: '#text', + textContent: stringTable.decode(textContent), + }) + break + } + + default: { + let tagName: string + let isSVG = false + if (nodeName.startsWith('svg>')) { + tagName = nodeName.substring(4).toLowerCase() + isSVG = true + } else { + tagName = nodeName.toLowerCase() + } + + const [, , ...attributeAssignments] = addedNode as AddElementNodeChange + const attributes: Record = {} + for (const [name, value] of attributeAssignments) { + attributes[stringTable.decode(name)] = stringTable.decode(value) + } + + node = document.createNode({ kind: '#element', tag: tagName, attributes, isSVG }) + break + } + } + + const insertionPoint = addedNode[0] + if (insertionPoint === null) { + document.root = node + } else if (insertionPoint === 0) { + const previousSiblingId = (node.id - 1) as NodeId + const previousSibling = document.getNodeById(previousSiblingId) + previousSibling.after(node) + } else if (insertionPoint > 0) { + const parentId = (node.id - insertionPoint) as NodeId + const parent = document.getNodeById(parentId) + parent.appendChild(node) + } else { + const nextSiblingId = (node.id + insertionPoint) as NodeId + const nextSibling = document.getNodeById(nextSiblingId) + nextSibling.before(node) + } +} + +function applyAddStyleSheetChange(change: AddStyleSheetChange, document: VDocument, stringTable: StringTable): void { + const [encodedRules, encodedMediaList = [], disabled = false] = change + const rules: string | string[] = Array.isArray(encodedRules) + ? encodedRules.map((rule) => stringTable.decode(rule)) + : stringTable.decode(encodedRules) + const mediaList = encodedMediaList.map((item) => stringTable.decode(item)) + document.createStyleSheet({ rules, mediaList, disabled }) +} + +function applyAttachedStyleSheetsChange(change: AttachedStyleSheetsChange, document: VDocument): void { + const [nodeId, ...sheetIds] = change + const node = document.getNodeById(nodeId as NodeId) + node.setAttachedStyleSheets(sheetIds.map((sheetId) => document.getStyleSheetById(sheetId as StyleSheetId))) +} + +function applyAttributeChange(change: AttributeChange, document: VDocument, stringTable: StringTable): void { + const [nodeId, ...attributeMutations] = change + const node = document.getNodeById(nodeId as NodeId) + for (const [nameOrId, valueOrId = null] of attributeMutations) { + const name = stringTable.decode(nameOrId) + if (valueOrId === null) { + node.setAttribute(name, null) + } else { + const value = stringTable.decode(valueOrId) + node.setAttribute(name, value) + } + } +} + +function applyMediaPlaybackStateChange(change: MediaPlaybackStateChange, document: VDocument): void { + const [nodeId, playbackState] = change + const node = document.getNodeById(nodeId as NodeId) + node.setPlaybackState(playbackState) +} + +function applyRemoveNodeChange(change: RemoveNodeChange, document: VDocument): void { + const nodeId = change as NodeId + const node = document.getNodeById(nodeId) + node.remove() +} + +function applyScrollPositionChange(change: ScrollPositionChange, document: VDocument): void { + const [nodeId, left, top] = change + const node = document.getNodeById(nodeId as NodeId) + node.setScrollPosition(left, top) +} + +function applySizeChange(change: SizeChange, document: VDocument): void { + const [nodeId, width, height] = change + const node = document.getNodeById(nodeId as NodeId) + node.setSize(width, height) +} + +function applyTextChange(change: TextChange, document: VDocument, stringTable: StringTable): void { + const [nodeId, textContent] = change + const node = document.getNodeById(nodeId as NodeId) + node.setTextContent(stringTable.decode(textContent)) +} diff --git a/packages/rum/src/domain/record/serialization/conversions/index.ts b/packages/rum/src/domain/record/serialization/conversions/index.ts new file mode 100644 index 0000000000..26d4e4c928 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/index.ts @@ -0,0 +1,5 @@ +export type { ChangeConverter } from './changeConverter' +export { createChangeConverter } from './changeConverter' +export type { MutationLog } from './mutationLog' +export type { NodeIdRemapper } from './nodeIdRemapper' +export { createCopyingNodeIdRemapper, createIdentityNodeIdRemapper } from './nodeIdRemapper' diff --git a/packages/rum/src/domain/record/serialization/conversions/mutationLog.ts b/packages/rum/src/domain/record/serialization/conversions/mutationLog.ts new file mode 100644 index 0000000000..6f0648d279 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/mutationLog.ts @@ -0,0 +1,56 @@ +import type { NodeId } from '../../itemIds' + +type ParentNodeId = NodeId + +export interface MutationLog { + onAttributeChanged(nodeId: NodeId, name: string): void + onNodeConnected(nodeId: NodeId): void + onNodeDisconnected(nodeId: NodeId, parentId: ParentNodeId): void + onTextChanged(nodeId: NodeId): void + + clear(): void + + attributeChanges: Map> + nodeAdds: Set + nodeRemoves: Map + textChanges: Set +} + +export function createMutationLog(): MutationLog { + const self: MutationLog = { + onAttributeChanged(nodeId: NodeId, name: string): void { + let changedAttributes = self.attributeChanges.get(nodeId) + if (!changedAttributes) { + changedAttributes = new Set() + self.attributeChanges.set(nodeId, changedAttributes) + } + changedAttributes.add(name) + }, + + onNodeConnected(nodeId: NodeId): void { + self.nodeAdds.add(nodeId) + }, + + onNodeDisconnected(nodeId: NodeId, parentId: ParentNodeId): void { + self.nodeRemoves.set(nodeId, parentId) + }, + + onTextChanged(nodeId: NodeId): void { + self.textChanges.add(nodeId) + }, + + clear(): void { + self.attributeChanges.clear() + self.nodeAdds.clear() + self.nodeRemoves.clear() + self.textChanges.clear() + }, + + attributeChanges: new Map>(), + nodeAdds: new Set(), + nodeRemoves: new Map(), + textChanges: new Set(), + } + + return self +} diff --git a/packages/rum/src/domain/record/serialization/conversions/nodeIdRemapper.ts b/packages/rum/src/domain/record/serialization/conversions/nodeIdRemapper.ts new file mode 100644 index 0000000000..dc2fe44954 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/nodeIdRemapper.ts @@ -0,0 +1,69 @@ +import type { NodeId, NodeIds } from '../../itemIds' +import { createNodeIds } from '../../itemIds' + +export interface NodeIdRemapper { + remap(inputNodeId: NodeId): NodeId +} + +export function createIdentityNodeIdRemapper(): NodeIdRemapper { + return { + remap(inputNodeId: NodeId): NodeId { + return inputNodeId + }, + } +} + +export interface CopyingNodeIdRemapper extends NodeIdRemapper { + inputNodeIds: NodeIds + outputNodeIds: NodeIds +} + +export function createCopyingNodeIdRemapper(): CopyingNodeIdRemapper { + const { nodeIds: inputNodeIds, nodesByNodeId: inputNodesByNodeId } = createTrackedNodeIds() + const { nodeIds: outputNodeIds, nodeIdsByNode: outputNodeIdsByNode } = createTrackedNodeIds() + + const self: CopyingNodeIdRemapper = { + remap(inputNodeId: NodeId): NodeId { + const node = inputNodesByNodeId.get(inputNodeId) + if (!node) { + throw new Error(`Input node id ${inputNodeId} not found`) + } + return outputNodeIdsByNode.get(node) ?? outputNodeIds.getOrInsert(node) + }, + + inputNodeIds, + outputNodeIds, + } + + return self +} + +function createTrackedNodeIds(): { + nodeIds: NodeIds + nodeIdsByNode: Map + nodesByNodeId: Map +} { + const wrappedNodeIds = createNodeIds() + const nodeIdsByNode = new Map() + const nodesByNodeId = new Map() + + const nodeIds: NodeIds = { + clear: wrappedNodeIds.clear, + delete: wrappedNodeIds.delete, + get: wrappedNodeIds.get, + getOrInsert(node: Node): NodeId { + const nodeId = wrappedNodeIds.getOrInsert(node) + nodeIdsByNode.set(node, nodeId) + nodesByNodeId.set(nodeId, node) + return nodeId + }, + get nextId(): NodeId { + return wrappedNodeIds.nextId + }, + get size(): number { + return wrappedNodeIds.size + }, + } + + return { nodeIds, nodeIdsByNode, nodesByNodeId } +} diff --git a/packages/rum/src/domain/record/serialization/conversions/renderOptions.ts b/packages/rum/src/domain/record/serialization/conversions/renderOptions.ts new file mode 100644 index 0000000000..77a98d5e7f --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/renderOptions.ts @@ -0,0 +1,14 @@ +import type { NodeIdRemapper } from './nodeIdRemapper' +import { createIdentityNodeIdRemapper } from './nodeIdRemapper' + +export interface V1RenderOptions { + nodeIdRemapper: NodeIdRemapper + timestamp: number +} + +export function createV1RenderOptions(options: Partial = {}): V1RenderOptions { + return { + nodeIdRemapper: options.nodeIdRemapper ?? createIdentityNodeIdRemapper(), + timestamp: options.timestamp ?? 0, + } +} diff --git a/packages/rum/src/domain/record/serialization/conversions/stringTable.ts b/packages/rum/src/domain/record/serialization/conversions/stringTable.ts new file mode 100644 index 0000000000..89168f854b --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/stringTable.ts @@ -0,0 +1,25 @@ +import type { StringId } from '../../itemIds' + +export interface StringTable { + add(newString: string): void + decode(stringOrStringId: number | string): string +} + +export function createStringTable(): StringTable { + const strings = new Map() + return { + add(newString: string): void { + strings.set(strings.size as StringId, newString) + }, + decode(stringOrStringId: number | string): string { + if (typeof stringOrStringId === 'string') { + return stringOrStringId + } + const referencedString = strings.get(stringOrStringId as StringId) + if (referencedString === undefined) { + throw new Error(`Reference to unknown string: ${stringOrStringId}`) + } + return referencedString + }, + } +} diff --git a/packages/rum/src/domain/record/serialization/conversions/vDocument.spec.ts b/packages/rum/src/domain/record/serialization/conversions/vDocument.spec.ts new file mode 100644 index 0000000000..d2217ce354 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/vDocument.spec.ts @@ -0,0 +1,931 @@ +import type { NodeId, StyleSheetId } from '../../itemIds' +import type { VDocument } from './vDocument' +import { createVDocument } from './vDocument' + +import { expectConnections, expectFullSnapshotRendering, expectIncrementalSnapshotRendering } from './vDom.specHelper' + +describe('VDocument', () => { + let document: VDocument + + beforeEach(() => { + document = createVDocument() + }) + + it('initially contains no nodes', () => { + expect(document.root).toBeUndefined() + expect(() => { + document.getNodeById(0 as NodeId) + }).toThrowError() + }) + + it('initially contains no stylesheets', () => { + expect(() => { + document.getStyleSheetById(0 as StyleSheetId) + }).toThrowError() + }) + + describe('createNode', () => { + it('creates nodes with monotonically increasing ids', () => { + const node0 = document.createNode({ kind: '#document' }) + expect(node0.id).toBe(0 as NodeId) + const node1 = document.createNode({ kind: '#element', tag: 'div', attributes: {} }) + expect(node1.id).toBe(1 as NodeId) + const node2 = document.createNode({ kind: '#element', tag: 'span', attributes: {} }) + expect(node2.id).toBe(2 as NodeId) + }) + }) + + describe('createStyleSheet', () => { + it('creates stylesheets with monotonically increasing ids', () => { + const sheet0 = document.createStyleSheet({ disabled: false, mediaList: [], rules: '' }) + expect(sheet0.id).toBe(0 as StyleSheetId) + const sheet1 = document.createStyleSheet({ disabled: false, mediaList: [], rules: 'foo { color: red }' }) + expect(sheet1.id).toBe(1 as StyleSheetId) + const sheet2 = document.createStyleSheet({ disabled: false, mediaList: [], rules: 'bar { width: 100px }' }) + expect(sheet2.id).toBe(2 as StyleSheetId) + }) + }) + + describe('root', () => { + it('can be attached', () => { + const rootNode = document.createNode({ kind: '#document' }) + expect(document.root).toBeUndefined() + + document.root = rootNode + + expect(document.root).toBe(rootNode) + expectConnections(rootNode, {}) + }) + + it('cannot be replaced', () => { + const rootNode = document.createNode({ kind: '#document' }) + document.root = rootNode + const secondRootNode = document.createNode({ kind: '#document' }) + + expect(() => { + document.root = secondRootNode + }).toThrowError() + }) + + it('cannot be detached', () => { + const rootNode = document.createNode({ kind: '#document' }) + document.root = rootNode + + expect(() => { + document.root = undefined + }).toThrowError() + }) + }) + + describe('renderAsFullSnapshot', () => { + it('can render a realistic document', () => { + // Construct a document which resembles a simple, but realistic, web page. + const root = document.createNode({ kind: '#document' }) + document.root = root + + const html = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + root.appendChild(html) + + const head = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + html.appendChild(head) + + const title = document.createNode({ kind: '#element', tag: 'title', attributes: {} }) + head.appendChild(title) + + const titleText = document.createNode({ kind: '#text', textContent: 'Test Page' }) + title.appendChild(titleText) + + const meta = document.createNode({ kind: '#element', tag: 'meta', attributes: { charset: 'utf-8' } }) + head.appendChild(meta) + + const body = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + html.appendChild(body) + + const div = document.createNode({ kind: '#element', tag: 'div', attributes: { class: 'container' } }) + body.appendChild(div) + + const h1 = document.createNode({ kind: '#element', tag: 'h1', attributes: {} }) + div.appendChild(h1) + + const h1Text = document.createNode({ kind: '#text', textContent: 'Hello World' }) + h1.appendChild(h1Text) + + const p = document.createNode({ kind: '#element', tag: 'p', attributes: {} }) + div.appendChild(p) + + const pText = document.createNode({ kind: '#text', textContent: 'This is a test paragraph.' }) + p.appendChild(pText) + + const ul = document.createNode({ kind: '#element', tag: 'ul', attributes: {} }) + div.appendChild(ul) + + const li1 = document.createNode({ kind: '#element', tag: 'li', attributes: {} }) + ul.appendChild(li1) + + const li1Text = document.createNode({ kind: '#text', textContent: 'Item 1' }) + li1.appendChild(li1Text) + + const li2 = document.createNode({ kind: '#element', tag: 'li', attributes: {} }) + ul.appendChild(li2) + + const li2Text = document.createNode({ kind: '#text', textContent: 'Item 2' }) + li2.appendChild(li2Text) + + // Check that the rendering matches our expectations. + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 2, + id: 1, + tagName: 'html', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 2, + tagName: 'head', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 3, + tagName: 'title', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 4, textContent: 'Test Page' }], + }, + { + type: 2, + id: 5, + tagName: 'meta', + attributes: { charset: 'utf-8' }, + isSVG: undefined, + childNodes: [], + }, + ], + }, + { + type: 2, + id: 6, + tagName: 'body', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 7, + tagName: 'div', + attributes: { class: 'container' }, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 8, + tagName: 'h1', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 9, textContent: 'Hello World' }], + }, + { + type: 2, + id: 10, + tagName: 'p', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 11, textContent: 'This is a test paragraph.' }], + }, + { + type: 2, + id: 12, + tagName: 'ul', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 13, + tagName: 'li', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 14, textContent: 'Item 1' }], + }, + { + type: 2, + id: 15, + tagName: 'li', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 16, textContent: 'Item 2' }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + }) + + describe('renderAsIncrementalSnapshot', () => { + it('can render nodes being added incrementally', () => { + // Construct a document which resembles a very simple web page. + const root = document.createNode({ kind: '#document' }) + document.root = root + + const html = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + root.appendChild(html) + + const head = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + html.appendChild(head) + + const body = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + html.appendChild(body) + + const div = document.createNode({ kind: '#element', tag: 'div', attributes: {} }) + body.appendChild(div) + + const span = document.createNode({ kind: '#element', tag: 'span', attributes: {} }) + body.appendChild(span) + + // Reset the mutation log. Beyond this point, any further changes will be treated + // as incremental mutations. + document.mutations.clear() + + // Perform some mutations. + const meta = document.createNode({ kind: '#element', tag: 'meta', attributes: {} }) + head.appendChild(meta) + + const p = document.createNode({ kind: '#element', tag: 'p', attributes: {} }) + body.appendChild(p) + + const pText = document.createNode({ kind: '#text', textContent: 'Content' }) + div.appendChild(pText) + + // Check that the rendering matches our expectations. + expectIncrementalSnapshotRendering( + document, + { + adds: [ + { + nextId: null, + parentId: 3, + node: { type: 2, id: 7, tagName: 'p', attributes: {}, isSVG: undefined, childNodes: [] }, + }, + { + nextId: null, + parentId: 4, + node: { type: 3, id: 8, textContent: 'Content' }, + }, + { + nextId: null, + parentId: 2, + node: { type: 2, id: 6, tagName: 'meta', attributes: {}, isSVG: undefined, childNodes: [] }, + }, + ], + removes: [], + texts: [], + attributes: [], + }, + { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 2, + id: 1, + tagName: 'html', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 2, + tagName: 'head', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 2, id: 6, tagName: 'meta', attributes: {}, isSVG: undefined, childNodes: [] }], + }, + { + type: 2, + id: 3, + tagName: 'body', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 4, + tagName: 'div', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 8, textContent: 'Content' }], + }, + { type: 2, id: 5, tagName: 'span', attributes: {}, isSVG: undefined, childNodes: [] }, + { type: 2, id: 7, tagName: 'p', attributes: {}, isSVG: undefined, childNodes: [] }, + ], + }, + ], + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + } + ) + }) + + it('can render siblings nodes being added incrementally', () => { + // Construct a document which resembles a very simple web page. + const root = document.createNode({ kind: '#document' }) + document.root = root + + const html = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + root.appendChild(html) + + const body = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + html.appendChild(body) + + // Reset the mutation log. Beyond this point, any further changes will be treated + // as incremental mutations. + document.mutations.clear() + + // Perform some mutations - add several sibling nodes. + const div1 = document.createNode({ kind: '#element', tag: 'div', attributes: { id: 'first' } }) + body.appendChild(div1) + + const div2 = document.createNode({ kind: '#element', tag: 'div', attributes: { id: 'second' } }) + body.appendChild(div2) + + const div3 = document.createNode({ kind: '#element', tag: 'div', attributes: { id: 'third' } }) + body.appendChild(div3) + + // Check that the rendering matches our expectations. + expectIncrementalSnapshotRendering( + document, + { + adds: [ + { + nextId: null, + parentId: 2, + node: { type: 2, id: 5, tagName: 'div', attributes: { id: 'third' }, isSVG: undefined, childNodes: [] }, + }, + { + nextId: 5, + parentId: 2, + node: { type: 2, id: 4, tagName: 'div', attributes: { id: 'second' }, isSVG: undefined, childNodes: [] }, + }, + { + nextId: 4, + parentId: 2, + node: { type: 2, id: 3, tagName: 'div', attributes: { id: 'first' }, isSVG: undefined, childNodes: [] }, + }, + ], + removes: [], + texts: [], + attributes: [], + }, + { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 2, + id: 1, + tagName: 'html', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 2, + tagName: 'body', + attributes: {}, + isSVG: undefined, + childNodes: [ + { type: 2, id: 3, tagName: 'div', attributes: { id: 'first' }, isSVG: undefined, childNodes: [] }, + { + type: 2, + id: 4, + tagName: 'div', + attributes: { id: 'second' }, + isSVG: undefined, + childNodes: [], + }, + { type: 2, id: 5, tagName: 'div', attributes: { id: 'third' }, isSVG: undefined, childNodes: [] }, + ], + }, + ], + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + } + ) + }) + + it('can render a node subtree being added incrementally', () => { + // Construct a document which resembles a very simple web page. + const root = document.createNode({ kind: '#document' }) + document.root = root + + const html = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + root.appendChild(html) + + const body = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + html.appendChild(body) + + // Reset the mutation log. Beyond this point, any further changes will be treated + // as incremental mutations. + document.mutations.clear() + + // Perform some mutations - add a node subtree. + const div = document.createNode({ kind: '#element', tag: 'div', attributes: {} }) + body.appendChild(div) + + const ul = document.createNode({ kind: '#element', tag: 'ul', attributes: {} }) + div.appendChild(ul) + + const li1 = document.createNode({ kind: '#element', tag: 'li', attributes: {} }) + ul.appendChild(li1) + + const li1Text = document.createNode({ kind: '#text', textContent: 'First item' }) + li1.appendChild(li1Text) + + const li2 = document.createNode({ kind: '#element', tag: 'li', attributes: {} }) + ul.appendChild(li2) + + const li2Text = document.createNode({ kind: '#text', textContent: 'Second item' }) + li2.appendChild(li2Text) + + // Check that the rendering matches our expectations. + expectIncrementalSnapshotRendering( + document, + { + adds: [ + { + nextId: null, + parentId: 2, + node: { + type: 2, + id: 3, + tagName: 'div', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 4, + tagName: 'ul', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 5, + tagName: 'li', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 6, textContent: 'First item' }], + }, + { + type: 2, + id: 7, + tagName: 'li', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 8, textContent: 'Second item' }], + }, + ], + }, + ], + }, + }, + ], + removes: [], + texts: [], + attributes: [], + }, + { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 2, + id: 1, + tagName: 'html', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 2, + tagName: 'body', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 3, + tagName: 'div', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 4, + tagName: 'ul', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 5, + tagName: 'li', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 6, textContent: 'First item' }], + }, + { + type: 2, + id: 7, + tagName: 'li', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 8, textContent: 'Second item' }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + } + ) + }) + + it('can render nodes being removed incrementally', () => { + // Construct a document which resembles a very simple web page. + const root = document.createNode({ kind: '#document' }) + document.root = root + + const html = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + root.appendChild(html) + + const body = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + html.appendChild(body) + + const div1 = document.createNode({ kind: '#element', tag: 'div', attributes: { id: 'first' } }) + body.appendChild(div1) + + const div2 = document.createNode({ kind: '#element', tag: 'div', attributes: { id: 'second' } }) + body.appendChild(div2) + + const div3 = document.createNode({ kind: '#element', tag: 'div', attributes: { id: 'third' } }) + body.appendChild(div3) + + const span = document.createNode({ kind: '#element', tag: 'span', attributes: {} }) + body.appendChild(span) + + // Reset the mutation log. Beyond this point, any further changes will be treated + // as incremental mutations. + document.mutations.clear() + + // Perform some mutations - remove several nodes (but not ancestors and descendants). + div1.remove() + div3.remove() + + // Check that the rendering matches our expectations. + expectIncrementalSnapshotRendering( + document, + { + adds: [], + removes: [ + { parentId: 2, id: 3 }, + { parentId: 2, id: 5 }, + ], + texts: [], + attributes: [], + }, + { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 2, + id: 1, + tagName: 'html', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 2, + tagName: 'body', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 4, + tagName: 'div', + attributes: { id: 'second' }, + isSVG: undefined, + childNodes: [], + }, + { type: 2, id: 6, tagName: 'span', attributes: {}, isSVG: undefined, childNodes: [] }, + ], + }, + ], + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + } + ) + }) + + it('can render a node subtree being removed incrementally', () => { + // Construct a document which resembles a very simple web page. + const root = document.createNode({ kind: '#document' }) + document.root = root + + const html = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + root.appendChild(html) + + const body = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + html.appendChild(body) + + const div = document.createNode({ kind: '#element', tag: 'div', attributes: {} }) + body.appendChild(div) + + const ul = document.createNode({ kind: '#element', tag: 'ul', attributes: {} }) + div.appendChild(ul) + + const li1 = document.createNode({ kind: '#element', tag: 'li', attributes: {} }) + ul.appendChild(li1) + + const li1Text = document.createNode({ kind: '#text', textContent: 'First item' }) + li1.appendChild(li1Text) + + const li2 = document.createNode({ kind: '#element', tag: 'li', attributes: {} }) + ul.appendChild(li2) + + const li2Text = document.createNode({ kind: '#text', textContent: 'Second item' }) + li2.appendChild(li2Text) + + const span = document.createNode({ kind: '#element', tag: 'span', attributes: {} }) + body.appendChild(span) + + // Reset the mutation log. Beyond this point, any further changes will be treated + // as incremental mutations. + document.mutations.clear() + + // Perform some mutations - remove some descendant nodes, then remove the root of the subtree. + li1.remove() + li2.remove() + div.remove() + + // Check that the rendering matches our expectations. + expectIncrementalSnapshotRendering( + document, + { + adds: [], + removes: [ + { parentId: 4, id: 5 }, + { parentId: 4, id: 7 }, + { parentId: 2, id: 3 }, + ], + texts: [], + attributes: [], + }, + { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 2, + id: 1, + tagName: 'html', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 2, + tagName: 'body', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 2, id: 9, tagName: 'span', attributes: {}, isSVG: undefined, childNodes: [] }], + }, + ], + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + } + ) + }) + + it('can render attributes being changed incrementally', () => { + // Construct a document which resembles a very simple web page. + const root = document.createNode({ kind: '#document' }) + document.root = root + + const html = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + root.appendChild(html) + + const body = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + html.appendChild(body) + + const div = document.createNode({ kind: '#element', tag: 'div', attributes: { id: 'container', class: 'old' } }) + body.appendChild(div) + + const span = document.createNode({ kind: '#element', tag: 'span', attributes: { title: 'hello' } }) + body.appendChild(span) + + // Reset the mutation log. Beyond this point, any further changes will be treated + // as incremental mutations. + document.mutations.clear() + + // Perform some mutations - change attributes on nodes. + div.setAttribute('class', 'new') + div.setAttribute('data-test', 'value') + span.setAttribute('title', null) + span.setAttribute('id', 'myspan') + + // Check that the rendering matches our expectations. + expectIncrementalSnapshotRendering( + document, + { + adds: [], + removes: [], + texts: [], + attributes: [ + { id: 3, attributes: { class: 'new', 'data-test': 'value' } }, + { id: 4, attributes: { title: null, id: 'myspan' } }, + ], + }, + { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 2, + id: 1, + tagName: 'html', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 2, + tagName: 'body', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 3, + tagName: 'div', + attributes: { id: 'container', class: 'new', 'data-test': 'value' }, + isSVG: undefined, + childNodes: [], + }, + { + type: 2, + id: 4, + tagName: 'span', + attributes: { id: 'myspan' }, + isSVG: undefined, + childNodes: [], + }, + ], + }, + ], + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + } + ) + }) + + it('can render text content being changed incrementally', () => { + // Construct a document which resembles a very simple web page. + const root = document.createNode({ kind: '#document' }) + document.root = root + + const html = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + root.appendChild(html) + + const body = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + html.appendChild(body) + + const p1 = document.createNode({ kind: '#element', tag: 'p', attributes: {} }) + body.appendChild(p1) + + const p1Text = document.createNode({ kind: '#text', textContent: 'Original text 1' }) + p1.appendChild(p1Text) + + const p2 = document.createNode({ kind: '#element', tag: 'p', attributes: {} }) + body.appendChild(p2) + + const p2Text = document.createNode({ kind: '#text', textContent: 'Original text 2' }) + p2.appendChild(p2Text) + + // Reset the mutation log. Beyond this point, any further changes will be treated + // as incremental mutations. + document.mutations.clear() + + // Perform some mutations - change text content. + p1Text.setTextContent('Updated text 1') + p2Text.setTextContent('Updated text 2') + + // Check that the rendering matches our expectations. + expectIncrementalSnapshotRendering( + document, + { + adds: [], + removes: [], + texts: [ + { id: 4, value: 'Updated text 1' }, + { id: 6, value: 'Updated text 2' }, + ], + attributes: [], + }, + { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 2, + id: 1, + tagName: 'html', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 2, + tagName: 'body', + attributes: {}, + isSVG: undefined, + childNodes: [ + { + type: 2, + id: 3, + tagName: 'p', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 4, textContent: 'Updated text 1' }], + }, + { + type: 2, + id: 5, + tagName: 'p', + attributes: {}, + isSVG: undefined, + childNodes: [{ type: 3, id: 6, textContent: 'Updated text 2' }], + }, + ], + }, + ], + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + } + ) + }) + }) +}) diff --git a/packages/rum/src/domain/record/serialization/conversions/vDocument.ts b/packages/rum/src/domain/record/serialization/conversions/vDocument.ts new file mode 100644 index 0000000000..6ba47764a6 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/vDocument.ts @@ -0,0 +1,239 @@ +import type { + AddedNodeMutation, + AttributeMutation, + BrowserFullSnapshotRecord, + BrowserIncrementalSnapshotRecord, + RemovedNodeMutation, + TextMutation, +} from '../../../../types' +import { IncrementalSource, RecordType } from '../../../../types' +import type { NodeId, StyleSheetId } from '../../itemIds' +import type { MutationLog } from './mutationLog' +import { createMutationLog } from './mutationLog' +import { createV1RenderOptions } from './renderOptions' +import type { V1RenderOptions } from './renderOptions' +import { createVNode } from './vNode' +import type { VNode, VNodeData } from './vNode' +import { createVStyleSheet } from './vStyleSheet' +import type { VStyleSheet, VStyleSheetData } from './vStyleSheet' + +export interface VDocument { + createNode(data: VNodeData): VNode + getNodeById(id: NodeId): VNode + + createStyleSheet(data: VStyleSheetData): VStyleSheet + getStyleSheetById(id: StyleSheetId): VStyleSheet + + onAttributeChanged(node: VNode, name: string): void + onNodeConnected(node: VNode, parent: VNode | undefined): void + onNodeDisconnected(node: VNode, parent: VNode | undefined): void + onTextChanged(node: VNode): void + + get mutations(): MutationLog + + naturalRendering(): BrowserFullSnapshotRecord['type'] | BrowserIncrementalSnapshotRecord['type'] + render(options?: Partial): BrowserFullSnapshotRecord | BrowserIncrementalSnapshotRecord + renderAsFullSnaphot(options?: Partial): BrowserFullSnapshotRecord + renderAsIncrementalSnapshot(options?: Partial): BrowserIncrementalSnapshotRecord + + root: VNode | undefined +} + +export function createVDocument(): VDocument { + const mutations = createMutationLog() + + let nextNodeId = 0 as NodeId + const nodesById = new Map() + let rootId: NodeId | undefined + + let nextStyleSheetId = 0 as StyleSheetId + const styleSheetsById = new Map() + + const self: VDocument = { + createNode(data: VNodeData): VNode { + const id = nextNodeId++ as NodeId + const node = createVNode(self, id, data) + nodesById.set(id, node) + return node + }, + + getNodeById(id: NodeId): VNode { + const node = nodesById.get(id) + if (!node) { + throw new Error(`Reference to unknown node: ${id}`) + } + return node + }, + + createStyleSheet(data: VStyleSheetData): VStyleSheet { + const id = nextStyleSheetId++ as StyleSheetId + const sheet = createVStyleSheet(self, id, data) + styleSheetsById.set(id, sheet) + return sheet + }, + + getStyleSheetById(id: StyleSheetId): VStyleSheet { + const sheet = styleSheetsById.get(id) + if (!sheet) { + throw new Error(`Reference to unknown stylesheet: ${id}`) + } + return sheet + }, + + onAttributeChanged(node: VNode, name: string): void { + mutations.onAttributeChanged(node.id, name) + }, + + onNodeConnected(node: VNode, parent: VNode | undefined): void { + if (node.state !== 'new') { + throw new Error(`Moving connected node: ${node.id}`) + } + + if (parent === undefined) { + if (rootId !== undefined) { + throw new Error('Replacing existing root node') + } + rootId = node.id + } + + node.state = 'connected' + mutations.onNodeConnected(node.id) + }, + + onNodeDisconnected(node: VNode, parent: VNode | undefined): void { + node.forSelfAndEachDescendant((descendant: VNode): void => { + if (descendant.state === 'disconnected') { + throw new Error(`Disconnecting node which isn't connected: ${descendant.id}`) + } + + descendant.state = 'disconnected' + }) + + if (!parent) { + throw new Error('Disconnecting the root node') + } + + mutations.onNodeDisconnected(node.id, parent.id) + }, + + onTextChanged(node: VNode): void { + mutations.onTextChanged(node.id) + }, + + get mutations(): MutationLog { + return mutations + }, + + naturalRendering(): BrowserFullSnapshotRecord['type'] | BrowserIncrementalSnapshotRecord['type'] { + if (rootId !== undefined && mutations.nodeAdds.has(rootId)) { + return RecordType.FullSnapshot + } + return RecordType.IncrementalSnapshot + }, + + render(options?: Partial): BrowserFullSnapshotRecord | BrowserIncrementalSnapshotRecord { + if (self.naturalRendering() === RecordType.FullSnapshot) { + return self.renderAsFullSnaphot(options) + } + return self.renderAsIncrementalSnapshot(options) + }, + + renderAsFullSnaphot(renderOptions: Partial = {}): BrowserFullSnapshotRecord { + const options = createV1RenderOptions(renderOptions) + + const root = self.root + if (!root) { + throw new Error('No root node found') + } + + let scrollLeft = 0 + let scrollTop = 0 + if (root.data.kind === '#document' || root.data.kind === '#element') { + scrollLeft = root.data.scrollLeft ?? 0 + scrollTop = root.data.scrollTop ?? 0 + } + + return { + data: { + node: root.render(options), + initialOffset: { left: scrollLeft, top: scrollTop }, + }, + type: RecordType.FullSnapshot, + timestamp: options.timestamp, + } + }, + + renderAsIncrementalSnapshot(renderOptions: Partial = {}): BrowserIncrementalSnapshotRecord { + const options = createV1RenderOptions(renderOptions) + + const root = self.root + if (!root) { + throw new Error('No root node found') + } + + const remappedId = (id: NodeId): NodeId => options.nodeIdRemapper.remap(id) + + const addMutations: AddedNodeMutation[] = [] + root.forEachAddedNodeRoot(mutations.nodeAdds, (node) => { + if (!node.parent) { + throw new Error(`Can't render incremental add of root node ${node.id}`) + } + addMutations.push({ + nextId: node.nextSibling?.id !== undefined ? remappedId(node.nextSibling.id) : null, + parentId: remappedId(node.parent.id), + node: node.render(options), + }) + }) + + const removeMutations: RemovedNodeMutation[] = [] + for (const [id, parentId] of mutations.nodeRemoves) { + removeMutations.push({ parentId: remappedId(parentId), id: remappedId(id) }) + } + + const textMutations: TextMutation[] = [] + for (const id of mutations.textChanges) { + const node = self.getNodeById(id) + if (node.data.kind !== '#text') { + throw new Error(`Can't render incremental text mutation of ${node.data.kind} node ${id}`) + } + textMutations.push({ id: remappedId(id), value: node.data.textContent }) + } + + const attributeMutations: AttributeMutation[] = [] + for (const [id, attrNames] of mutations.attributeChanges) { + const node = self.getNodeById(id) + if (node.data.kind !== '#element') { + throw new Error(`Can't render incremental attribute mutation of ${node.data.kind} node ${id}`) + } + + const attributes: Record = {} + for (const attrName of attrNames) { + attributes[attrName] = node.data.attributes[attrName] ?? null + } + + attributeMutations.push({ id: remappedId(id), attributes }) + } + + return { + data: { + source: IncrementalSource.Mutation, + adds: addMutations, + removes: removeMutations, + texts: textMutations, + attributes: attributeMutations, + }, + type: RecordType.IncrementalSnapshot, + timestamp: options.timestamp, + } + }, + + get root(): VNode | undefined { + return rootId === undefined ? undefined : self.getNodeById(rootId) + }, + set root(node: VNode) { + self.onNodeConnected(node, undefined) + }, + } + + return self +} diff --git a/packages/rum/src/domain/record/serialization/conversions/vDom.specHelper.ts b/packages/rum/src/domain/record/serialization/conversions/vDom.specHelper.ts new file mode 100644 index 0000000000..9de5857d11 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/vDom.specHelper.ts @@ -0,0 +1,142 @@ +import type { + BrowserFullSnapshotRecord, + BrowserIncrementalSnapshotRecord, + BrowserMutationPayload, + SerializedNodeWithId, +} from '../../../../types' +import { IncrementalSource, RecordType } from '../../../../types' +import type { NodeId } from '../../itemIds' +import { createV1RenderOptions } from './renderOptions' +import type { VDocument } from './vDocument' +import type { VNode, VNodeState } from './vNode' + +export function expectConnections( + node: VNode, + connections: { + state?: VNodeState | undefined + firstChild?: VNode | undefined + lastChild?: VNode | undefined + previousSibling?: VNode | undefined + nextSibling?: VNode | undefined + parent?: VNode | undefined + } +): void { + expect(node.state).toBe(connections.state ?? 'connected') + expect(node.firstChild).toBe(connections.firstChild) + expect(node.lastChild).toBe(connections.lastChild) + expect(node.previousSibling).toBe(connections.previousSibling) + expect(node.nextSibling).toBe(connections.nextSibling) + expect(node.parent).toBe(connections.parent) +} + +export function expectMutations( + document: VDocument, + mutations: { + attributeChanges?: Array<{ node: VNode; attributes: string[] }> + nodeAdds?: VNode[] + nodeRemoves?: Array<{ node: VNode; parent: VNode }> + textChanges?: VNode[] + } +): void { + const expectedAttributeChanges: Array<{ node: NodeId; attributes: string[] }> = + mutations.attributeChanges?.map(({ node, attributes }) => ({ node: node.id, attributes })) ?? [] + const actualAttributeChanges: Array<{ node: NodeId; attributes: string[] }> = [ + ...document.mutations.attributeChanges, + ].map(([nodeId, attributes]) => ({ node: nodeId, attributes: [...attributes] })) + expect(actualAttributeChanges).toEqual(expectedAttributeChanges) + + const expectedNodeAdds: NodeId[] = mutations.nodeAdds?.map((node) => node.id) ?? [] + const actualNodeAdds: NodeId[] = [...document.mutations.nodeAdds] + expect(actualNodeAdds).toEqual(expectedNodeAdds) + + const expectedNodeRemoves: Array<{ node: NodeId; parent: NodeId }> = + mutations.nodeRemoves?.map(({ node, parent }) => ({ node: node.id, parent: parent.id })) ?? [] + const actualNodeRemoves: Array<{ node: NodeId; parent: NodeId }> = [...document.mutations.nodeRemoves].map( + ([node, parent]) => ({ node, parent }) + ) + expect(actualNodeRemoves).toEqual(expectedNodeRemoves) + + const expectedTextChanges: NodeId[] = mutations.textChanges?.map((node) => node.id) ?? [] + const actualTextChanges: NodeId[] = [...document.mutations.textChanges] + expect(actualTextChanges).toEqual(expectedTextChanges) +} + +export function expectFullSnapshotRendering( + document: VDocument, + data: BrowserFullSnapshotRecord['data'], + naturalRendering: + | BrowserFullSnapshotRecord['type'] + | BrowserIncrementalSnapshotRecord['type'] = RecordType.FullSnapshot +): void { + expect(document.naturalRendering()).toBe(naturalRendering) + + const expectedRecord: BrowserFullSnapshotRecord = { + data, + type: RecordType.FullSnapshot, + timestamp: 0, + } + const actualRecord = document.renderAsFullSnaphot() + expect(actualRecord).toEqual(expectedRecord) + + const expectedSerialization = JSON.stringify(expectedRecord) + const actualSerialization = JSON.stringify(actualRecord) + const context = stringMismatchContext(expectedSerialization, actualSerialization) + expect(actualSerialization).withContext(context).toBe(expectedSerialization) +} + +export function expectIncrementalSnapshotRendering( + document: VDocument, + incrementalSnapshotPayload: BrowserMutationPayload, + fullSnapshotData: BrowserFullSnapshotRecord['data'] +): void { + expect(document.naturalRendering()).toBe(RecordType.IncrementalSnapshot) + + const expectedRecord: BrowserIncrementalSnapshotRecord = { + data: { + source: IncrementalSource.Mutation, + ...incrementalSnapshotPayload, + }, + type: RecordType.IncrementalSnapshot, + timestamp: 0, + } + const actualRecord = document.render() + expect(actualRecord).toEqual(expectedRecord) + + const expectedSerialization = JSON.stringify(expectedRecord) + const actualSerialization = JSON.stringify(actualRecord) + const context = stringMismatchContext(expectedSerialization, actualSerialization) + expect(actualSerialization).withContext(context).toBe(expectedSerialization) + + expectFullSnapshotRendering(document, fullSnapshotData, RecordType.IncrementalSnapshot) +} + +export function expectNodeRendering(node: VNode, expectedSerializedNode: SerializedNodeWithId): void { + const actualSerializedNode = node.render(createV1RenderOptions()) + expect(actualSerializedNode).toEqual(expectedSerializedNode) + + const expectedSerialization = JSON.stringify(expectedSerializedNode) + const actualSerialization = JSON.stringify(actualSerializedNode) + const context = stringMismatchContext(expectedSerialization, actualSerialization) + expect(actualSerialization).withContext(context).toBe(expectedSerialization) +} + +function stringMismatchContext(expected: string, actual: string): string { + if (expected === actual) { + return '(equal)' + } + + let firstDifferenceIndex = 0 + while (expected[firstDifferenceIndex] === actual[firstDifferenceIndex]) { + firstDifferenceIndex++ + } + + const expectedContext = getStringNearPosition(expected, firstDifferenceIndex) + const actualContext = getStringNearPosition(actual, firstDifferenceIndex) + return JSON.stringify({ expected: expectedContext, actual: actualContext }, null, 2) +} + +function getStringNearPosition(str: string, index: number): string { + const leftContextStart = Math.max(index - 50, 0) + const rightContextEnd = Math.min(index + 150, str.length) + return `${str.substring(leftContextStart, index)}(!)${str.substring(index, rightContextEnd)}` +} diff --git a/packages/rum/src/domain/record/serialization/conversions/vNode.spec.ts b/packages/rum/src/domain/record/serialization/conversions/vNode.spec.ts new file mode 100644 index 0000000000..767134d422 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/vNode.spec.ts @@ -0,0 +1,1592 @@ +import { PlaybackState } from '../../../../types' +import type { NodeId } from '../../itemIds' +import type { VDocument } from './vDocument' +import { createVDocument } from './vDocument' +import type { VNode, VNodeData } from './vNode' +import type { VStyleSheet } from './vStyleSheet' + +import { expectConnections, expectFullSnapshotRendering, expectMutations, expectNodeRendering } from './vDom.specHelper' + +describe('VNode', () => { + let document: VDocument + + beforeEach(() => { + document = createVDocument() + }) + + function expectThrowsForNode(nodeData: VNodeData, action: (node: VNode) => void): void { + const node = document.createNode(nodeData) + document.root = node + + expect(() => { + action(node) + }).toThrow() + } + + function expectThrowsForDisconnectedNodes(action: (node: VNode) => void): void { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + + const removed = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(removed) + removed.remove() + expect(() => { + action(removed) + }).toThrow() + + const neverAttached = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + expect(() => { + action(neverAttached) + }).toThrow() + } + + it('has the expected state on creation', () => { + const nodeData: VNodeData = { kind: '#element', tag: 'div', attributes: {}, isSVG: false } + const node = document.createNode(nodeData) + + expect(node.data).toEqual(nodeData) + expect(node.ownerDocument).toBe(document) + expect(node.id).toBe(0 as NodeId) + expectConnections(node, { state: 'new' }) + expectMutations(document, {}) + }) + + describe('after', () => { + it('can attach after an only child', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const firstChild = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(firstChild) + + const lastChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + firstChild.after(lastChild) + + expectConnections(parent, { firstChild, lastChild }) + expectConnections(firstChild, { parent, nextSibling: lastChild }) + expectConnections(lastChild, { parent, previousSibling: firstChild }) + expectMutations(document, { nodeAdds: [parent, firstChild, lastChild] }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { type: 2, id: 1, tagName: 'html', attributes: {}, childNodes: [], isSVG: undefined }, + { type: 2, id: 2, tagName: 'body', attributes: {}, childNodes: [], isSVG: undefined }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can attach at the end of a child list', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const firstChild = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + parent.appendChild(firstChild) + const middleChild = document.createNode({ kind: '#element', tag: 'style', attributes: {} }) + parent.appendChild(middleChild) + + const lastChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + middleChild.after(lastChild) + + expectConnections(parent, { firstChild, lastChild }) + expectConnections(firstChild, { parent, nextSibling: middleChild }) + expectConnections(middleChild, { parent, previousSibling: firstChild, nextSibling: lastChild }) + expectConnections(lastChild, { parent, previousSibling: middleChild }) + expectMutations(document, { nodeAdds: [parent, firstChild, middleChild, lastChild] }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 2, + id: 1, + tagName: 'head', + attributes: {}, + childNodes: [], + isSVG: undefined, + }, + { + type: 2, + id: 2, + tagName: 'style', + attributes: {}, + childNodes: [], + isSVG: undefined, + }, + { + type: 2, + id: 3, + tagName: 'body', + attributes: {}, + childNodes: [], + isSVG: undefined, + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can attach in the middle of a child list', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const firstChild = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + parent.appendChild(firstChild) + const lastChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + parent.appendChild(lastChild) + + const middleChild = document.createNode({ kind: '#element', tag: 'style', attributes: {} }) + firstChild.after(middleChild) + + expectConnections(parent, { firstChild, lastChild }) + expectConnections(firstChild, { parent, nextSibling: middleChild }) + expectConnections(middleChild, { parent, previousSibling: firstChild, nextSibling: lastChild }) + expectConnections(lastChild, { parent, previousSibling: middleChild }) + expectMutations(document, { nodeAdds: [parent, firstChild, lastChild, middleChild] }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 2, + id: 1, + tagName: 'head', + attributes: {}, + childNodes: [], + isSVG: undefined, + }, + { + type: 2, + id: 3, + tagName: 'style', + attributes: {}, + childNodes: [], + isSVG: undefined, + }, + { + type: 2, + id: 2, + tagName: 'body', + attributes: {}, + childNodes: [], + isSVG: undefined, + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('cannot attach a node which is already connected', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const child = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(child) + const grandChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + child.appendChild(grandChild) + + expect(() => { + child.after(grandChild) + }).toThrow() + }) + + it('cannot attach a node to a disconnected sibling', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const child = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(child) + const node = document.createNode({ kind: '#element', tag: 'div', attributes: {} }) + + const removed = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(removed) + removed.remove() + expect(() => { + removed.after(node) + }).toThrow() + + const neverAttached = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + expect(() => { + neverAttached.after(node) + }).toThrow() + }) + + it('cannot attach a node which was previously removed', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const child = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(child) + const node = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + child.appendChild(node) + node.remove() + + expect(() => { + child.after(node) + }).toThrow() + }) + + it('cannot attach a node as a sibling to the root node', () => { + const root = document.createNode({ kind: '#document' }) + document.root = root + const node = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + + expect(() => { + root.after(node) + }).toThrow() + }) + }) + + describe('appendChild', () => { + it('can attach the first child', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + + const child = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(child) + + expectConnections(parent, { firstChild: child, lastChild: child }) + expectConnections(child, { parent }) + expectMutations(document, { nodeAdds: [parent, child] }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 2, + id: 1, + tagName: 'html', + attributes: {}, + childNodes: [], + isSVG: undefined, + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can attach two children', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + + const firstChild = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + parent.appendChild(firstChild) + const lastChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + parent.appendChild(lastChild) + + expectConnections(parent, { firstChild, lastChild }) + expectConnections(firstChild, { parent, nextSibling: lastChild }) + expectConnections(lastChild, { parent, previousSibling: firstChild }) + expectMutations(document, { nodeAdds: [parent, firstChild, lastChild] }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { type: 2, id: 1, tagName: 'head', attributes: {}, childNodes: [], isSVG: undefined }, + { type: 2, id: 2, tagName: 'body', attributes: {}, childNodes: [], isSVG: undefined }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can attach three children', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + + const firstChild = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + parent.appendChild(firstChild) + const middleChild = document.createNode({ kind: '#element', tag: 'style', attributes: {} }) + parent.appendChild(middleChild) + const lastChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + parent.appendChild(lastChild) + + expectConnections(parent, { firstChild, lastChild }) + expectConnections(firstChild, { parent, nextSibling: middleChild }) + expectConnections(middleChild, { parent, previousSibling: firstChild, nextSibling: lastChild }) + expectConnections(lastChild, { parent, previousSibling: middleChild }) + expectMutations(document, { nodeAdds: [parent, firstChild, middleChild, lastChild] }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { type: 2, id: 1, tagName: 'head', attributes: {}, childNodes: [], isSVG: undefined }, + { type: 2, id: 2, tagName: 'style', attributes: {}, childNodes: [], isSVG: undefined }, + { type: 2, id: 3, tagName: 'body', attributes: {}, childNodes: [], isSVG: undefined }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('cannot attach a node which is already connected', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const child = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(child) + const grandChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + child.appendChild(grandChild) + + expect(() => { + parent.appendChild(grandChild) + }).toThrow() + }) + + it('cannot attach a node to a disconnected parent', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const child = document.createNode({ kind: '#element', tag: 'style', attributes: {} }) + + const removed = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(removed) + removed.remove() + expect(() => { + removed.appendChild(child) + }).toThrow() + + const neverAttached = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + expect(() => { + neverAttached.appendChild(child) + }).toThrow() + }) + + it('cannot attach a node which was previously removed', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const child = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(child) + const grandChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + child.appendChild(grandChild) + grandChild.remove() + + expect(() => { + child.appendChild(grandChild) + }).toThrow() + }) + }) + + describe('before', () => { + it('can attach before an only child', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const lastChild = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(lastChild) + + const firstChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + lastChild.before(firstChild) + + expectConnections(parent, { firstChild, lastChild }) + expectConnections(firstChild, { parent, nextSibling: lastChild }) + expectConnections(lastChild, { parent, previousSibling: firstChild }) + expectMutations(document, { nodeAdds: [parent, lastChild, firstChild] }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { type: 2, id: 2, tagName: 'body', attributes: {}, childNodes: [], isSVG: undefined }, + { type: 2, id: 1, tagName: 'html', attributes: {}, childNodes: [], isSVG: undefined }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can attach at the beginning of a child list', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const middleChild = document.createNode({ kind: '#element', tag: 'style', attributes: {} }) + parent.appendChild(middleChild) + const lastChild = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + parent.appendChild(lastChild) + + const firstChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + middleChild.before(firstChild) + + expectConnections(parent, { firstChild, lastChild }) + expectConnections(firstChild, { parent, nextSibling: middleChild }) + expectConnections(middleChild, { parent, previousSibling: firstChild, nextSibling: lastChild }) + expectConnections(lastChild, { parent, previousSibling: middleChild }) + expectMutations(document, { nodeAdds: [parent, middleChild, lastChild, firstChild] }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { type: 2, id: 3, tagName: 'body', attributes: {}, childNodes: [], isSVG: undefined }, + { type: 2, id: 1, tagName: 'style', attributes: {}, childNodes: [], isSVG: undefined }, + { type: 2, id: 2, tagName: 'head', attributes: {}, childNodes: [], isSVG: undefined }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can attach in the middle of a child list', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const firstChild = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + parent.appendChild(firstChild) + const lastChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + parent.appendChild(lastChild) + + const middleChild = document.createNode({ kind: '#element', tag: 'style', attributes: {} }) + lastChild.before(middleChild) + + expectConnections(parent, { firstChild, lastChild }) + expectConnections(firstChild, { parent, nextSibling: middleChild }) + expectConnections(middleChild, { parent, previousSibling: firstChild, nextSibling: lastChild }) + expectConnections(lastChild, { parent, previousSibling: middleChild }) + expectMutations(document, { nodeAdds: [parent, firstChild, lastChild, middleChild] }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { type: 2, id: 1, tagName: 'head', attributes: {}, childNodes: [], isSVG: undefined }, + { type: 2, id: 3, tagName: 'style', attributes: {}, childNodes: [], isSVG: undefined }, + { type: 2, id: 2, tagName: 'body', attributes: {}, childNodes: [], isSVG: undefined }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('cannot attach a node which is already connected', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const child = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(child) + const grandChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + child.appendChild(grandChild) + + expect(() => { + child.before(grandChild) + }).toThrow() + }) + + it('cannot attach a node to a disconnected sibling', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const child = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(child) + const node = document.createNode({ kind: '#element', tag: 'div', attributes: {} }) + + const removed = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(removed) + removed.remove() + expect(() => { + removed.before(node) + }).toThrow() + + const neverAttached = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + expect(() => { + neverAttached.before(node) + }).toThrow() + }) + + it('cannot attach a node which was previously removed', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const child = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(child) + const node = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + child.appendChild(node) + node.remove() + + expect(() => { + child.before(node) + }).toThrow() + }) + + it('cannot attach a node as a sibling to the root node', () => { + const root = document.createNode({ kind: '#document' }) + document.root = root + const node = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + + expect(() => { + root.before(node) + }).toThrow() + }) + }) + + describe('remove', () => { + it('can remove an only child', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const child = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(child) + + child.remove() + + expectConnections(parent, {}) + expectConnections(child, { state: 'disconnected' }) + expectMutations(document, { nodeAdds: [parent, child], nodeRemoves: [{ node: child, parent }] }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can remove a node from the end of a child list', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const firstChild = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + parent.appendChild(firstChild) + const lastChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + parent.appendChild(lastChild) + + lastChild.remove() + + expectConnections(parent, { firstChild, lastChild: firstChild }) + expectConnections(firstChild, { parent }) + expectConnections(lastChild, { state: 'disconnected' }) + expectMutations(document, { + nodeAdds: [parent, firstChild, lastChild], + nodeRemoves: [{ node: lastChild, parent }], + }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [{ type: 2, id: 1, tagName: 'head', attributes: {}, childNodes: [], isSVG: undefined }], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can remove a node from the beginning of a child list', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const firstChild = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + parent.appendChild(firstChild) + const lastChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + parent.appendChild(lastChild) + + firstChild.remove() + + expectConnections(parent, { firstChild: lastChild, lastChild }) + expectConnections(firstChild, { state: 'disconnected' }) + expectConnections(lastChild, { parent }) + expectMutations(document, { + nodeAdds: [parent, firstChild, lastChild], + nodeRemoves: [{ node: firstChild, parent }], + }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [{ type: 2, id: 2, tagName: 'body', attributes: {}, childNodes: [], isSVG: undefined }], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can remove a node from the middle of a child list', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const firstChild = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + parent.appendChild(firstChild) + const middleChild = document.createNode({ kind: '#element', tag: 'style', attributes: {} }) + parent.appendChild(middleChild) + const lastChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + parent.appendChild(lastChild) + + middleChild.remove() + + expectConnections(parent, { firstChild, lastChild }) + expectConnections(firstChild, { parent, nextSibling: lastChild }) + expectConnections(middleChild, { state: 'disconnected' }) + expectConnections(lastChild, { parent, previousSibling: firstChild }) + expectMutations(document, { + nodeAdds: [parent, firstChild, middleChild, lastChild], + nodeRemoves: [{ node: middleChild, parent }], + }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { type: 2, id: 1, tagName: 'head', attributes: {}, childNodes: [], isSVG: undefined }, + { type: 2, id: 3, tagName: 'body', attributes: {}, childNodes: [], isSVG: undefined }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can remove a node with descendants', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + const firstChild = document.createNode({ kind: '#element', tag: 'head', attributes: {} }) + parent.appendChild(firstChild) + const middleChild = document.createNode({ kind: '#element', tag: 'style', attributes: {} }) + parent.appendChild(middleChild) + const lastChild = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + parent.appendChild(lastChild) + const middleFirstChild = document.createNode({ kind: '#element', tag: 'div', attributes: {} }) + middleChild.appendChild(middleFirstChild) + const middleLastChild = document.createNode({ kind: '#element', tag: 'span', attributes: {} }) + middleChild.appendChild(middleLastChild) + + middleChild.remove() + + expectConnections(parent, { firstChild, lastChild }) + expectConnections(firstChild, { parent, nextSibling: lastChild }) + expectConnections(middleChild, { + state: 'disconnected', + firstChild: middleFirstChild, + lastChild: middleLastChild, + }) + expectConnections(lastChild, { parent, previousSibling: firstChild }) + expectConnections(middleFirstChild, { state: 'disconnected', parent: middleChild, nextSibling: middleLastChild }) + expectConnections(middleLastChild, { + state: 'disconnected', + parent: middleChild, + previousSibling: middleFirstChild, + }) + expectMutations(document, { + nodeAdds: [parent, firstChild, middleChild, lastChild, middleFirstChild, middleLastChild], + nodeRemoves: [{ node: middleChild, parent }], + }) + + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { type: 2, id: 1, tagName: 'head', attributes: {}, childNodes: [], isSVG: undefined }, + { type: 2, id: 3, tagName: 'body', attributes: {}, childNodes: [], isSVG: undefined }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can remove a node which has already been removed', () => { + const parent = document.createNode({ kind: '#document' }) + document.root = parent + + const removed = document.createNode({ kind: '#element', tag: 'html', attributes: {} }) + parent.appendChild(removed) + removed.remove() + expect(() => { + removed.remove() + }).not.toThrow() + }) + + it('cannot remove a node which has never been attached', () => { + const neverAttached = document.createNode({ kind: '#element', tag: 'body', attributes: {} }) + expect(() => { + neverAttached.remove() + }).toThrow() + }) + + it('cannot remove the root node', () => { + const root = document.createNode({ kind: '#document' }) + document.root = root + + expect(() => { + root.remove() + }).toThrow() + }) + }) + + describe('setAttachedStyleSheets', () => { + let sheet: VStyleSheet + + beforeEach(() => { + sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: 'div { color: blue }', + }) + }) + + function expectCanAttachStyleSheet(nodeData: VNodeData & { attachedStyleSheets?: [VStyleSheet] }): void { + const node = document.createNode(nodeData) + document.root = node + + node.setAttachedStyleSheets([sheet]) + + expect(nodeData.attachedStyleSheets).toEqual([sheet]) + + // Incremental mutations for stylesheet attachment aren't supported yet. + expectMutations(document, { nodeAdds: [node] }) + } + + it('cannot attach stylesheets to #cdata-section nodes', () => { + expectThrowsForNode({ kind: '#cdata-section' }, (node) => node.setAttachedStyleSheets([sheet])) + }) + + it('cannot attach stylesheets to #doctype nodes', () => { + expectThrowsForNode({ kind: '#doctype', name: '', publicId: '', systemId: '' }, (node) => + node.setAttachedStyleSheets([sheet]) + ) + }) + + it('can attach stylesheets to #document nodes', () => { + expectCanAttachStyleSheet({ kind: '#document' }) + }) + + it('can attach stylesheets to #element nodes', () => { + expectCanAttachStyleSheet({ kind: '#element', tag: 'style', attributes: {} }) + }) + + it('can attach stylesheets to #document-fragment nodes', () => { + expectCanAttachStyleSheet({ kind: '#document-fragment' }) + }) + + it('can attach stylesheets to #shadow-root nodes', () => { + expectCanAttachStyleSheet({ kind: '#shadow-root' }) + }) + + it('cannot attach stylesheets to #text', () => { + expectThrowsForNode({ kind: '#text', textContent: '' }, (node) => node.setAttachedStyleSheets([sheet])) + }) + + it('cannot attach stylesheets to a node which is not connected', () => { + expectThrowsForDisconnectedNodes((node: VNode) => { + node.setAttachedStyleSheets([sheet]) + }) + }) + }) + + describe('setAttribute', () => { + it('cannot set an attribute on #cdata-section nodes', () => { + expectThrowsForNode({ kind: '#cdata-section' }, (node) => node.setAttribute('foo', 'bar')) + }) + + it('cannot set an attribute on #doctype nodes', () => { + expectThrowsForNode({ kind: '#doctype', name: '', publicId: '', systemId: '' }, (node) => + node.setAttribute('foo', 'bar') + ) + }) + + it('cannot set an attribute on #document nodes', () => { + expectThrowsForNode({ kind: '#document' }, (node) => node.setAttribute('foo', 'bar')) + }) + + it('cannot set an attribute on #document-fragment nodes', () => { + expectThrowsForNode({ kind: '#document-fragment' }, (node) => node.setAttribute('foo', 'bar')) + }) + + it('can set an attribute on #element nodes', () => { + const elementData: VNodeData = { kind: '#element', tag: 'div', attributes: {} } + const element = document.createNode(elementData) + document.root = element + + element.setAttribute('foo', 'bar') + + expect(elementData.attributes).toEqual({ foo: 'bar' }) + expectMutations(document, { attributeChanges: [{ node: element, attributes: ['foo'] }], nodeAdds: [element] }) + + expectFullSnapshotRendering(document, { + node: { + type: 2, + id: 0, + tagName: 'div', + attributes: { foo: 'bar' }, + childNodes: [], + isSVG: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can set multiple attributes on #element nodes', () => { + const elementData: VNodeData = { kind: '#element', tag: 'div', attributes: {} } + const element = document.createNode(elementData) + document.root = element + + element.setAttribute('foo', 'bar') + element.setAttribute('baz', 'bat') + + expect(elementData.attributes).toEqual({ foo: 'bar', baz: 'bat' }) + expectMutations(document, { + attributeChanges: [{ node: element, attributes: ['foo', 'baz'] }], + nodeAdds: [element], + }) + + expectFullSnapshotRendering(document, { + node: { + type: 2, + id: 0, + tagName: 'div', + attributes: { foo: 'bar', baz: 'bat' }, + childNodes: [], + isSVG: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can clear an attribute on #element nodes', () => { + const elementData: VNodeData = { kind: '#element', tag: 'div', attributes: {} } + const element = document.createNode(elementData) + document.root = element + + element.setAttribute('foo', 'bar') + element.setAttribute('foo', null) + + expect(elementData.attributes).toEqual({}) + expectMutations(document, { attributeChanges: [{ node: element, attributes: ['foo'] }], nodeAdds: [element] }) + + expectFullSnapshotRendering(document, { + node: { + type: 2, + id: 0, + tagName: 'div', + attributes: {}, + childNodes: [], + isSVG: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('can clear an attribute on #element nodes which was not already set', () => { + const elementData: VNodeData = { kind: '#element', tag: 'div', attributes: {} } + const element = document.createNode(elementData) + document.root = element + + element.setAttribute('foo', null) + + expect(elementData.attributes).toEqual({}) + expectMutations(document, { attributeChanges: [{ node: element, attributes: ['foo'] }], nodeAdds: [element] }) + + expectFullSnapshotRendering(document, { + node: { + type: 2, + id: 0, + tagName: 'div', + attributes: {}, + childNodes: [], + isSVG: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('cannot set an attribute on #shadow-root nodes', () => { + expectThrowsForNode({ kind: '#shadow-root' }, (node) => node.setAttribute('foo', 'bar')) + }) + + it('cannot set an attribute on #text nodes', () => { + expectThrowsForNode({ kind: '#text', textContent: '' }, (node) => node.setAttribute('foo', 'bar')) + }) + + it('cannot set an attribute on a node which is not connected', () => { + expectThrowsForDisconnectedNodes((node: VNode) => { + node.setAttribute('foo', 'bar') + }) + }) + }) + + describe('setPlaybackState', () => { + it('cannot set playback state on #cdata-section nodes', () => { + expectThrowsForNode({ kind: '#cdata-section' }, (node) => node.setPlaybackState(PlaybackState.Playing)) + }) + + it('cannot set playback state on #doctype nodes', () => { + expectThrowsForNode({ kind: '#doctype', name: '', publicId: '', systemId: '' }, (node) => + node.setPlaybackState(PlaybackState.Playing) + ) + }) + + it('cannot set playback state on #document nodes', () => { + expectThrowsForNode({ kind: '#document' }, (node) => node.setPlaybackState(PlaybackState.Playing)) + }) + + it('cannot set playback state on #document-fragment nodes', () => { + expectThrowsForNode({ kind: '#document-fragment' }, (node) => node.setPlaybackState(PlaybackState.Playing)) + }) + + it('can set playback state on #element nodes', () => { + const elementData: VNodeData = { kind: '#element', tag: 'div', attributes: {} } + const element = document.createNode(elementData) + document.root = element + expect(elementData.playbackState).toBe(undefined) + + element.setPlaybackState(PlaybackState.Playing) + expect(elementData.playbackState).toBe(PlaybackState.Playing) + + element.setPlaybackState(PlaybackState.Paused) + expect(elementData.playbackState).toBe(PlaybackState.Paused) + + // Incremental mutations for playback state aren't supported yet. + expectMutations(document, { nodeAdds: [element] }) + + expectFullSnapshotRendering(document, { + node: { + type: 2, + id: 0, + tagName: 'div', + attributes: { rr_mediaState: 'paused' }, + childNodes: [], + isSVG: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('cannot set playback state on #shadow-root nodes', () => { + expectThrowsForNode({ kind: '#shadow-root' }, (node) => node.setPlaybackState(PlaybackState.Playing)) + }) + + it('cannot set playback state on #text nodes', () => { + expectThrowsForNode({ kind: '#text', textContent: '' }, (node) => node.setPlaybackState(PlaybackState.Playing)) + }) + + it('cannot set playback state on a node which is not connected', () => { + expectThrowsForDisconnectedNodes((node: VNode) => { + node.setPlaybackState(PlaybackState.Paused) + }) + }) + }) + + describe('setScrollPosition', () => { + function describeScrollPositionBehavior( + createNodeData: () => VNodeData & { scrollLeft?: number; scrollTop?: number } + ): void { + it('can set scroll position', () => { + const nodeData = createNodeData() + const node = document.createNode(nodeData) + document.root = node + expect(nodeData.scrollLeft).toBe(undefined) + expect(nodeData.scrollTop).toBe(undefined) + + node.setScrollPosition(5, 10) + expect(nodeData.scrollLeft).toBe(5) + expect(nodeData.scrollTop).toBe(10) + + node.setScrollPosition(10, 20) + expect(nodeData.scrollLeft).toBe(10) + expect(nodeData.scrollTop).toBe(20) + + // Incremental mutations for scroll position aren't supported yet. + expectMutations(document, { nodeAdds: [node] }) + + if (nodeData.kind === '#element') { + expectFullSnapshotRendering(document, { + node: { + type: 2, + id: 0, + tagName: nodeData.tag, + attributes: { rr_scrollLeft: 10, rr_scrollTop: 20 }, + childNodes: [], + isSVG: undefined, + }, + initialOffset: { left: 10, top: 20 }, + }) + } else if (nodeData.kind === '#document') { + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 10, top: 20 }, + }) + } + }) + + it('ignores zero initial scroll coordinates', () => { + const nodeData = createNodeData() + const node = document.createNode(nodeData) + document.root = node + expect(nodeData.scrollLeft).toBe(undefined) + expect(nodeData.scrollTop).toBe(undefined) + + node.setScrollPosition(0, 0) + expect(nodeData.scrollLeft).toBe(undefined) + expect(nodeData.scrollTop).toBe(undefined) + + // Incremental mutations for scroll position aren't supported yet. + expectMutations(document, { nodeAdds: [node] }) + + if (nodeData.kind === '#element') { + expectFullSnapshotRendering(document, { + node: { + type: 2, + id: 0, + tagName: nodeData.tag, + attributes: {}, + childNodes: [], + isSVG: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + } else if (nodeData.kind === '#document') { + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + } + }) + + it('sets zero scroll coordinates on #element nodes if they were previously non-zero', () => { + const nodeData = createNodeData() + const node = document.createNode(nodeData) + document.root = node + expect(nodeData.scrollLeft).toBe(undefined) + expect(nodeData.scrollTop).toBe(undefined) + + node.setScrollPosition(10, 20) + expect(nodeData.scrollLeft).toBe(10) + expect(nodeData.scrollTop).toBe(20) + + node.setScrollPosition(0, 0) + expect(nodeData.scrollLeft).toBe(0) + expect(nodeData.scrollTop).toBe(0) + + // Incremental mutations for scroll position aren't supported yet. + expectMutations(document, { nodeAdds: [node] }) + + if (nodeData.kind === '#element') { + expectFullSnapshotRendering(document, { + node: { + type: 2, + id: 0, + tagName: nodeData.tag, + attributes: {}, + childNodes: [], + isSVG: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + } else if (nodeData.kind === '#document') { + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + } + }) + + it('ignores zero initial scroll coordinates on #element nodes independently for each coordinate', () => { + const node1Data = createNodeData() + const node1 = document.createNode(node1Data) + document.root = node1 + const node2Data = createNodeData() + const node2 = document.createNode(node2Data) + node1.appendChild(node2) + + expect(node1Data.scrollLeft).toBe(undefined) + expect(node1Data.scrollTop).toBe(undefined) + expect(node2Data.scrollLeft).toBe(undefined) + expect(node2Data.scrollTop).toBe(undefined) + + node1.setScrollPosition(0, 20) + expect(node1Data.scrollLeft).toBe(undefined) + expect(node1Data.scrollTop).toBe(20) + + node2.setScrollPosition(10, 0) + expect(node2Data.scrollLeft).toBe(10) + expect(node2Data.scrollTop).toBe(undefined) + + // Incremental mutations for scroll position aren't supported yet. + expectMutations(document, { nodeAdds: [node1, node2] }) + + if (node1Data.kind === '#element' && node2Data.kind === '#element') { + expectFullSnapshotRendering(document, { + node: { + type: 2, + id: 0, + tagName: node1Data.tag, + attributes: { rr_scrollTop: 20 }, + childNodes: [ + { + type: 2, + id: 1, + tagName: node2Data.tag, + attributes: { rr_scrollLeft: 10 }, + childNodes: [], + isSVG: undefined, + }, + ], + isSVG: undefined, + }, + initialOffset: { left: 0, top: 20 }, + }) + } else if (node1Data.kind === '#document' && node2Data.kind === '#document') { + expectFullSnapshotRendering(document, { + node: { + type: 0, + id: 0, + childNodes: [ + { + type: 0, + id: 1, + childNodes: [], + adoptedStyleSheets: undefined, + }, + ], + adoptedStyleSheets: undefined, + }, + initialOffset: { left: 0, top: 20 }, + }) + } + }) + } + + it('cannot set scroll position on #cdata-section nodes', () => { + expectThrowsForNode({ kind: '#cdata-section' }, (node) => node.setScrollPosition(10, 20)) + }) + + describe('for #element nodes', () => { + describeScrollPositionBehavior(() => ({ + kind: '#element', + tag: 'div', + attributes: {}, + })) + }) + + it('cannot set scroll position on #doctype nodes', () => { + expectThrowsForNode({ kind: '#doctype', name: '', publicId: '', systemId: '' }, (node) => + node.setScrollPosition(10, 20) + ) + }) + + describe('for #document nodes', () => { + describeScrollPositionBehavior(() => ({ kind: '#document' })) + }) + + it('cannot set scroll position on #document-fragment nodes', () => { + expectThrowsForNode({ kind: '#document-fragment' }, (node) => node.setScrollPosition(10, 20)) + }) + + it('cannot set scroll position on #shadow-root nodes', () => { + expectThrowsForNode({ kind: '#shadow-root' }, (node) => node.setScrollPosition(10, 20)) + }) + + it('cannot set scroll position on #text nodes', () => { + expectThrowsForNode({ kind: '#text', textContent: '' }, (node) => node.setScrollPosition(10, 20)) + }) + + it('cannot set scroll position on a node which is not connected', () => { + expectThrowsForDisconnectedNodes((node: VNode) => { + node.setScrollPosition(10, 20) + }) + }) + }) + + describe('setSize', () => { + it('cannot set size on #cdata-section nodes', () => { + expectThrowsForNode({ kind: '#cdata-section' }, (node) => node.setSize(10, 20)) + }) + + it('cannot set size on #doctype nodes', () => { + expectThrowsForNode({ kind: '#doctype', name: '', publicId: '', systemId: '' }, (node) => node.setSize(10, 20)) + }) + + it('cannot set size on #document nodes', () => { + expectThrowsForNode({ kind: '#document' }, (node) => node.setSize(10, 20)) + }) + + it('cannot set size on #document-fragment nodes', () => { + expectThrowsForNode({ kind: '#document-fragment' }, (node) => node.setSize(10, 20)) + }) + + it('can set size on #element nodes', () => { + const elementData: VNodeData = { kind: '#element', tag: 'div', attributes: {} } + const element = document.createNode(elementData) + document.root = element + expect(elementData.width).toBe(undefined) + expect(elementData.height).toBe(undefined) + + element.setSize(1, 2) + expect(elementData.width).toBe(1) + expect(elementData.height).toBe(2) + + element.setSize(10, 20) + expect(elementData.width).toBe(10) + expect(elementData.height).toBe(20) + + // Incremental mutations for size aren't supported yet. + expectMutations(document, { nodeAdds: [element] }) + + expectFullSnapshotRendering(document, { + node: { + type: 2, + id: 0, + tagName: 'div', + attributes: { rr_width: '10px', rr_height: '20px' }, + childNodes: [], + isSVG: undefined, + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('cannot set size on #shadow-root nodes', () => { + expectThrowsForNode({ kind: '#shadow-root' }, (node) => node.setSize(10, 20)) + }) + + it('cannot set size on #text nodes', () => { + expectThrowsForNode({ kind: '#text', textContent: '' }, (node) => node.setSize(10, 20)) + }) + + it('cannot set size on a node which is not connected', () => { + expectThrowsForDisconnectedNodes((node: VNode) => { + node.setSize(10, 20) + }) + }) + }) + + describe('setTextContent', () => { + it('cannot set text content on #cdata-section nodes', () => { + expectThrowsForNode({ kind: '#cdata-section' }, (node) => node.setTextContent('')) + }) + + it('cannot set text content on #doctype nodes', () => { + expectThrowsForNode({ kind: '#doctype', name: '', publicId: '', systemId: '' }, (node) => node.setTextContent('')) + }) + + it('cannot set text content on #document nodes', () => { + expectThrowsForNode({ kind: '#document' }, (node) => node.setTextContent('')) + }) + + it('cannot set text content on #document-fragment nodes', () => { + expectThrowsForNode({ kind: '#document-fragment' }, (node) => node.setTextContent('')) + }) + + it('cannot set text content on #element nodes', () => { + expectThrowsForNode({ kind: '#element', tag: 'div', attributes: {} }, (node) => node.setTextContent('')) + }) + + it('cannot set text content on #shadow-root nodes', () => { + expectThrowsForNode({ kind: '#shadow-root' }, (node) => node.setTextContent('')) + }) + + it('can set text content on #text nodes', () => { + const textData: VNodeData = { kind: '#text', textContent: '' } + const text = document.createNode(textData) + document.root = text + expect(textData.textContent).toBe('') + + text.setTextContent('foo') + expect(textData.textContent).toBe('foo') + + text.setTextContent('bar') + expect(textData.textContent).toBe('bar') + + expectMutations(document, { nodeAdds: [text], textChanges: [text] }) + + expectFullSnapshotRendering(document, { + node: { + type: 3, + id: 0, + textContent: 'bar', + }, + initialOffset: { left: 0, top: 0 }, + }) + }) + + it('cannot set text content on a node which is not connected', () => { + expectThrowsForDisconnectedNodes((node: VNode) => { + node.setTextContent('') + }) + }) + }) + + describe('render', () => { + it('can render a #cdata-section node', () => { + // Create a parent node to hold both test nodes + const parent = document.createNode({ kind: '#document' }) + document.root = parent + + // Minimal #cdata-section node (no optional properties) + const minimalNode = document.createNode({ kind: '#cdata-section' }) + parent.appendChild(minimalNode) + expectNodeRendering(minimalNode, { + type: 4, + id: 1, + textContent: '', + }) + + // Maximal #cdata-section node (same as minimal - no optional properties) + const maximalNode = document.createNode({ kind: '#cdata-section' }) + parent.appendChild(maximalNode) + expectNodeRendering(maximalNode, { + type: 4, + id: 2, + textContent: '', + }) + }) + + it('can render a #doctype node', () => { + // Create a parent node to hold both test nodes + const parent = document.createNode({ kind: '#document' }) + document.root = parent + + // Minimal #doctype node (empty strings) + const minimalNode = document.createNode({ kind: '#doctype', name: '', publicId: '', systemId: '' }) + parent.appendChild(minimalNode) + expectNodeRendering(minimalNode, { + type: 1, + id: 1, + name: '', + publicId: '', + systemId: '', + }) + + // Maximal #doctype node (all properties set) + const maximalNode = document.createNode({ + kind: '#doctype', + name: 'html', + publicId: '-//W3C//DTD XHTML 1.0 Strict//EN', + systemId: 'http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd', + }) + parent.appendChild(maximalNode) + expectNodeRendering(maximalNode, { + type: 1, + id: 2, + name: 'html', + publicId: '-//W3C//DTD XHTML 1.0 Strict//EN', + systemId: 'http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd', + }) + }) + + it('can render a #document node', () => { + // Minimal #document node (no optional properties) + const minimalNode = document.createNode({ kind: '#document' }) + document.root = minimalNode + expectNodeRendering(minimalNode, { + type: 0, + id: 0, + childNodes: [], + adoptedStyleSheets: undefined, + }) + + // Maximal #document node (all optional properties set) + const styleSheet = document.createStyleSheet({ + disabled: true, + mediaList: ['screen', 'print'], + rules: 'div { color: red }', + }) + const maximalData: VNodeData = { kind: '#document' } + const maximalNode = document.createNode(maximalData) + minimalNode.appendChild(maximalNode) + maximalNode.setAttachedStyleSheets([styleSheet]) + expectNodeRendering(maximalNode, { + type: 0, + id: 1, + childNodes: [], + adoptedStyleSheets: [ + { + cssRules: ['div { color: red }'], + disabled: true, + media: ['screen', 'print'], + }, + ], + }) + }) + + it('can render a #document-fragment node', () => { + // Create a parent node to hold both test nodes + const parent = document.createNode({ kind: '#document' }) + document.root = parent + + // Minimal #document-fragment node (no optional properties) + const minimalNode = document.createNode({ kind: '#document-fragment' }) + parent.appendChild(minimalNode) + expectNodeRendering(minimalNode, { + type: 11, + id: 1, + childNodes: [], + adoptedStyleSheets: undefined, + isShadowRoot: false, + }) + + // Maximal #document-fragment node (all optional properties set) + const styleSheet = document.createStyleSheet({ + disabled: true, + mediaList: ['screen', 'print'], + rules: 'span { background: yellow }', + }) + const maximalData: VNodeData = { kind: '#document-fragment' } + const maximalNode = document.createNode(maximalData) + parent.appendChild(maximalNode) + maximalNode.setAttachedStyleSheets([styleSheet]) + expectNodeRendering(maximalNode, { + type: 11, + id: 2, + childNodes: [], + adoptedStyleSheets: [ + { + cssRules: ['span { background: yellow }'], + disabled: true, + media: ['screen', 'print'], + }, + ], + isShadowRoot: false, + }) + }) + + it('can render a #element node', () => { + // Create a parent node to hold both test nodes + const parent = document.createNode({ kind: '#document' }) + document.root = parent + + // Minimal #element node (no optional properties) + const minimalNode = document.createNode({ kind: '#element', tag: 'div', attributes: {} }) + parent.appendChild(minimalNode) + expectNodeRendering(minimalNode, { + type: 2, + id: 1, + tagName: 'div', + attributes: {}, + childNodes: [], + isSVG: undefined, + }) + + // Maximal #element node (all optional properties set) + const styleSheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: 'p { font-size: 14px }', + }) + const maximalData: VNodeData = { + kind: '#element', + tag: 'svg', + attributes: { id: 'test', class: 'foo' }, + isSVG: true, + } + const maximalNode = document.createNode(maximalData) + parent.appendChild(maximalNode) + maximalNode.setPlaybackState(PlaybackState.Playing) + maximalNode.setScrollPosition(10, 20) + maximalNode.setSize(100, 200) + maximalNode.setAttachedStyleSheets([styleSheet]) + expectNodeRendering(maximalNode, { + type: 2, + id: 2, + tagName: 'svg', + attributes: { + rr_width: '100px', + rr_height: '200px', + id: 'test', + class: 'foo', + _cssText: 'p { font-size: 14px }', + rr_mediaState: 'played', + rr_scrollLeft: 10, + rr_scrollTop: 20, + }, + childNodes: [], + isSVG: true, + }) + }) + + it('can render a #shadow-root node', () => { + // Create a parent node to hold both test nodes + const parent = document.createNode({ kind: '#document' }) + document.root = parent + + // Minimal #shadow-root node (no optional properties) + const minimalNode = document.createNode({ kind: '#shadow-root' }) + parent.appendChild(minimalNode) + expectNodeRendering(minimalNode, { + type: 11, + id: 1, + childNodes: [], + adoptedStyleSheets: undefined, + isShadowRoot: true, + }) + + // Maximal #shadow-root node (all optional properties set) + const styleSheet = document.createStyleSheet({ + disabled: true, + mediaList: ['screen'], + rules: 'h1 { color: blue }', + }) + const maximalData: VNodeData = { kind: '#shadow-root' } + const maximalNode = document.createNode(maximalData) + parent.appendChild(maximalNode) + maximalNode.setAttachedStyleSheets([styleSheet]) + expectNodeRendering(maximalNode, { + type: 11, + id: 2, + childNodes: [], + adoptedStyleSheets: [ + { + cssRules: ['h1 { color: blue }'], + disabled: true, + media: ['screen'], + }, + ], + isShadowRoot: true, + }) + }) + + it('can render a #text node', () => { + // Create a parent node to hold both test nodes + const parent = document.createNode({ kind: '#document' }) + document.root = parent + + // Minimal #text node (empty textContent) + const minimalNode = document.createNode({ kind: '#text', textContent: '' }) + parent.appendChild(minimalNode) + expectNodeRendering(minimalNode, { + type: 3, + id: 1, + textContent: '', + }) + + // Maximal #text node (non-empty textContent) + const maximalNode = document.createNode({ kind: '#text', textContent: 'Hello, world!' }) + parent.appendChild(maximalNode) + expectNodeRendering(maximalNode, { + type: 3, + id: 2, + textContent: 'Hello, world!', + }) + }) + }) +}) diff --git a/packages/rum/src/domain/record/serialization/conversions/vNode.ts b/packages/rum/src/domain/record/serialization/conversions/vNode.ts new file mode 100644 index 0000000000..fdb0d6b086 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/vNode.ts @@ -0,0 +1,417 @@ +import type { SerializedNodeWithId } from '../../../../types' +import { PlaybackState, NodeType } from '../../../../types' +import type { NodeId } from '../../itemIds' +import type { V1RenderOptions } from './renderOptions' +import type { VDocument } from './vDocument' +import type { VStyleSheet } from './vStyleSheet' + +export interface VNode { + after(node: VNode): void + appendChild(node: VNode): void + before(node: VNode): void + remove(): void + + setAttachedStyleSheets(sheets: VStyleSheet[]): void + setAttribute(name: string, value: string | null): void + setPlaybackState(state: PlaybackState): void + setScrollPosition(left: number, top: number): void + setSize(width: number, height: number): void + setTextContent(value: string): void + + forEachAddedNodeRoot(nodeAdds: Set, action: (node: VNode) => void): void + forSelfAndEachDescendant(action: (node: VNode) => void): void + mapChildren(action: (node: VNode) => Result): Result[] + + render(options: V1RenderOptions): SerializedNodeWithId + + get data(): VNodeData + get id(): NodeId + get ownerDocument(): VDocument + + state: VNodeState + firstChild: VNode | undefined + lastChild: VNode | undefined + previousSibling: VNode | undefined + nextSibling: VNode | undefined + parent: VNode | undefined +} + +export type VNodeState = 'new' | 'connected' | 'disconnected' + +export type VNodeData = + | { + kind: '#cdata-section' + } + | { + kind: '#document' + attachedStyleSheets?: VStyleSheet[] | undefined + scrollLeft?: number + scrollTop?: number + } + | { + kind: '#doctype' + name: string + publicId: string + systemId: string + } + | { + kind: '#document-fragment' + attachedStyleSheets?: VStyleSheet[] | undefined + } + | { + kind: '#element' + tag: string + attachedStyleSheets?: VStyleSheet[] | undefined + attributes: Record + isSVG?: boolean + playbackState?: PlaybackState + scrollLeft?: number + scrollTop?: number + width?: number + height?: number + } + | { + kind: '#shadow-root' + attachedStyleSheets?: VStyleSheet[] | undefined + } + | { + kind: '#text' + textContent: string + } + +export function createVNode(document: VDocument, id: NodeId, data: VNodeData): VNode { + let state: VNodeState = 'new' + + const assertConnectedCorrectly = (): void => { + if (self.state !== 'connected') { + throw new Error(`Expected node ${self.id} to be connected, not ${self.state}`) + } + + for (const connection of ['firstChild', 'lastChild', 'previousSibling', 'nextSibling', 'parent'] as const) { + const connectedNode = self[connection] + if (connectedNode && connectedNode.state !== 'connected') { + throw new Error(`Expected node ${self.id}'s ${connection} to be connected, not ${connectedNode.state}`) + } + } + } + + const self: VNode = { + after(node: VNode): void { + node.parent = self.parent + node.previousSibling = self + + if (self.nextSibling) { + self.nextSibling.previousSibling = node + } + node.nextSibling = self.nextSibling + self.nextSibling = node + + if (self.parent?.lastChild === self) { + self.parent.lastChild = node + } + + document.onNodeConnected(node, self.parent) + assertConnectedCorrectly() + }, + + appendChild(node: VNode): void { + if (self.lastChild) { + self.lastChild.after(node) + return + } + + node.parent = self + self.firstChild = node + self.lastChild = node + + document.onNodeConnected(node, self) + assertConnectedCorrectly() + }, + + before(node: VNode): void { + node.parent = self.parent + node.nextSibling = self + + if (self.previousSibling) { + self.previousSibling.nextSibling = node + } + node.previousSibling = self.previousSibling + self.previousSibling = node + + if (self.parent?.firstChild === self) { + self.parent.firstChild = node + } + + document.onNodeConnected(node, self.parent) + assertConnectedCorrectly() + }, + + remove(): void { + if (self.state === 'disconnected') { + return // This is a redundant remove. + } + + if (self.parent?.firstChild === self) { + self.parent.firstChild = self.nextSibling + } + if (self.parent?.lastChild === self) { + self.parent.lastChild = self.previousSibling + } + if (self.previousSibling) { + self.previousSibling.nextSibling = self.nextSibling + } + if (self.nextSibling) { + self.nextSibling.previousSibling = self.previousSibling + } + + const parent = self.parent + self.parent = undefined + self.previousSibling = undefined + self.nextSibling = undefined + + document.onNodeDisconnected(self, parent) + }, + + setAttachedStyleSheets(sheets: VStyleSheet[]): void { + assertConnectedCorrectly() + + switch (data.kind) { + case '#document': + case '#document-fragment': + case '#element': + case '#shadow-root': + data.attachedStyleSheets = sheets + break + + default: + throw new Error(`Cannot attach stylesheets to ${data.kind} node ${id}`) + } + }, + + setAttribute(name: string, value: string | null): void { + assertConnectedCorrectly() + + if (data.kind !== '#element') { + throw new Error(`Cannot set attribute ${name} on ${data.kind} node ${id}`) + } + + if (value === null) { + delete data.attributes[name] + } else { + data.attributes[name] = value + } + + document.onAttributeChanged(self, name) + }, + + setPlaybackState(state: PlaybackState): void { + assertConnectedCorrectly() + + if (data.kind !== '#element') { + throw new Error(`Cannot set media playback state of ${data.kind} node ${id}`) + } + + data.playbackState = state + }, + + setScrollPosition(left: number, top: number): void { + assertConnectedCorrectly() + + if (data.kind !== '#document' && data.kind !== '#element') { + throw new Error(`Cannot set scroll position on ${data.kind} node ${id}`) + } + + // Treat zero coordinates as 'not scrolled' unless this element has scrolled in the past. + if (left !== 0 || data.scrollLeft !== undefined) { + data.scrollLeft = left + } + if (top !== 0 || data.scrollTop !== undefined) { + data.scrollTop = top + } + }, + + setSize(width: number, height: number): void { + assertConnectedCorrectly() + + if (data.kind !== '#element') { + throw new Error(`Cannot set size of ${data.kind} node ${id}`) + } + + data.width = width + data.height = height + }, + + setTextContent(value: string): void { + assertConnectedCorrectly() + + if (data.kind !== '#text') { + throw new Error(`Cannot set text on ${data.kind} node ${id}`) + } + + data.textContent = value + + document.onTextChanged(self) + }, + + forEachAddedNodeRoot(nodeAdds: Set, action: (node: VNode) => void): void { + if (nodeAdds.has(id)) { + // This is the root of a newly-added subtree. + action(self) + return + } + + // This is an existing node, but there may be new nodes among our descendants. Visit + // children in reverse order to match the ordering that the V1 serialization + // algorithm would use. + for (let child = self.lastChild; child; child = child.previousSibling) { + child.forEachAddedNodeRoot(nodeAdds, action) + } + }, + + forSelfAndEachDescendant(action: (node: VNode) => void): void { + action(self) + for (let child = self.firstChild; child; child = child.nextSibling) { + child.forSelfAndEachDescendant(action) + } + }, + + mapChildren(action: (node: VNode) => Result): Result[] { + const results: Result[] = [] + for (let child = self.firstChild; child; child = child.nextSibling) { + results.push(action(child)) + } + return results + }, + + render(options: V1RenderOptions): SerializedNodeWithId { + assertConnectedCorrectly() + + const id = options.nodeIdRemapper?.remap(self.id) ?? self.id + + switch (data.kind) { + case '#cdata-section': + return { + type: NodeType.CDATA, + id, + textContent: '', + } + + case '#doctype': + return { + type: NodeType.DocumentType, + id, + name: data.name, + publicId: data.publicId, + systemId: data.systemId, + } + + case '#document': + return { + type: NodeType.Document, + id, + childNodes: self.mapChildren((node) => node.render(options)), + adoptedStyleSheets: data.attachedStyleSheets?.map((sheet) => sheet.renderAsAdoptedStyleSheet()), + } + + case '#document-fragment': + return { + type: NodeType.DocumentFragment, + id, + childNodes: self.mapChildren((node) => node.render(options)), + adoptedStyleSheets: data.attachedStyleSheets?.map((sheet) => sheet.renderAsAdoptedStyleSheet()), + isShadowRoot: false, + } + + case '#element': { + const attributes: Record = {} + + // Add size-related virtual attributes before the real DOM attributes, to match + // the ordering used in the V1 format. + if (data.width !== undefined && data.height !== undefined) { + attributes.rr_width = `${data.width}px` + attributes.rr_height = `${data.height}px` + } + + // Add DOM attributes. + Object.assign(attributes, data.attributes) + + // Add other virtual attributes after the real DOM attributes, to match the + // ordering used in the V1 format. + if (data.attachedStyleSheets !== undefined) { + attributes._cssText = data.attachedStyleSheets.map((sheet) => sheet.renderAsCssText()).join('') + } + if (data.playbackState !== undefined) { + attributes.rr_mediaState = data.playbackState === PlaybackState.Paused ? 'paused' : 'played' + } + if (data.scrollLeft) { + attributes.rr_scrollLeft = data.scrollLeft + } + if (data.scrollTop) { + attributes.rr_scrollTop = data.scrollTop + } + + return { + type: NodeType.Element, + id, + tagName: data.tag, + attributes, + childNodes: self.mapChildren((node) => node.render(options)), + isSVG: data.isSVG === true ? true : undefined, + } + } + + case '#shadow-root': + return { + type: NodeType.DocumentFragment, + id, + childNodes: self.mapChildren((node) => node.render(options)), + adoptedStyleSheets: data.attachedStyleSheets?.map((sheet) => sheet.renderAsAdoptedStyleSheet()), + isShadowRoot: true, + } + + case '#text': + return { + type: NodeType.Text, + id, + textContent: data.textContent, + } + + default: + data satisfies never + throw new Error(`Rendering not implemented for ${self.data.kind} node ${id}`) + } + }, + + get data(): VNodeData { + return data + }, + get ownerDocument(): VDocument { + return document + }, + get id(): NodeId { + return id + }, + + get state(): VNodeState { + return state + }, + set state(value: VNodeState) { + if ( + (state === 'new' && value !== 'connected') || + (state === 'connected' && value !== 'disconnected') || + state === 'disconnected' + ) { + throw new Error(`Invalid state transition from ${state} to ${value}`) + } + state = value + }, + + firstChild: undefined, + lastChild: undefined, + previousSibling: undefined, + nextSibling: undefined, + parent: undefined, + } + + return self +} diff --git a/packages/rum/src/domain/record/serialization/conversions/vStyleSheet.spec.ts b/packages/rum/src/domain/record/serialization/conversions/vStyleSheet.spec.ts new file mode 100644 index 0000000000..ee50315c3b --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/vStyleSheet.spec.ts @@ -0,0 +1,420 @@ +import type { StyleSheetId } from '../../itemIds' +import type { VDocument } from './vDocument' +import { createVDocument } from './vDocument' + +describe('VStyleSheet', () => { + let document: VDocument + + beforeEach(() => { + document = createVDocument() + }) + + it('has the expected state on creation', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: 'div { color: red }', + }) + + expect(sheet.id).toBe(0 as StyleSheetId) + expect(sheet.ownerDocument).toBe(document) + expect(sheet.data).toEqual({ + disabled: false, + mediaList: [], + rules: 'div { color: red }', + }) + }) + + describe('renderAsAdoptedStyleSheet', () => { + it('converts string rules to array', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: 'div { color: red }', + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result).toEqual({ + cssRules: ['div { color: red }'], + disabled: undefined, + media: undefined, + }) + }) + + it('preserves array rules', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: ['div { color: red }', 'span { color: blue }'], + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result).toEqual({ + cssRules: ['div { color: red }', 'span { color: blue }'], + disabled: undefined, + media: undefined, + }) + }) + + it('renders disabled as true when data.disabled is true', () => { + const sheet = document.createStyleSheet({ + disabled: true, + mediaList: [], + rules: 'div { color: red }', + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result.disabled).toBe(true) + }) + + it('renders disabled as undefined when data.disabled is false', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: 'div { color: red }', + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result.disabled).toBe(undefined) + }) + + it('renders media when mediaList has elements', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: ['screen', 'print'], + rules: 'div { color: red }', + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result.media).toEqual(['screen', 'print']) + }) + + it('renders media as undefined when mediaList is empty', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: 'div { color: red }', + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result.media).toBe(undefined) + }) + + it('renders media when mediaList has a single element', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: ['screen'], + rules: 'div { color: red }', + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result.media).toEqual(['screen']) + }) + + it('handles empty string rules', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: '', + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result).toEqual({ + cssRules: [''], + disabled: undefined, + media: undefined, + }) + }) + + it('handles empty array rules', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: [], + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result).toEqual({ + cssRules: [], + disabled: undefined, + media: undefined, + }) + }) + + it('renders all properties when all are set', () => { + const sheet = document.createStyleSheet({ + disabled: true, + mediaList: ['screen', 'print'], + rules: ['div { color: red }', 'span { color: blue }', 'p { font-size: 14px }'], + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result).toEqual({ + cssRules: ['div { color: red }', 'span { color: blue }', 'p { font-size: 14px }'], + disabled: true, + media: ['screen', 'print'], + }) + }) + + it('handles complex CSS rules as string', () => { + const complexCSS = ` + @media screen and (min-width: 768px) { + .container { + max-width: 1200px; + } + } + @keyframes fade { + from { opacity: 0; } + to { opacity: 1; } + } + ` + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: complexCSS, + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result).toEqual({ + cssRules: [complexCSS], + disabled: undefined, + media: undefined, + }) + }) + + it('handles complex CSS rules as array', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: [ + '@media screen { div { color: red } }', + '@keyframes fade { from { opacity: 0 } }', + '.class { background: blue }', + ], + }) + + const result = sheet.renderAsAdoptedStyleSheet() + + expect(result).toEqual({ + cssRules: [ + '@media screen { div { color: red } }', + '@keyframes fade { from { opacity: 0 } }', + '.class { background: blue }', + ], + disabled: undefined, + media: undefined, + }) + }) + }) + + describe('renderAsCssText', () => { + it('returns string rules directly', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: 'div { color: red }', + }) + + const result = sheet.renderAsCssText() + + expect(result).toBe('div { color: red }') + }) + + it('joins array rules with empty string', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: ['div { color: red }', 'span { color: blue }'], + }) + + const result = sheet.renderAsCssText() + + expect(result).toBe('div { color: red }span { color: blue }') + }) + + it('handles empty string rules', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: '', + }) + + const result = sheet.renderAsCssText() + + expect(result).toBe('') + }) + + it('handles empty array rules', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: [], + }) + + const result = sheet.renderAsCssText() + + expect(result).toBe('') + }) + + it('handles array with single rule', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: ['div { color: red }'], + }) + + const result = sheet.renderAsCssText() + + expect(result).toBe('div { color: red }') + }) + + it('handles array with multiple rules', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: ['rule1', 'rule2', 'rule3'], + }) + + const result = sheet.renderAsCssText() + + expect(result).toBe('rule1rule2rule3') + }) + + it('preserves whitespace and formatting in string rules', () => { + const formattedCSS = ` +div { + color: red; + background: blue; +} + +span { + font-size: 14px; +} + `.trim() + + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: formattedCSS, + }) + + const result = sheet.renderAsCssText() + + expect(result).toBe(formattedCSS) + }) + + it('concatenates array rules without separator', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: ['div { color: red; }', 'span { color: blue; }', 'p { font-size: 14px; }'], + }) + + const result = sheet.renderAsCssText() + + expect(result).toBe('div { color: red; }span { color: blue; }p { font-size: 14px; }') + }) + + it('handles complex CSS as string', () => { + const complexCSS = ` + @media screen and (min-width: 768px) { + .container { + max-width: 1200px; + } + } + @keyframes fade { + from { opacity: 0; } + to { opacity: 1; } + } + ` + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: complexCSS, + }) + + const result = sheet.renderAsCssText() + + expect(result).toBe(complexCSS) + }) + + it('does not include disabled or mediaList properties', () => { + const sheet = document.createStyleSheet({ + disabled: true, + mediaList: ['screen', 'print'], + rules: 'div { color: red }', + }) + + const result = sheet.renderAsCssText() + + expect(result).toBe('div { color: red }') + expect(typeof result).toBe('string') + }) + }) + + describe('data getter', () => { + it('returns the stylesheet data', () => { + const data = { + disabled: true, + mediaList: ['screen'], + rules: ['rule1', 'rule2'], + } + const sheet = document.createStyleSheet(data) + + expect(sheet.data).toBe(data) + }) + + it('allows access to individual data properties', () => { + const sheet = document.createStyleSheet({ + disabled: true, + mediaList: ['screen', 'print'], + rules: 'div { color: red }', + }) + + expect(sheet.data.disabled).toBe(true) + expect(sheet.data.mediaList).toEqual(['screen', 'print']) + expect(sheet.data.rules).toBe('div { color: red }') + }) + }) + + describe('id getter', () => { + it('returns the stylesheet id', () => { + const sheet1 = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: '', + }) + const sheet2 = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: '', + }) + + expect(sheet1.id).toBe(0 as StyleSheetId) + expect(sheet2.id).toBe(1 as StyleSheetId) + }) + }) + + describe('ownerDocument getter', () => { + it('returns the owner document', () => { + const sheet = document.createStyleSheet({ + disabled: false, + mediaList: [], + rules: '', + }) + + expect(sheet.ownerDocument).toBe(document) + }) + }) +}) diff --git a/packages/rum/src/domain/record/serialization/conversions/vStyleSheet.ts b/packages/rum/src/domain/record/serialization/conversions/vStyleSheet.ts new file mode 100644 index 0000000000..739d255d57 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/conversions/vStyleSheet.ts @@ -0,0 +1,47 @@ +import type { StyleSheet } from '../../../../types' +import type { StyleSheetId } from '../../itemIds' +import type { VDocument } from './vDocument' + +export interface VStyleSheet { + renderAsAdoptedStyleSheet(): StyleSheet + renderAsCssText(): string + + get data(): VStyleSheetData + get id(): StyleSheetId + get ownerDocument(): VDocument +} + +export interface VStyleSheetData { + disabled: boolean + mediaList: string[] + rules: string | string[] +} + +export function createVStyleSheet(document: VDocument, id: StyleSheetId, data: VStyleSheetData): VStyleSheet { + const self: VStyleSheet = { + renderAsAdoptedStyleSheet(): StyleSheet { + const cssRules = typeof data.rules === 'string' ? [data.rules] : data.rules + return { + cssRules, + disabled: data.disabled ? true : undefined, + media: data.mediaList.length > 0 ? data.mediaList : undefined, + } + }, + + renderAsCssText(): string { + return typeof data.rules === 'string' ? data.rules : data.rules.join('') + }, + + get data(): VStyleSheetData { + return data + }, + get id(): StyleSheetId { + return id + }, + get ownerDocument(): VDocument { + return document + }, + } + + return self +} diff --git a/packages/rum/src/domain/record/serialization/index.ts b/packages/rum/src/domain/record/serialization/index.ts index d3892ab490..8e0480db9e 100644 --- a/packages/rum/src/domain/record/serialization/index.ts +++ b/packages/rum/src/domain/record/serialization/index.ts @@ -1,4 +1,6 @@ -export { createRootInsertionCursor } from './insertionCursor' +export type { ChangeConverter, MutationLog, NodeIdRemapper } from './conversions' +export { createChangeConverter, createCopyingNodeIdRemapper, createIdentityNodeIdRemapper } from './conversions' +export { createChildInsertionCursor, createRootInsertionCursor } from './insertionCursor' export { getElementInputValue } from './serializationUtils' export { serializeDocument } from './serializeDocument' export { serializeNode } from './serializeNode' diff --git a/packages/rum/src/domain/record/test/changeConversions.specHelper.ts b/packages/rum/src/domain/record/test/changeConversions.specHelper.ts deleted file mode 100644 index 91ac443275..0000000000 --- a/packages/rum/src/domain/record/test/changeConversions.specHelper.ts +++ /dev/null @@ -1,418 +0,0 @@ -import type { - AddDocTypeNodeChange, - AddElementNodeChange, - AddNodeChange, - AddStyleSheetChange, - AddTextNodeChange, - AttachedStyleSheetsChange, - BrowserChangeRecord, - BrowserFullSnapshotRecord, - MediaPlaybackStateChange, - ScrollPositionChange, - SerializedNodeWithId, - SizeChange, - StyleSheet, -} from '../../../types' -import { ChangeType, NodeType, PlaybackState, RecordType } from '../../../types' -import type { NodeId, StringId, StyleSheetId } from '../itemIds' - -export function convertChangeToFullSnapshot(record: BrowserChangeRecord): BrowserFullSnapshotRecord { - const nodeTracker = createNodeTracker() - const stringTracker = createStringTracker() - const sheetTracker = createStyleSheetTracker() - - for (const change of record.data) { - switch (change[0]) { - case ChangeType.AddString: { - for (let i = 1; i < change.length; i++) { - stringTracker.add(change[i] as string) - } - break - } - - case ChangeType.AddNode: { - for (let i = 1; i < change.length; i++) { - convertAddNodeChange(change[i] as AddNodeChange, nodeTracker, stringTracker) - } - break - } - - case ChangeType.ScrollPosition: { - for (let i = 1; i < change.length; i++) { - convertScrollPositionChange(change[i] as ScrollPositionChange, nodeTracker) - } - break - } - - case ChangeType.Size: { - for (let i = 1; i < change.length; i++) { - convertSizeChange(change[i] as SizeChange, nodeTracker) - } - break - } - - case ChangeType.AddStyleSheet: { - for (let i = 1; i < change.length; i++) { - convertAddStyleSheetChange(change[i] as AddStyleSheetChange, sheetTracker, stringTracker) - } - break - } - - case ChangeType.AttachedStyleSheets: { - for (let i = 1; i < change.length; i++) { - convertAttachedStyleSheetsChange(change[i] as AttachedStyleSheetsChange, nodeTracker, sheetTracker) - } - break - } - - case ChangeType.MediaPlaybackState: { - for (let i = 1; i < change.length; i++) { - convertMediaPlaybackStateChange(change[i] as MediaPlaybackStateChange, nodeTracker) - } - break - } - } - } - - const node = nodeTracker.getRoot() - const initialOffset = nodeTracker.getRootScrollPosition() - - if (!node) { - throw new Error('No root node found') - } - - return { - data: { node, initialOffset }, - type: RecordType.FullSnapshot, - timestamp: record.timestamp, - } -} - -type SerializedNodeWithChildren = SerializedNodeWithId & { childNodes: SerializedNodeWithId[] } - -interface NodeTracker { - add(nodeId: NodeId, node: SerializedNodeWithId): void - get(nodeId: NodeId): SerializedNodeWithChildren - getParent(nodeId: NodeId): SerializedNodeWithChildren - setParent(nodeId: NodeId, node: SerializedNodeWithChildren): void - getRoot(): SerializedNodeWithChildren | undefined - setRoot(node: SerializedNodeWithId): void - getRootScrollPosition(): { left: number; top: number } - setRootScrollPosition(position: { left: number; top: number }): void - nextId(): NodeId -} - -function createNodeTracker(): NodeTracker { - const nodes = new Map() - const nodeParents = new Map() - let rootNode: SerializedNodeWithChildren | undefined - let rootScrollPosition = { left: 0, top: 0 } - return { - add(nodeId: NodeId, node: SerializedNodeWithId): void { - nodes.set(nodeId, node as SerializedNodeWithChildren) - }, - get(nodeId: NodeId): SerializedNodeWithChildren { - const node = nodes.get(nodeId) - if (!node) { - throw new Error(`Reference to unknown node: ${nodeId}`) - } - return node - }, - getParent(nodeId: NodeId): SerializedNodeWithChildren { - const parent = nodeParents.get(nodeId) - if (!parent) { - throw new Error(`Reference to unknown parent of node: ${nodeId}`) - } - return parent - }, - setParent(nodeId: NodeId, node: SerializedNodeWithChildren): void { - nodeParents.set(nodeId, node) - }, - getRoot(): SerializedNodeWithChildren | undefined { - return rootNode - }, - setRoot(node: SerializedNodeWithId): void { - rootNode = node as SerializedNodeWithChildren - }, - getRootScrollPosition(): { left: number; top: number } { - return rootScrollPosition - }, - setRootScrollPosition(position: { left: number; top: number }): void { - rootScrollPosition = position - }, - nextId(): NodeId { - return nodes.size as NodeId - }, - } -} - -interface StringTracker { - add(newString: string): void - get(stringOrStringId: number | string): string -} - -function createStringTracker(): StringTracker { - const strings = new Map() - return { - add(newString: string): void { - strings.set(strings.size as StringId, newString) - }, - get(stringOrStringId: number | string): string { - if (typeof stringOrStringId === 'string') { - return stringOrStringId - } - const referencedString = strings.get(stringOrStringId as StringId) - if (referencedString === undefined) { - throw new Error(`Reference to unknown string: ${stringOrStringId}`) - } - return referencedString - }, - } -} - -interface StyleSheetData { - rules: string | string[] - mediaList: string[] - disabled: boolean -} - -interface StyleSheetTracker { - add(data: StyleSheetData): void - get(sheetId: number): StyleSheetData -} - -function createStyleSheetTracker(): StyleSheetTracker { - const styleSheets = new Map() - return { - add(data: StyleSheetData): void { - styleSheets.set(styleSheets.size as StyleSheetId, data) - }, - get(sheetId: StyleSheetId): StyleSheetData { - const styleSheet = styleSheets.get(sheetId) - if (!styleSheet) { - throw new Error(`Reference to unknown stylesheet: ${sheetId}`) - } - return styleSheet - }, - } -} - -function convertAddNodeChange(addedNode: AddNodeChange, nodeTracker: NodeTracker, stringTracker: StringTracker): void { - const id = nodeTracker.nextId() - const nodeName = stringTracker.get(addedNode[1]) - - let node: SerializedNodeWithId - switch (nodeName) { - case '#cdata-section': - node = { - type: NodeType.CDATA, - id, - textContent: '', - } - break - - case '#document': - node = { - type: NodeType.Document, - id, - childNodes: [], - adoptedStyleSheets: undefined, - } - break - - case '#document-fragment': - node = { - type: NodeType.DocumentFragment, - id, - childNodes: [], - isShadowRoot: false, - adoptedStyleSheets: undefined, - } - break - - case '#doctype': { - const [, , name, publicId, systemId] = addedNode as AddDocTypeNodeChange - node = { - type: NodeType.DocumentType, - id, - name: stringTracker.get(name), - publicId: stringTracker.get(publicId), - systemId: stringTracker.get(systemId), - } - break - } - - case '#shadow-root': - node = { - type: NodeType.DocumentFragment, - id, - childNodes: [], - isShadowRoot: true, - adoptedStyleSheets: undefined, - } - break - - case '#text': { - const [, , textContent] = addedNode as AddTextNodeChange - node = { - type: NodeType.Text, - id, - textContent: stringTracker.get(textContent), - } - break - } - - default: { - let tagName: string - let isSVG: true | undefined - if (nodeName.startsWith('svg>')) { - tagName = nodeName.substring(4).toLowerCase() - isSVG = true - } else { - tagName = nodeName.toLowerCase() - } - - const [, , ...attributeAssignments] = addedNode as AddElementNodeChange - const attributes: Record = {} - for (const [name, value] of attributeAssignments) { - attributes[stringTracker.get(name)] = stringTracker.get(value) - } - - node = { - type: NodeType.Element, - id, - tagName, - attributes, - childNodes: [], - isSVG, - } - break - } - } - - nodeTracker.add(id, node) - - const insertionPoint = addedNode[0] - if (insertionPoint === null) { - // Insert as the root node. - nodeTracker.setRoot(node) - } else if (insertionPoint === 0) { - // Insert as the next sibling of the previous node. - const parent = nodeTracker.getParent((id - 1) as NodeId) - nodeTracker.setParent(id, parent) - parent.childNodes.push(node) - } else if (insertionPoint > 0) { - // Insert via the equivalent of appendChild(). - const parent = nodeTracker.get((id - insertionPoint) as NodeId) - nodeTracker.setParent(id, parent) - parent.childNodes.push(node) - } else { - // Insert via the equivalent of after(). - const referenceId = (id + insertionPoint) as NodeId - const reference = nodeTracker.get(referenceId) - const parent = nodeTracker.getParent(referenceId) - nodeTracker.setParent(id, parent) - parent.childNodes.splice(parent.childNodes.indexOf(reference), 0, node) - } -} - -function convertAddStyleSheetChange( - change: AddStyleSheetChange, - sheetTracker: StyleSheetTracker, - stringTracker: StringTracker -): void { - const [encodedRules, encodedMediaList = [], disabled = false] = change - const rules: string | string[] = Array.isArray(encodedRules) - ? encodedRules.map((rule) => stringTracker.get(rule)) - : stringTracker.get(encodedRules) - const mediaList = encodedMediaList.map((item) => stringTracker.get(item)) - sheetTracker.add({ rules, mediaList, disabled }) -} - -function convertAttachedStyleSheetsChange( - change: AttachedStyleSheetsChange, - nodeTracker: NodeTracker, - sheetTracker: StyleSheetTracker -): void { - const [nodeId, ...styleSheetIds] = change - const node = nodeTracker.get(nodeId as NodeId) - switch (node.type) { - case NodeType.Document: - case NodeType.DocumentFragment: { - const styleSheets: StyleSheet[] = [] - for (const styleSheetId of styleSheetIds) { - const styleSheet = sheetTracker.get(styleSheetId as StyleSheetId) - if (!Array.isArray(styleSheet.rules)) { - throw new Error(`Stylesheet ${styleSheetId} is encoded in the wrong format for attachedStyleSheets`) - } - styleSheets.push({ - cssRules: styleSheet.rules, - media: styleSheet.mediaList.length > 0 ? styleSheet.mediaList : undefined, - disabled: styleSheet.disabled ? true : undefined, - }) - } - ;(node.adoptedStyleSheets as unknown as StyleSheet[]) = styleSheets - break - } - - case NodeType.Element: { - const cssTextBlocks: string[] = [] - for (const styleSheetId of styleSheetIds) { - const styleSheet = sheetTracker.get(styleSheetId as StyleSheetId) - if (Array.isArray(styleSheet.rules)) { - for (const rule of styleSheet.rules) { - cssTextBlocks.push(rule) - } - } else { - cssTextBlocks.push(styleSheet.rules) - } - } - node.attributes._cssText = cssTextBlocks.join('') - break - } - } -} - -function convertScrollPositionChange(change: ScrollPositionChange, nodeTracker: NodeTracker): void { - const [nodeId, left, top] = change - - const node = nodeTracker.get(nodeId as NodeId) - if (node === nodeTracker.getRoot()) { - nodeTracker.setRootScrollPosition({ left, top }) - } - if (node.type !== NodeType.Element) { - return - } - - const existingLeft = node.attributes.rr_scrollLeft ?? 0 - if (left !== 0 || existingLeft !== left) { - node.attributes.rr_scrollLeft = left - } - - const existingTop = node.attributes.rr_scrollTop ?? 0 - if (top !== 0 || existingTop !== top) { - node.attributes.rr_scrollTop = top - } -} - -function convertSizeChange(change: SizeChange, nodeTracker: NodeTracker): void { - const [nodeId, width, height] = change - const node = nodeTracker.get(nodeId as NodeId) - if (node.type !== NodeType.Element) { - throw new Error(`Got size change for node ${nodeId} with non-element type ${node.type}`) - } - node.attributes = { - rr_width: `${width}px`, - rr_height: `${height}px`, - ...node.attributes, - } -} - -function convertMediaPlaybackStateChange(change: MediaPlaybackStateChange, nodeTracker: NodeTracker): void { - const [nodeId, playbackState] = change - const node = nodeTracker.get(nodeId as NodeId) - if (node.type !== NodeType.Element) { - throw new Error(`Got media playback state change for node ${nodeId} with non-element type ${node.type}`) - } - node.attributes.rr_mediaState = playbackState === PlaybackState.Playing ? 'played' : 'paused' -} diff --git a/packages/rum/src/domain/record/test/serialization.specHelper.ts b/packages/rum/src/domain/record/test/serialization.specHelper.ts index 34bfb7a6b6..9ede06a5d4 100644 --- a/packages/rum/src/domain/record/test/serialization.specHelper.ts +++ b/packages/rum/src/domain/record/test/serialization.specHelper.ts @@ -2,6 +2,7 @@ import { noop, timeStampNow } from '@datadog/browser-core' import { RecordType } from '../../../types' import type { BrowserChangeRecord, + BrowserFullSnapshotRecord, BrowserRecord, DocumentNode, ElementNode, @@ -24,8 +25,8 @@ import { serializeChangesInTransaction, createRootInsertionCursor, serializeNodeAsChange, + createChangeConverter, } from '../serialization' -import { convertChangeToFullSnapshot } from './changeConversions.specHelper' import { createRecordingScopeForTesting } from './recordingScope.specHelper' export function createSerializationTransactionForTesting({ @@ -126,14 +127,16 @@ export function serializeNodeAndVerifyChangeRecord( // When converted to a FullSnapshot record, serializeNodeAsChange()'s output should // match serializeNode() exactly. expect(changeRecord).not.toBeUndefined() - const converted = convertChangeToFullSnapshot(changeRecord!).data.node - expect(converted).toEqual(serializedNode) + const converter = createChangeConverter() + const convertedRecord = converter.convert(changeRecord!) as BrowserFullSnapshotRecord + const convertedNode = convertedRecord.data.node + expect(convertedNode).toEqual(serializedNode) // When stringified, serializeNodeAsChange()'s converted output should be // byte-for-byte identical to serializeNode()'s. (This test is stricter than the one // above, but produces error messages which are a lot harder to read, so it's worth // having both.) - expect(JSON.stringify(converted)).toEqual(JSON.stringify(serializedNode)) + expect(JSON.stringify(convertedNode)).toEqual(JSON.stringify(serializedNode)) } return serializedNode