From d872d2809e3ec8d6ff5d3d5f43bc81aff70e7548 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Thu, 3 Aug 2023 13:56:31 +0100 Subject: [PATCH 1/2] Compact style mutation fixes and improvements (#1268) * Don't use the CSSOM when there's `var()` present as it fails badly https://github.com/rrweb-io/rrweb/pull/1246 * As the CSS Object Model expands out shorthand properties, do a check on the string length before choosing which format to go for - this approach allows 'var()' in a styleOMValue as it's only a problem when combined with a shorthand property - before this change background:black; was getting expaned to 10 OM properties as follows: 'style': { 'background-color': 'black', 'background-image': false, 'background-position-x': false, 'background-position-y': false, 'background-size': false, 'background-repeat-x': false, 'background-repeat-y': false, 'background-attachment': false, 'background-origin': false, 'background-clip': false } * Updates to remainder of tests based on refined compact style mutations * Apply suggestions from code review by: Justin Halsall --------- Authored-by: eoghanmurray --- .changeset/clean-plants-play.md | 9 + packages/rrweb/src/record/mutation.ts | 93 ++++--- .../__snapshots__/integration.test.ts.snap | 240 +++++++++++++++++- packages/rrweb/test/integration.test.ts | 50 ++++ .../cross-origin-iframes.test.ts.snap | 5 +- packages/rrweb/test/utils.ts | 33 +-- packages/types/src/index.ts | 8 +- 7 files changed, 366 insertions(+), 72 deletions(-) create mode 100644 .changeset/clean-plants-play.md diff --git a/.changeset/clean-plants-play.md b/.changeset/clean-plants-play.md new file mode 100644 index 0000000000..809dae8d86 --- /dev/null +++ b/.changeset/clean-plants-play.md @@ -0,0 +1,9 @@ +--- +'rrweb': patch +'@rrweb/types': patch +--- + +Compact style mutation fixes and improvements + +- fixes when style updates contain a 'var()' on a shorthand property #1246 +- further ensures that style mutations are compact by reverting to string method if it is shorter diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 39a6107635..0eea35c073 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -18,7 +18,6 @@ import type { attributeCursor, removedNodeMutation, addedNodeMutation, - styleAttributeValue, Optional, } from '@rrweb/types'; import { @@ -438,10 +437,29 @@ export default class MutationBuffer { // text mutation's id was not in the mirror map means the target node has been removed .filter((text) => this.mirror.has(text.id)), attributes: this.attributes - .map((attribute) => ({ - id: this.mirror.getId(attribute.node), - attributes: attribute.attributes, - })) + .map((attribute) => { + const { attributes } = attribute; + if (typeof attributes.style === 'string') { + const diffAsStr = JSON.stringify(attribute.styleDiff); + const unchangedAsStr = JSON.stringify(attribute._unchangedStyles); + // check if the style diff is actually shorter than the regular string based mutation + // (which was the whole point of #464 'compact style mutation'). + if (diffAsStr.length < attributes.style.length) { + // also: CSSOM fails badly when var() is present on shorthand properties, so only proceed with + // the compact style mutation if these have all been accounted for + if ( + (diffAsStr + unchangedAsStr).split('var(').length === + attributes.style.split('var(').length + ) { + attributes.style = attribute.styleDiff; + } + } + } + return { + id: this.mirror.getId(attribute.node), + attributes: attributes, + }; + }) // attribute mutation's id was not in the mirror map means the target node has been removed .filter((attribute) => this.mirror.has(attribute.id)), removes: this.removes, @@ -548,6 +566,8 @@ export default class MutationBuffer { item = { node: m.target, attributes: {}, + styleDiff: {}, + _unchangedStyles: {}, }; this.attributes.push(item); } @@ -562,39 +582,7 @@ export default class MutationBuffer { target.setAttribute('data-rr-is-password', 'true'); } - if (attributeName === 'style') { - const old = unattachedDoc.createElement('span'); - if (m.oldValue) { - old.setAttribute('style', m.oldValue); - } - if ( - item.attributes.style === undefined || - item.attributes.style === null - ) { - item.attributes.style = {}; - } - const styleObj = item.attributes.style as styleAttributeValue; - for (const pname of Array.from(target.style)) { - const newValue = target.style.getPropertyValue(pname); - const newPriority = target.style.getPropertyPriority(pname); - if ( - newValue !== old.style.getPropertyValue(pname) || - newPriority !== old.style.getPropertyPriority(pname) - ) { - if (newPriority === '') { - styleObj[pname] = newValue; - } else { - styleObj[pname] = [newValue, newPriority]; - } - } - } - for (const pname of Array.from(old.style)) { - if (target.style.getPropertyValue(pname) === '') { - // "if not set, returns the empty string" - styleObj[pname] = false; // delete - } - } - } else if (!ignoreAttribute(target.tagName, attributeName, value)) { + if (!ignoreAttribute(target.tagName, attributeName, value)) { // overwrite attribute if the mutations was triggered in same time item.attributes[attributeName] = transformAttribute( this.doc, @@ -602,6 +590,35 @@ export default class MutationBuffer { toLowerCase(attributeName), value, ); + if (attributeName === 'style') { + const old = unattachedDoc.createElement('span'); + if (m.oldValue) { + old.setAttribute('style', m.oldValue); + } + for (const pname of Array.from(target.style)) { + const newValue = target.style.getPropertyValue(pname); + const newPriority = target.style.getPropertyPriority(pname); + if ( + newValue !== old.style.getPropertyValue(pname) || + newPriority !== old.style.getPropertyPriority(pname) + ) { + if (newPriority === '') { + item.styleDiff[pname] = newValue; + } else { + item.styleDiff[pname] = [newValue, newPriority]; + } + } else { + // for checking + item._unchangedStyles[pname] = [newValue, newPriority]; + } + } + for (const pname of Array.from(old.style)) { + if (target.style.getPropertyValue(pname) === '') { + // "if not set, returns the empty string" + item.styleDiff[pname] = false; // delete + } + } + } } break; } diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index f244948b67..773d2351b4 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -2933,24 +2933,14 @@ exports[`record integration tests can record node mutations 1`] = ` \\"id\\": 36, \\"attributes\\": { \\"id\\": \\"select2-drop\\", - \\"style\\": { - \\"left\\": \\"Npx\\", - \\"width\\": \\"Npx\\", - \\"top\\": \\"Npx\\", - \\"bottom\\": \\"auto\\", - \\"display\\": \\"block\\", - \\"position\\": false, - \\"visibility\\": false - }, + \\"style\\": \\"left: Npx; width: Npx; top: Npx; bottom: auto; display: block;\\", \\"class\\": \\"select2-drop select2-display-none select2-with-searchbox select2-drop-active\\" } }, { \\"id\\": 70, \\"attributes\\": { - \\"style\\": { - \\"display\\": false - } + \\"style\\": \\"\\" } }, { @@ -3391,6 +3381,232 @@ exports[`record integration tests can record node mutations 1`] = ` ]" `; +exports[`record integration tests can record style changes compactly and preserve css var() functions 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 3 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 7 + } + ], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 8 + } + ], + \\"id\\": 4 + } + ], + \\"id\\": 2 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": \\"background: var(--mystery)\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": \\"background: var(--mystery); background-color: black\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": \\"\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": \\"display:block\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": { + \\"color\\": \\"var(--mystery-color)\\" + } + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": \\"color:var(--mystery-color);display:block;margin:10px\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": { + \\"margin-left\\": \\"Npx\\" + } + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": { + \\"margin-top\\": \\"Npx\\", + \\"color\\": false + } + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + } +]" +`; + exports[`record integration tests can use maskInputOptions to configure which type of inputs should be masked 1`] = ` "[ { diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 39e32dbcd6..b61f76ece5 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -209,6 +209,56 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots); }); + it('can record style changes compactly and preserve css var() functions', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, 'blank.html'), { + waitUntil: 'networkidle0', + }); + + // goal here is to ensure var(--mystery) ends up in the mutations (CSSOM fails in this case) + await page.evaluate( + 'document.body.setAttribute("style", "background: var(--mystery)")', + ); + await waitForRAF(page); + // and in this change we can't use the shorter styleObj format either + await page.evaluate( + 'document.body.setAttribute("style", "background: var(--mystery); background-color: black")', + ); + + // reset is always shorter to be recorded as a sting rather than a styleObj + await page.evaluate('document.body.setAttribute("style", "")'); + await waitForRAF(page); + + await page.evaluate('document.body.setAttribute("style", "display:block")'); + await waitForRAF(page); + // following should be recorded as an update of `{ color: 'var(--mystery-color)' }` without needing to include the display + await page.evaluate( + 'document.body.setAttribute("style", "color:var(--mystery-color);display:block")', + ); + await waitForRAF(page); + // whereas this case, it's shorter to record the entire string than the longhands for margin + await page.evaluate( + 'document.body.setAttribute("style", "color:var(--mystery-color);display:block;margin:10px")', + ); + await waitForRAF(page); + // and in this case, it's shorter to record just the change to the longhand margin-left; + await page.evaluate( + 'document.body.setAttribute("style", "color:var(--mystery-color);display:block;margin:10px 10px 10px 0px;")', + ); + await waitForRAF(page); + // see what happens when we manipulate the style object directly (expecting a compact mutation with just these two changes) + await page.evaluate( + 'document.body.style.marginTop = 0; document.body.style.color = null', + ); + await waitForRAF(page); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + it('can freeze mutations', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); diff --git a/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap b/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap index 48d4bdb33a..5caabe69ee 100644 --- a/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap +++ b/packages/rrweb/test/record/__snapshots__/cross-origin-iframes.test.ts.snap @@ -2625,10 +2625,7 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] = { \\"id\\": 9, \\"attributes\\": { - \\"style\\": { - \\"width\\": \\"Npx\\", - \\"height\\": \\"Npx\\" - } + \\"style\\": \\"width: Npx; height: Npx;\\" } } ], diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index dd5a8cf7cc..0d1e6400c6 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -133,23 +133,26 @@ function stringifySnapshots(snapshots: eventWithTime[]): string { s.data.source === IncrementalSource.Mutation ) { s.data.attributes.forEach((a) => { - if ( - 'style' in a.attributes && - a.attributes.style && - typeof a.attributes.style === 'object' - ) { - for (const [k, v] of Object.entries(a.attributes.style)) { - if (Array.isArray(v)) { - if (coordinatesReg.test(k + ': ' + v[0])) { - // TODO: could round the number here instead depending on what's coming out of various test envs - a.attributes.style[k] = ['Npx', v[1]]; - } - } else if (typeof v === 'string') { - if (coordinatesReg.test(k + ': ' + v)) { - a.attributes.style[k] = 'Npx'; + if ('style' in a.attributes && a.attributes.style) { + if (typeof a.attributes.style === 'object') { + for (const [k, v] of Object.entries(a.attributes.style)) { + if (Array.isArray(v)) { + if (coordinatesReg.test(k + ': ' + v[0])) { + // TODO: could round the number here instead depending on what's coming out of various test envs + a.attributes.style[k] = ['Npx', v[1]]; + } + } else if (typeof v === 'string') { + if (coordinatesReg.test(k + ': ' + v)) { + a.attributes.style[k] = 'Npx'; + } } + coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript } - coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript + } else if (coordinatesReg.test(a.attributes.style)) { + a.attributes.style = a.attributes.style.replace( + coordinatesReg, + '$1: Npx', + ); } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6601457291..e6f6f15cd3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -283,7 +283,7 @@ export type textMutation = { value: string | null; }; -export type styleAttributeValue = { +export type styleOMValue = { [key: string]: styleValueWithPriority | string | false; }; @@ -292,13 +292,15 @@ export type styleValueWithPriority = [string, string]; export type attributeCursor = { node: Node; attributes: { - [key: string]: string | styleAttributeValue | null; + [key: string]: string | styleOMValue | null; }; + styleDiff: styleOMValue; + _unchangedStyles: styleOMValue; }; export type attributeMutation = { id: number; attributes: { - [key: string]: string | styleAttributeValue | null; + [key: string]: string | styleOMValue | null; }; }; From 7103625b4683cbd75732ee03973e38f573847b1c Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Thu, 3 Aug 2023 17:11:43 +0100 Subject: [PATCH 2/2] Mutation (attribute & text) duplicate info elimination (#1269) * Add a test which demonstrates how no mutations are generated when an element is created & destroyed in the same 'cycle' (a cylce here being enforced by freezePage) * Test demonstrating current behaviour I'm about to modify; the data-test="x" attribute is present twice in the mutation, as is the textContent value of 'y' * Attribute or text modifications on just-added nodes are redundant as demonstrated in test case * Some correct test changes from other tests; I've manually inspected each of these mutation removals and confirmed that the attribute values are already present in the newly added nodes elsewhere in the same mutation * Improve reliability of test case as per Justin's advice --- .changeset/attribute-text-reductions.md | 5 + packages/rrweb/src/record/mutation.ts | 6 + .../__snapshots__/integration.test.ts.snap | 43 +--- .../test/__snapshots__/record.test.ts.snap | 206 ++++++++++++++++++ packages/rrweb/test/record.test.ts | 87 ++++++++ 5 files changed, 305 insertions(+), 42 deletions(-) create mode 100644 .changeset/attribute-text-reductions.md diff --git a/.changeset/attribute-text-reductions.md b/.changeset/attribute-text-reductions.md new file mode 100644 index 0000000000..648e0d81b9 --- /dev/null +++ b/.changeset/attribute-text-reductions.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +Don't include redundant data from text/attribute mutations on just-added nodes diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 0eea35c073..097d1a8fd5 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -265,6 +265,7 @@ export default class MutationBuffer { // so that the mirror for takeFullSnapshot doesn't get mutated while it's event is being processed const adds: addedNodeMutation[] = []; + const addedIds = new Set(); /** * Sometimes child node may be pushed before its newly added @@ -335,6 +336,7 @@ export default class MutationBuffer { nextId, node: sn, }); + addedIds.add(sn.id); } }; @@ -434,6 +436,8 @@ export default class MutationBuffer { id: this.mirror.getId(text.node), value: text.value, })) + // no need to include them on added elements, as they have just been serialized with up to date attribubtes + .filter((text) => !addedIds.has(text.id)) // text mutation's id was not in the mirror map means the target node has been removed .filter((text) => this.mirror.has(text.id)), attributes: this.attributes @@ -460,6 +464,8 @@ export default class MutationBuffer { attributes: attributes, }; }) + // no need to include them on added elements, as they have just been serialized with up to date attribubtes + .filter((attribute) => !addedIds.has(attribute.id)) // attribute mutation's id was not in the mirror map means the target node has been removed .filter((attribute) => this.mirror.has(attribute.id)), removes: this.removes, diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 773d2351b4..fe33ef3c7d 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -566,12 +566,6 @@ exports[`record integration tests can freeze mutations 1`] = ` \\"source\\": 0, \\"texts\\": [], \\"attributes\\": [ - { - \\"id\\": 20, - \\"attributes\\": { - \\"foo\\": \\"bar\\" - } - }, { \\"id\\": 5, \\"attributes\\": { @@ -2929,38 +2923,11 @@ exports[`record integration tests can record node mutations 1`] = ` \\"class\\": \\"select2-container select2-dropdown-open select2-container-active\\" } }, - { - \\"id\\": 36, - \\"attributes\\": { - \\"id\\": \\"select2-drop\\", - \\"style\\": \\"left: Npx; width: Npx; top: Npx; bottom: auto; display: block;\\", - \\"class\\": \\"select2-drop select2-display-none select2-with-searchbox select2-drop-active\\" - } - }, - { - \\"id\\": 70, - \\"attributes\\": { - \\"style\\": \\"\\" - } - }, - { - \\"id\\": 42, - \\"attributes\\": { - \\"class\\": \\"select2-input select2-focused\\", - \\"aria-activedescendant\\": \\"select2-result-label-2\\" - } - }, { \\"id\\": 35, \\"attributes\\": { \\"disabled\\": \\"\\" } - }, - { - \\"id\\": 72, - \\"attributes\\": { - \\"class\\": \\"select2-results-dept-0 select2-result select2-result-selectable select2-highlighted\\" - } } ], \\"removes\\": [ @@ -4615,15 +4582,7 @@ exports[`record integration tests handles null attribute values 1`] = ` \\"data\\": { \\"source\\": 0, \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 20, - \\"attributes\\": { - \\"aria-label\\": \\"label\\", - \\"id\\": \\"test-li\\" - } - } - ], + \\"attributes\\": [], \\"removes\\": [], \\"adds\\": [ { diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index ad9b438600..cb40f3328d 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -1,5 +1,91 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`record aggregates mutations 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 6 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 8 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 2, + \\"id\\": 5 + } + } +]" +`; + exports[`record can add custom event 1`] = ` "[ { @@ -3349,6 +3435,126 @@ exports[`record loading stylesheets captures stylesheets that are still loading ]" `; +exports[`record no need for attribute mutations on adds 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 6 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 8 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 5, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"id\\": \\"here\\", + \\"data-test\\": \\"x\\" + }, + \\"childNodes\\": [], + \\"id\\": 9 + } + }, + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"y\\", + \\"id\\": 10 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 2, + \\"id\\": 5 + } + } +]" +`; + exports[`record should record scroll position 1`] = ` "[ { diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 1a0a87421f..51e7ad2342 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -35,6 +35,7 @@ interface IWindow extends Window { takeFullSnapshot: (isCheckout?: boolean | undefined) => void; }; + freezePage(): void; addCustomEvent(tag: string, payload: T): void; }; emit: (e: eventWithTime) => undefined; @@ -651,6 +652,92 @@ describe('record', function (this: ISuite) { assertSnapshot(ctx.events); }); + it('aggregates mutations', async () => { + await ctx.page.evaluate(() => { + return new Promise((resolve) => { + const { record, freezePage } = (window as unknown as IWindow).rrweb; + record({ + emit: (window as unknown as IWindow).emit, + }); + freezePage(); + setTimeout(() => { + const div = document.createElement('div'); + div.setAttribute('id', 'here-and-gone'); + document.body.appendChild(div); + }, 0); + setTimeout(() => { + const div = document.getElementById('here-and-gone'); + if (div) { + div.setAttribute('data-test', 'x'); + } + }, 10); + setTimeout(() => { + const div = document.getElementById('here-and-gone'); + if (div) { + div.parentNode?.removeChild(div as HTMLElement); + } + }, 15); + setTimeout(() => { + // 'unfreeze' happens upon a user event + // however, we expect none of the above mutations to produce any effect + document.body.click(); + }, 20); + setTimeout(() => { + resolve(null); + }, 25); + }); + }); + await waitForRAF(ctx.page); // wait till events get sent + + const mutationEvents = ctx.events.filter( + (e) => + e.type === EventType.IncrementalSnapshot && + e.data.source === IncrementalSource.Mutation, + ); + expect(mutationEvents.length).toEqual(0); // there was no aggregate effect + + assertSnapshot(ctx.events); + }); + + it('no need for attribute mutations on adds', async () => { + await ctx.page.evaluate(() => { + const { record, freezePage } = (window as unknown as IWindow).rrweb; + record({ + emit: (window as unknown as IWindow).emit, + }); + freezePage(); + setTimeout(() => { + const div = document.createElement('div'); + div.setAttribute('id', 'here'); + div.innerText = 'as-created'; + div.setAttribute('data-test', 'as-created'); + document.body.appendChild(div); + }, 0); + setTimeout(() => { + const div = document.getElementById('here'); + if (div) { + div.setAttribute('data-test', 'x'); + (div.childNodes[0] as Text).replaceData(0, 'as-created'.length, 'y'); + } + }, 10); + setTimeout(() => { + // 'unfreeze' happens upon a user event + document.body.click(); + }, 20); + }); + await ctx.page.waitForTimeout(50); // wait till setTimeout is called + await waitForRAF(ctx.page); // wait till events get sent + + const mutationEvents = ctx.events.filter( + (e) => + e.type === EventType.IncrementalSnapshot && + e.data.source === IncrementalSource.Mutation, + ); + expect(mutationEvents.length).toEqual(1); + + assertSnapshot(ctx.events); + }); + describe('loading stylesheets', () => { let server: Server; let serverURL: string;