diff --git a/.changeset/fluffy-planes-retire.md b/.changeset/fluffy-planes-retire.md
new file mode 100644
index 0000000000..41e9601704
--- /dev/null
+++ b/.changeset/fluffy-planes-retire.md
@@ -0,0 +1,5 @@
+---
+'rrweb': patch
+---
+
+Feat: Add support for replaying :defined pseudo-class of custom elements
diff --git a/.changeset/smart-ears-refuse.md b/.changeset/smart-ears-refuse.md
new file mode 100644
index 0000000000..0aaaabcf0f
--- /dev/null
+++ b/.changeset/smart-ears-refuse.md
@@ -0,0 +1,7 @@
+---
+'rrweb-snapshot': patch
+---
+
+Feat: Add 'isCustom' flag to serialized elements.
+
+This flag is used to indicate whether the element is a custom element or not. This is useful for replaying the :defined pseudo-class of custom elements.
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index a1ad8e81cf..fa618e607b 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -1,6 +1,11 @@
name: Tests
-on: [push, pull_request]
+on:
+ push:
+ branches:
+ - master
+ - release/**
+ pull_request:
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -8,6 +13,8 @@ jobs:
release:
name: Tests
runs-on: ubuntu-latest
+ # For now, we know this will fail, so we allow failures
+ continue-on-error: true
steps:
- name: Checkout Repo
uses: actions/checkout@v3
diff --git a/guide.md b/guide.md
index e2dbf0d23f..fd6267a19b 100644
--- a/guide.md
+++ b/guide.md
@@ -144,8 +144,11 @@ The parameter of `rrweb.record` accepts the following options.
| blockSelector | null | Use a string to configure which selector should be blocked, refer to the [privacy](#privacy) chapter |
| ignoreClass | 'rr-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter |
| ignoreCSSAttributes | null | array of CSS attributes that should be ignored |
+| maskAllText | false | mask all text content as \* |
| maskTextClass | 'rr-mask' | Use a string or RegExp to configure which elements should be masked, refer to the [privacy](#privacy) chapter |
+| unmaskTextClass | null | Use a string or RegExp to configure which elements should be unmasked, refer to the [privacy](#privacy) chapter |
| maskTextSelector | null | Use a string to configure which selector should be masked, refer to the [privacy](#privacy) chapter |
+| unmaskTextSelector | null | Use a string to configure which selector should be unmasked, refer to the [privacy](#privacy) chapter |
| maskAllInputs | false | mask all input content as \* |
| maskInputOptions | { password: true } | mask some kinds of input \*
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L77-L95) |
| maskInputFn | - | customize mask input content recording logic |
@@ -172,6 +175,7 @@ You may find some contents on the webpage which are not willing to be recorded,
- An element with the class name `.rr-block` will not be recorded. Instead, it will replay as a placeholder with the same dimension.
- An element with the class name `.rr-ignore` will not record its input events.
- All text of elements with the class name `.rr-mask` and their children will be masked.
+- All text of elements with the optional unmasking class name `unmaskTextClass` and their children will be unmasked, unless any child is marked with `.rr-mask`.
- `input[type="password"]` will be masked by default.
- Mask options to mask the content in input elements.
diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts
index 5cf52ebd38..c3b15babba 100644
--- a/packages/rrweb-snapshot/src/rebuild.ts
+++ b/packages/rrweb-snapshot/src/rebuild.ts
@@ -142,6 +142,18 @@ function buildNode(
if (n.isSVG) {
node = doc.createElementNS('http://www.w3.org/2000/svg', tagName);
} else {
+ if (
+ // If the tag name is a custom element name
+ n.isCustom &&
+ // If the browser supports custom elements
+ doc.defaultView?.customElements &&
+ // If the custom element hasn't been defined yet
+ !doc.defaultView.customElements.get(n.tagName)
+ )
+ doc.defaultView.customElements.define(
+ n.tagName,
+ class extends doc.defaultView.HTMLElement {},
+ );
node = doc.createElement(tagName);
}
/**
diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts
index 51f76f46f8..8789c197ea 100644
--- a/packages/rrweb-snapshot/src/snapshot.ts
+++ b/packages/rrweb-snapshot/src/snapshot.ts
@@ -293,31 +293,76 @@ export function _isBlockedElement(
return false;
}
+function elementClassMatchesRegex(el: HTMLElement, regex: RegExp): boolean {
+ for (let eIndex = el.classList.length; eIndex--; ) {
+ const className = el.classList[eIndex];
+ if (regex.test(className)) {
+ return true;
+ }
+ }
+ return false;
+}
+
export function classMatchesRegex(
node: Node | null,
regex: RegExp,
checkAncestors: boolean,
): boolean {
if (!node) return false;
- if (node.nodeType !== node.ELEMENT_NODE) {
- if (!checkAncestors) return false;
- return classMatchesRegex(node.parentNode, regex, checkAncestors);
+ if (checkAncestors) {
+ return (
+ distanceToMatch(node, (node) =>
+ elementClassMatchesRegex(node as HTMLElement, regex),
+ ) >= 0
+ );
+ } else if (node.nodeType === node.ELEMENT_NODE) {
+ return elementClassMatchesRegex(node as HTMLElement, regex);
}
+ return false;
+}
- for (let eIndex = (node as HTMLElement).classList.length; eIndex--; ) {
- const className = (node as HTMLElement).classList[eIndex];
- if (regex.test(className)) {
- return true;
+function distanceToMatch(
+ node: Node | null,
+ matchPredicate: (node: Node) => boolean,
+ limit = Infinity,
+ distance = 0,
+): number {
+ if (!node) return -1;
+ if (node.nodeType !== node.ELEMENT_NODE) return -1;
+ if (distance > limit) return -1;
+ if (matchPredicate(node)) return distance;
+ return distanceToMatch(node.parentNode, matchPredicate, limit, distance + 1);
+}
+
+function createMatchPredicate(
+ className: string | RegExp | null,
+ selector: string | null,
+): (node: Node) => boolean {
+ return (node: Node) => {
+ const el = node as HTMLElement;
+ if (el === null) return false;
+
+ if (className) {
+ if (typeof className === 'string') {
+ if (el.matches(`.${className}`)) return true;
+ } else if (elementClassMatchesRegex(el, className)) {
+ return true;
+ }
}
- }
- if (!checkAncestors) return false;
- return classMatchesRegex(node.parentNode, regex, checkAncestors);
+
+ if (selector && el.matches(selector)) return true;
+
+ return false;
+ };
}
export function needMaskingText(
node: Node,
maskTextClass: string | RegExp,
maskTextSelector: string | null,
+ unmaskTextClass: string | RegExp | null,
+ unmaskTextSelector: string | null,
+ maskAllText: boolean,
): boolean {
try {
const el: HTMLElement | null =
@@ -326,21 +371,53 @@ export function needMaskingText(
: node.parentElement;
if (el === null) return false;
- if (typeof maskTextClass === 'string') {
- if (el.classList.contains(maskTextClass)) return true;
- if (el.closest(`.${maskTextClass}`)) return true;
+ let maskDistance = -1;
+ let unmaskDistance = -1;
+
+ if (maskAllText) {
+ unmaskDistance = distanceToMatch(
+ el,
+ createMatchPredicate(unmaskTextClass, unmaskTextSelector),
+ );
+
+ if (unmaskDistance < 0) {
+ return true;
+ }
+
+ maskDistance = distanceToMatch(
+ el,
+ createMatchPredicate(maskTextClass, maskTextSelector),
+ unmaskDistance >= 0 ? unmaskDistance : Infinity,
+ );
} else {
- if (classMatchesRegex(el, maskTextClass, true)) return true;
- }
+ maskDistance = distanceToMatch(
+ el,
+ createMatchPredicate(maskTextClass, maskTextSelector),
+ );
+
+ if (maskDistance < 0) {
+ return false;
+ }
- if (maskTextSelector) {
- if (el.matches(maskTextSelector)) return true;
- if (el.closest(maskTextSelector)) return true;
+ unmaskDistance = distanceToMatch(
+ el,
+ createMatchPredicate(unmaskTextClass, unmaskTextSelector),
+ maskDistance >= 0 ? maskDistance : Infinity,
+ );
}
+
+ return maskDistance >= 0
+ ? unmaskDistance >= 0
+ ? maskDistance <= unmaskDistance
+ : true
+ : unmaskDistance >= 0
+ ? false
+ : !!maskAllText;
} catch (e) {
//
}
- return false;
+
+ return !!maskAllText;
}
// https://stackoverflow.com/a/36155560
@@ -434,8 +511,11 @@ function serializeNode(
mirror: Mirror;
blockClass: string | RegExp;
blockSelector: string | null;
+ maskAllText: boolean;
maskTextClass: string | RegExp;
+ unmaskTextClass: string | RegExp | null;
maskTextSelector: string | null;
+ unmaskTextSelector: string | null;
inlineStylesheet: boolean;
maskInputOptions: MaskInputOptions;
maskTextFn: MaskTextFn | undefined;
@@ -455,8 +535,11 @@ function serializeNode(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
inlineStylesheet,
maskInputOptions = {},
maskTextFn,
@@ -505,11 +588,19 @@ function serializeNode(
keepIframeSrcFn,
newlyAddedElement,
rootId,
+ maskAllText,
+ maskTextClass,
+ unmaskTextClass,
+ maskTextSelector,
+ unmaskTextSelector,
});
case n.TEXT_NODE:
return serializeTextNode(n as Text, {
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
maskTextFn,
rootId,
});
@@ -539,13 +630,24 @@ function getRootId(doc: Document, mirror: Mirror): number | undefined {
function serializeTextNode(
n: Text,
options: {
+ maskAllText: boolean;
maskTextClass: string | RegExp;
+ unmaskTextClass: string | RegExp | null;
maskTextSelector: string | null;
+ unmaskTextSelector: string | null;
maskTextFn: MaskTextFn | undefined;
rootId: number | undefined;
},
): serializedNode {
- const { maskTextClass, maskTextSelector, maskTextFn, rootId } = options;
+ const {
+ maskAllText,
+ maskTextClass,
+ unmaskTextClass,
+ maskTextSelector,
+ unmaskTextSelector,
+ 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;
@@ -580,7 +682,14 @@ function serializeTextNode(
!isStyle &&
!isScript &&
textContent &&
- needMaskingText(n, maskTextClass, maskTextSelector)
+ needMaskingText(
+ n,
+ maskTextClass,
+ maskTextSelector,
+ unmaskTextClass,
+ unmaskTextSelector,
+ maskAllText,
+ )
) {
textContent = maskTextFn
? maskTextFn(textContent)
@@ -613,6 +722,11 @@ function serializeElementNode(
*/
newlyAddedElement?: boolean;
rootId: number | undefined;
+ maskAllText: boolean;
+ maskTextClass: string | RegExp;
+ unmaskTextClass: string | RegExp | null;
+ maskTextSelector: string | null;
+ unmaskTextSelector: string | null;
},
): serializedNode | false {
const {
@@ -628,6 +742,11 @@ function serializeElementNode(
keepIframeSrcFn,
newlyAddedElement = false,
rootId,
+ maskAllText,
+ maskTextClass,
+ unmaskTextClass,
+ maskTextSelector,
+ unmaskTextSelector,
} = options;
const needBlock = _isBlockedElement(n, blockClass, blockSelector);
const tagName = getValidTagName(n);
@@ -685,6 +804,15 @@ function serializeElementNode(
value
) {
const type = getInputType(n);
+ const forceMask = needMaskingText(
+ n,
+ maskTextClass,
+ maskTextSelector,
+ unmaskTextClass,
+ unmaskTextSelector,
+ maskAllText,
+ );
+
attributes.value = maskInputValue({
element: n,
type,
@@ -692,6 +820,7 @@ function serializeElementNode(
value,
maskInputOptions,
maskInputFn,
+ forceMask,
});
} else if (checked) {
attributes.checked = checked;
@@ -809,6 +938,13 @@ function serializeElementNode(
delete attributes.src; // prevent auto loading
}
+ let isCustomElement: true | undefined;
+ try {
+ if (customElements.get(tagName)) isCustomElement = true;
+ } catch (e) {
+ // In case old browsers don't support customElements
+ }
+
return {
type: NodeType.Element,
tagName,
@@ -817,6 +953,7 @@ function serializeElementNode(
isSVG: isSVGElement(n as Element) || undefined,
needBlock,
rootId,
+ isCustom: isCustomElement,
};
}
@@ -930,11 +1067,14 @@ export function serializeNodeWithId(
blockClass: string | RegExp;
blockSelector: string | null;
maskTextClass: string | RegExp;
+ unmaskTextClass: string | RegExp | null;
maskTextSelector: string | null;
+ unmaskTextSelector: string | null;
skipChild: boolean;
inlineStylesheet: boolean;
newlyAddedElement?: boolean;
maskInputOptions?: MaskInputOptions;
+ maskAllText: boolean;
maskTextFn: MaskTextFn | undefined;
maskInputFn: MaskInputFn | undefined;
slimDOMOptions: SlimDOMOptions;
@@ -961,8 +1101,11 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
skipChild = false,
inlineStylesheet = true,
maskInputOptions = {},
@@ -986,8 +1129,11 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
inlineStylesheet,
maskInputOptions,
maskTextFn,
@@ -1058,8 +1204,11 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
skipChild,
inlineStylesheet,
maskInputOptions,
@@ -1118,8 +1267,11 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
skipChild: false,
inlineStylesheet,
maskInputOptions,
@@ -1165,8 +1317,11 @@ export function serializeNodeWithId(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
skipChild: false,
inlineStylesheet,
maskInputOptions,
@@ -1206,12 +1361,15 @@ function snapshot(
mirror?: Mirror;
blockClass?: string | RegExp;
blockSelector?: string | null;
+ maskAllText?: boolean;
maskTextClass?: string | RegExp;
+ unmaskTextClass?: string | RegExp | null;
maskTextSelector?: string | null;
+ unmaskTextSelector?: string | null;
inlineStylesheet?: boolean;
maskAllInputs?: boolean | MaskInputOptions;
maskTextFn?: MaskTextFn;
- maskInputFn?: MaskTextFn;
+ maskInputFn?: MaskInputFn;
slimDOM?: 'all' | boolean | SlimDOMOptions;
dataURLOptions?: DataURLOptions;
inlineImages?: boolean;
@@ -1235,8 +1393,11 @@ function snapshot(
mirror = new Mirror(),
blockClass = 'rr-block',
blockSelector = null,
+ maskAllText = false,
maskTextClass = 'rr-mask',
+ unmaskTextClass = null,
maskTextSelector = null,
+ unmaskTextSelector = null,
inlineStylesheet = true,
inlineImages = false,
recordCanvas = false,
@@ -1301,8 +1462,11 @@ function snapshot(
mirror,
blockClass,
blockSelector,
+ maskAllText,
maskTextClass,
+ unmaskTextClass,
maskTextSelector,
+ unmaskTextSelector,
skipChild: false,
inlineStylesheet,
maskInputOptions,
diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts
index 9edb4dd6d4..089afadd40 100644
--- a/packages/rrweb-snapshot/src/types.ts
+++ b/packages/rrweb-snapshot/src/types.ts
@@ -38,6 +38,8 @@ export type elementNode = {
childNodes: serializedNodeWithId[];
isSVG?: true;
needBlock?: boolean;
+ // This is a custom element or not.
+ isCustom?: true;
};
export type textNode = {
@@ -67,6 +69,7 @@ export type serializedNode = (
rootId?: number;
isShadowHost?: boolean;
isShadow?: boolean;
+ isCustom?: boolean;
};
export type serializedNodeWithId = serializedNode & { id: number };
diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts
index b124680b57..49e2d70d94 100644
--- a/packages/rrweb-snapshot/src/utils.ts
+++ b/packages/rrweb-snapshot/src/utils.ts
@@ -160,6 +160,7 @@ export function maskInputValue({
type,
value,
maskInputFn,
+ forceMask,
}: {
element: HTMLElement;
maskInputOptions: MaskInputOptions;
@@ -167,13 +168,15 @@ export function maskInputValue({
type: string | null;
value: string | null;
maskInputFn?: MaskInputFn;
+ forceMask?: boolean;
}): string {
let text = value || '';
const actualType = type && toLowerCase(type);
if (
maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] ||
- (actualType && maskInputOptions[actualType as keyof MaskInputOptions])
+ (actualType && maskInputOptions[actualType as keyof MaskInputOptions]) ||
+ forceMask
) {
if (maskInputFn) {
text = maskInputFn(text, element);
diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap
index 529a51eeff..9488069129 100644
--- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap
+++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap
@@ -246,7 +246,10 @@ exports[`integration tests [html file]: form-fields.html 1`] = `
+