Masking: Avoid the repeated calls to closest when recursing through the DOM (#1349)

* masking performance: avoid the repeated calls to `closest` when recursing through the DOM
 - needsMask===true means that an ancestor has tested positively for masking, and so this node and all descendents should be masked
 - needsMask===false means that no ancestors have tested positively for masking, we should check each node encountered
 - needsMask===undefined means that we don't know whether ancestors are masked or not (e.g. after a mutation) and should look up the tree
* Add tests including an explicit characterData mutation tests 
* Further performance improvement: avoid calls to `el.matches` when on a leaf node, e.g. a `<br/>`
---------

Authored-by: eoghanmurray <eoghan@getthere.ie>
Based on initial PR #1338 by Alexey Babik <alexey.babik@noibu.com>
This commit is contained in:
Eoghan Murray
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 4157f28e7b
commit d1d0c7f366
6 changed files with 323 additions and 24 deletions

View File

@@ -0,0 +1,6 @@
---
'rrweb-snapshot': patch
'rrweb': patch
---
Snapshot performance when masking text: Avoid the repeated calls to `closest` when recursing through the DOM

View File

@@ -310,6 +310,7 @@ export function needMaskingText(
node: Node, node: Node,
maskTextClass: string | RegExp, maskTextClass: string | RegExp,
maskTextSelector: string | null, maskTextSelector: string | null,
checkAncestors: boolean,
): boolean { ): boolean {
try { try {
const el: HTMLElement | null = const el: HTMLElement | null =
@@ -317,17 +318,21 @@ export function needMaskingText(
? (node as HTMLElement) ? (node as HTMLElement)
: node.parentElement; : node.parentElement;
if (el === null) return false; if (el === null) return false;
if (typeof maskTextClass === 'string') { if (typeof maskTextClass === 'string') {
if (el.classList.contains(maskTextClass)) return true; if (checkAncestors) {
if (el.closest(`.${maskTextClass}`)) return true; if (el.closest(`.${maskTextClass}`)) return true;
} else {
if (el.classList.contains(maskTextClass)) return true;
}
} else { } else {
if (classMatchesRegex(el, maskTextClass, true)) return true; if (classMatchesRegex(el, maskTextClass, checkAncestors)) return true;
} }
if (maskTextSelector) { if (maskTextSelector) {
if (el.matches(maskTextSelector)) return true; if (checkAncestors) {
if (el.closest(maskTextSelector)) return true; if (el.closest(maskTextSelector)) return true;
} else {
if (el.matches(maskTextSelector)) return true;
}
} }
} catch (e) { } catch (e) {
// //
@@ -426,8 +431,7 @@ function serializeNode(
mirror: Mirror; mirror: Mirror;
blockClass: string | RegExp; blockClass: string | RegExp;
blockSelector: string | null; blockSelector: string | null;
maskTextClass: string | RegExp; needsMask: boolean | undefined;
maskTextSelector: string | null;
inlineStylesheet: boolean; inlineStylesheet: boolean;
maskInputOptions: MaskInputOptions; maskInputOptions: MaskInputOptions;
maskTextFn: MaskTextFn | undefined; maskTextFn: MaskTextFn | undefined;
@@ -447,8 +451,7 @@ function serializeNode(
mirror, mirror,
blockClass, blockClass,
blockSelector, blockSelector,
maskTextClass, needsMask,
maskTextSelector,
inlineStylesheet, inlineStylesheet,
maskInputOptions = {}, maskInputOptions = {},
maskTextFn, maskTextFn,
@@ -500,8 +503,7 @@ function serializeNode(
}); });
case n.TEXT_NODE: case n.TEXT_NODE:
return serializeTextNode(n as Text, { return serializeTextNode(n as Text, {
maskTextClass, needsMask,
maskTextSelector,
maskTextFn, maskTextFn,
rootId, rootId,
}); });
@@ -531,13 +533,12 @@ function getRootId(doc: Document, mirror: Mirror): number | undefined {
function serializeTextNode( function serializeTextNode(
n: Text, n: Text,
options: { options: {
maskTextClass: string | RegExp; needsMask: boolean | undefined;
maskTextSelector: string | null;
maskTextFn: MaskTextFn | undefined; maskTextFn: MaskTextFn | undefined;
rootId: number | undefined; rootId: number | undefined;
}, },
): serializedNode { ): serializedNode {
const { maskTextClass, maskTextSelector, maskTextFn, rootId } = options; const { needsMask, maskTextFn, rootId } = options;
// The parent node may not be a html element which has a tagName attribute. // The parent node may not be a html element which has a tagName attribute.
// So just let it be undefined which is ok in this use case. // So just let it be undefined which is ok in this use case.
const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName; const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName;
@@ -568,12 +569,7 @@ function serializeTextNode(
if (isScript) { if (isScript) {
textContent = 'SCRIPT_PLACEHOLDER'; textContent = 'SCRIPT_PLACEHOLDER';
} }
if ( if (!isStyle && !isScript && textContent && needsMask) {
!isStyle &&
!isScript &&
textContent &&
needMaskingText(n, maskTextClass, maskTextSelector)
) {
textContent = maskTextFn textContent = maskTextFn
? maskTextFn(textContent, n.parentElement) ? maskTextFn(textContent, n.parentElement)
: textContent.replace(/[\S]/g, '*'); : textContent.replace(/[\S]/g, '*');
@@ -935,6 +931,7 @@ export function serializeNodeWithId(
inlineStylesheet: boolean; inlineStylesheet: boolean;
newlyAddedElement?: boolean; newlyAddedElement?: boolean;
maskInputOptions?: MaskInputOptions; maskInputOptions?: MaskInputOptions;
needsMask?: boolean;
maskTextFn: MaskTextFn | undefined; maskTextFn: MaskTextFn | undefined;
maskInputFn: MaskInputFn | undefined; maskInputFn: MaskInputFn | undefined;
slimDOMOptions: SlimDOMOptions; slimDOMOptions: SlimDOMOptions;
@@ -980,14 +977,29 @@ export function serializeNodeWithId(
keepIframeSrcFn = () => false, keepIframeSrcFn = () => false,
newlyAddedElement = false, newlyAddedElement = false,
} = options; } = options;
let { needsMask } = options;
let { preserveWhiteSpace = true } = options; let { preserveWhiteSpace = true } = options;
if (
!needsMask &&
n.childNodes // we can avoid the check on leaf elements, as masking is applied to child text nodes only
) {
// perf: if needsMask = true, children won't also need to check
const checkAncestors = needsMask === undefined; // if false, we've already checked ancestors
needsMask = needMaskingText(
n as Element,
maskTextClass,
maskTextSelector,
checkAncestors,
);
}
const _serializedNode = serializeNode(n, { const _serializedNode = serializeNode(n, {
doc, doc,
mirror, mirror,
blockClass, blockClass,
blockSelector, blockSelector,
maskTextClass, needsMask,
maskTextSelector,
inlineStylesheet, inlineStylesheet,
maskInputOptions, maskInputOptions,
maskTextFn, maskTextFn,
@@ -1058,6 +1070,7 @@ export function serializeNodeWithId(
mirror, mirror,
blockClass, blockClass,
blockSelector, blockSelector,
needsMask,
maskTextClass, maskTextClass,
maskTextSelector, maskTextSelector,
skipChild, skipChild,
@@ -1118,6 +1131,7 @@ export function serializeNodeWithId(
mirror, mirror,
blockClass, blockClass,
blockSelector, blockSelector,
needsMask,
maskTextClass, maskTextClass,
maskTextSelector, maskTextSelector,
skipChild: false, skipChild: false,
@@ -1165,6 +1179,7 @@ export function serializeNodeWithId(
mirror, mirror,
blockClass, blockClass,
blockSelector, blockSelector,
needsMask,
maskTextClass, maskTextClass,
maskTextSelector, maskTextSelector,
skipChild: false, skipChild: false,

View File

@@ -515,6 +515,7 @@ export default class MutationBuffer {
m.target, m.target,
this.maskTextClass, this.maskTextClass,
this.maskTextSelector, this.maskTextSelector,
true, // checkAncestors
) && value ) && value
? this.maskTextFn ? this.maskTextFn
? this.maskTextFn(value, closestElementOfNode(m.target)) ? this.maskTextFn(value, closestElementOfNode(m.target))

View File

@@ -793,6 +793,243 @@ exports[`record integration tests can mask character data mutations 1`] = `
} }
] ]
} }
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [
{
\\"id\\": 22,
\\"value\\": \\"****** *******\\"
}
],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": []
}
}
]"
`;
exports[`record integration tests can mask character data mutations with regexp 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\\": 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\\": \\"p\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"mutation observer\\",
\\"id\\": 8
}
],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 9
},
{
\\"type\\": 2,
\\"tagName\\": \\"ul\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 11
},
{
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 12
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 13
}
],
\\"id\\": 10
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 18
}
],
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 10,
\\"attributes\\": {
\\"class\\": \\"custom-mask\\"
}
},
{
\\"id\\": 7,
\\"attributes\\": {
\\"class\\": \\"custom-mask\\"
}
}
],
\\"removes\\": [
{
\\"parentId\\": 7,
\\"id\\": 8
}
],
\\"adds\\": [
{
\\"parentId\\": 10,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 20
}
},
{
\\"parentId\\": 20,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"*** **** ****\\",
\\"id\\": 21
}
},
{
\\"parentId\\": 7,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"*******\\",
\\"id\\": 22
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [
{
\\"id\\": 21,
\\"value\\": \\"********** ****** ** ****** *** **** ****\\"
}
],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": []
}
} }
]" ]"
`; `;

View File

@@ -1207,6 +1207,45 @@ describe('record integration tests', function (this: ISuite) {
p.innerText = 'mutated'; p.innerText = 'mutated';
}); });
await page.evaluate(() => {
// generate a characterData mutation; innerText doesn't do that
const p = document.querySelector('p') as HTMLParagraphElement;
(p.childNodes[0] as Text).insertData(0, 'doubly ');
});
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('can mask character data mutations with regexp', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'mutation-observer.html', {
maskTextClass: /custom/,
}),
);
await page.evaluate(() => {
const li = document.createElement('li');
const ul = document.querySelector('ul') as HTMLUListElement;
const p = document.querySelector('p') as HTMLParagraphElement;
[ul, p].forEach((element) => {
element.className = 'custom-mask';
});
ul.appendChild(li);
li.innerText = 'new list item';
p.innerText = 'mutated';
});
await page.evaluate(() => {
// generate a characterData mutation; innerText doesn't do that
const li = document.querySelector('li:not(:empty)') as HTMLLIElement;
(li.childNodes[0] as Text).insertData(0, 'descendent should be masked ');
});
const snapshots = (await page.evaluate( const snapshots = (await page.evaluate(
'window.snapshots', 'window.snapshots',
)) as eventWithTime[]; )) as eventWithTime[];

View File

@@ -693,6 +693,7 @@ export function generateRecordSnippet(options: recordOptions<eventWithTime>) {
maskAllInputs: ${options.maskAllInputs}, maskAllInputs: ${options.maskAllInputs},
maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, maskInputOptions: ${JSON.stringify(options.maskAllInputs)},
userTriggeredOnInput: ${options.userTriggeredOnInput}, userTriggeredOnInput: ${options.userTriggeredOnInput},
maskTextClass: ${options.maskTextClass},
maskTextFn: ${options.maskTextFn}, maskTextFn: ${options.maskTextFn},
maskInputFn: ${options.maskInputFn}, maskInputFn: ${options.maskInputFn},
recordCanvas: ${options.recordCanvas}, recordCanvas: ${options.recordCanvas},