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

@@ -310,6 +310,7 @@ export function needMaskingText(
node: Node,
maskTextClass: string | RegExp,
maskTextSelector: string | null,
checkAncestors: boolean,
): boolean {
try {
const el: HTMLElement | null =
@@ -317,17 +318,21 @@ export function needMaskingText(
? (node as HTMLElement)
: node.parentElement;
if (el === null) return false;
if (typeof maskTextClass === 'string') {
if (el.classList.contains(maskTextClass)) return true;
if (el.closest(`.${maskTextClass}`)) return true;
if (checkAncestors) {
if (el.closest(`.${maskTextClass}`)) return true;
} else {
if (el.classList.contains(maskTextClass)) return true;
}
} else {
if (classMatchesRegex(el, maskTextClass, true)) return true;
if (classMatchesRegex(el, maskTextClass, checkAncestors)) return true;
}
if (maskTextSelector) {
if (el.matches(maskTextSelector)) return true;
if (el.closest(maskTextSelector)) return true;
if (checkAncestors) {
if (el.closest(maskTextSelector)) return true;
} else {
if (el.matches(maskTextSelector)) return true;
}
}
} catch (e) {
//
@@ -426,8 +431,7 @@ function serializeNode(
mirror: Mirror;
blockClass: string | RegExp;
blockSelector: string | null;
maskTextClass: string | RegExp;
maskTextSelector: string | null;
needsMask: boolean | undefined;
inlineStylesheet: boolean;
maskInputOptions: MaskInputOptions;
maskTextFn: MaskTextFn | undefined;
@@ -447,8 +451,7 @@ function serializeNode(
mirror,
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
needsMask,
inlineStylesheet,
maskInputOptions = {},
maskTextFn,
@@ -500,8 +503,7 @@ function serializeNode(
});
case n.TEXT_NODE:
return serializeTextNode(n as Text, {
maskTextClass,
maskTextSelector,
needsMask,
maskTextFn,
rootId,
});
@@ -531,13 +533,12 @@ function getRootId(doc: Document, mirror: Mirror): number | undefined {
function serializeTextNode(
n: Text,
options: {
maskTextClass: string | RegExp;
maskTextSelector: string | null;
needsMask: boolean | undefined;
maskTextFn: MaskTextFn | undefined;
rootId: number | undefined;
},
): 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.
// So just let it be undefined which is ok in this use case.
const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName;
@@ -568,12 +569,7 @@ function serializeTextNode(
if (isScript) {
textContent = 'SCRIPT_PLACEHOLDER';
}
if (
!isStyle &&
!isScript &&
textContent &&
needMaskingText(n, maskTextClass, maskTextSelector)
) {
if (!isStyle && !isScript && textContent && needsMask) {
textContent = maskTextFn
? maskTextFn(textContent, n.parentElement)
: textContent.replace(/[\S]/g, '*');
@@ -935,6 +931,7 @@ export function serializeNodeWithId(
inlineStylesheet: boolean;
newlyAddedElement?: boolean;
maskInputOptions?: MaskInputOptions;
needsMask?: boolean;
maskTextFn: MaskTextFn | undefined;
maskInputFn: MaskInputFn | undefined;
slimDOMOptions: SlimDOMOptions;
@@ -980,14 +977,29 @@ export function serializeNodeWithId(
keepIframeSrcFn = () => false,
newlyAddedElement = false,
} = options;
let { needsMask } = 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, {
doc,
mirror,
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
needsMask,
inlineStylesheet,
maskInputOptions,
maskTextFn,
@@ -1058,6 +1070,7 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
needsMask,
maskTextClass,
maskTextSelector,
skipChild,
@@ -1118,6 +1131,7 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
needsMask,
maskTextClass,
maskTextSelector,
skipChild: false,
@@ -1165,6 +1179,7 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
needsMask,
maskTextClass,
maskTextSelector,
skipChild: false,