Reverse monkey patch built in methods to support LWC (#1509)

* Get around monkey patched Nodes

* inlineImages: Setting of `image.crossOrigin` is not always necessary (#1468)

Setting of the `crossorigin` attribute is not necessary for same-origin images, and causes an immediate image reload (albeit from cache) necessitating the use of a load event listener which subsequently mutates the snapshot.  This change allows us to  avoid the mutation of the snapshot for the same-origin case.

* Modify inlineImages test to remove delay and show that we can inline images without mutation

* Add an explicit test for when the `image.crossOrigin = 'anonymous';` method is necessary.  Uses a combination of about:blank and our test server to simulate a cross-origin context

* Other test changes: there were some spurious rrweb mutations being generated by the addition of the crossorigin attribute that are now elimnated from the rrweb/__snapshots__/integration.test.ts.snap after this PR - this is good

* Move `childNodes` to @rrweb/utils

* Use non-monkey patched versions of the `childNodes`, `parentNode` `parentElement` `textContent` accessors

* Add getRootNode and contains, and add comprehensive todo list

* chore: Update turbo.json tasks for better build process

* Update caniuse-lite

* chore: Update eslint-plugin-compat to version 5.0.0

* chore: Bump @rrweb/utils version to 2.0.0-alpha.15

* delete unused yarn.lock files

* Set correct @rrweb/utils version in package.json

* Migrate over some accessors to reverse-monkey-patched version

* Add missing functions

* Fix illegal invocation error

* Revert closer to what it was.

This feels incorrect to me (Justin Halsall), but some of the tests break without it so I'm restoring this to be closer to its original here:
cfd686d488/packages/rrweb-snapshot/src/snapshot.ts (L1011)

* Reverse monkey patch all methods LWC hijacks

* Make tests more stable

* Safely handle rrdom nodes in hasShadowRoot

* Remove duplicated test

* Use variable `serverURL` in test

* Use monorepo default browserlist

* Fix typing issue for new typescript

* Remove unused package

* Remove unused code

* Add prefix to reverse-monkey-patched methods to make them more explicit

* Add default exports to @rrweb/utils

---------

Co-authored-by: Eoghan Murray <eoghan@getthere.ie>
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 4277d7e9c3
commit d4c1440389
28 changed files with 911 additions and 184 deletions

View File

@@ -54,6 +54,7 @@
},
"homepage": "https://github.com/rrweb-io/rrweb/tree/master/packages/rrweb-snapshot#readme",
"devDependencies": {
"@rrweb/utils": "^2.0.0-alpha.16",
"@types/jsdom": "^20.0.0",
"@types/node": "^18.15.11",
"@types/puppeteer": "^5.4.4",

View File

@@ -28,6 +28,7 @@ import {
extractFileExtension,
absolutifyURLs,
} from './utils';
import dom from '@rrweb/utils';
let _id = 1;
const tagNameRegex = new RegExp('[^a-z0-9-_:]');
@@ -247,7 +248,7 @@ export function classMatchesRegex(
if (!node) return false;
if (node.nodeType !== node.ELEMENT_NODE) {
if (!checkAncestors) return false;
return classMatchesRegex(node.parentNode, regex, checkAncestors);
return classMatchesRegex(dom.parentNode(node), regex, checkAncestors);
}
for (let eIndex = (node as HTMLElement).classList.length; eIndex--; ) {
@@ -257,7 +258,7 @@ export function classMatchesRegex(
}
}
if (!checkAncestors) return false;
return classMatchesRegex(node.parentNode, regex, checkAncestors);
return classMatchesRegex(dom.parentNode(node), regex, checkAncestors);
}
export function needMaskingText(
@@ -269,16 +270,16 @@ export function needMaskingText(
let el: Element;
if (isElement(node)) {
el = node;
if (!el.childNodes.length) {
if (!dom.childNodes(el).length) {
// optimisation: we can avoid any of the below checks on leaf elements
// as masking is applied to child text nodes only
return false;
}
} else if (node.parentElement === null) {
} else if (dom.parentElement(node) === null) {
// should warn? maybe a text node isn't attached to a parent node yet?
return false;
} else {
el = node.parentElement;
el = dom.parentElement(node)!;
}
try {
if (typeof maskTextClass === 'string') {
@@ -475,7 +476,7 @@ function serializeNode(
case n.COMMENT_NODE:
return {
type: NodeType.Comment,
textContent: (n as Comment).textContent || '',
textContent: dom.textContent(n as Comment) || '',
rootId,
};
default:
@@ -501,11 +502,12 @@ function serializeTextNode(
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;
let textContent = n.textContent;
const parent = dom.parentNode(n);
const parentTagName = parent && (parent as HTMLElement).tagName;
let text = dom.textContent(n);
const isStyle = parentTagName === 'STYLE' ? true : undefined;
const isScript = parentTagName === 'SCRIPT' ? true : undefined;
if (isStyle && textContent) {
if (isStyle && text) {
try {
// try to read style sheet
if (n.nextSibling || n.previousSibling) {
@@ -513,10 +515,8 @@ function serializeTextNode(
// We can't read all of the sheet's .cssRules and expect them
// to _only_ include the current rule(s) added by the text node.
// So we'll be conservative and keep textContent as-is.
} else if ((n.parentNode as HTMLStyleElement).sheet?.cssRules) {
textContent = stringifyStylesheet(
(n.parentNode as HTMLStyleElement).sheet!,
);
} else if ((parent as HTMLStyleElement).sheet?.cssRules) {
text = stringifyStylesheet((parent as HTMLStyleElement).sheet!);
}
} catch (err) {
console.warn(
@@ -524,20 +524,20 @@ function serializeTextNode(
n,
);
}
textContent = absolutifyURLs(textContent, getHref(options.doc));
text = absolutifyURLs(text, getHref(options.doc));
}
if (isScript) {
textContent = 'SCRIPT_PLACEHOLDER';
text = 'SCRIPT_PLACEHOLDER';
}
if (!isStyle && !isScript && textContent && needsMask) {
textContent = maskTextFn
? maskTextFn(textContent, n.parentElement)
: textContent.replace(/[\S]/g, '*');
if (!isStyle && !isScript && text && needsMask) {
text = maskTextFn
? maskTextFn(text, dom.parentElement(n))
: text.replace(/[\S]/g, '*');
}
return {
type: NodeType.Text,
textContent: textContent || '',
textContent: text || '',
isStyle,
rootId,
};
@@ -594,6 +594,7 @@ function serializeElementNode(
}
// remote css
if (tagName === 'link' && inlineStylesheet) {
//TODO: maybe replace this `.styleSheets` with original one
const stylesheet = Array.from(doc.styleSheets).find((s) => {
return s.href === (n as HTMLLinkElement).href;
});
@@ -612,7 +613,7 @@ function serializeElementNode(
tagName === 'style' &&
(n as HTMLStyleElement).sheet &&
// TODO: Currently we only try to get dynamic stylesheet when it is an empty style element
!(n.innerText || n.textContent || '').trim().length
!(n.innerText || dom.textContent(n) || '').trim().length
) {
const cssText = stringifyStylesheet(
(n as HTMLStyleElement).sheet as CSSStyleSheet,
@@ -1030,8 +1031,8 @@ export function serializeNodeWithId(
recordChild = recordChild && !serializedNode.needBlock;
// this property was not needed in replay side
delete serializedNode.needBlock;
const shadowRoot = (n as HTMLElement).shadowRoot;
if (shadowRoot && isNativeShadowDom(shadowRoot))
const shadowRootEl = dom.shadowRoot(n);
if (shadowRootEl && isNativeShadowDom(shadowRootEl))
serializedNode.isShadowHost = true;
}
if (
@@ -1080,7 +1081,7 @@ export function serializeNodeWithId(
) {
// value parameter in DOM reflects the correct value, so ignore childNode
} else {
for (const childN of Array.from(n.childNodes)) {
for (const childN of Array.from(dom.childNodes(n))) {
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
if (serializedChildNode) {
serializedNode.childNodes.push(serializedChildNode);
@@ -1088,11 +1089,12 @@ export function serializeNodeWithId(
}
}
if (isElement(n) && n.shadowRoot) {
for (const childN of Array.from(n.shadowRoot.childNodes)) {
let shadowRootEl: ShadowRoot | null = null;
if (isElement(n) && (shadowRootEl = dom.shadowRoot(n))) {
for (const childN of Array.from(dom.childNodes(shadowRootEl))) {
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
if (serializedChildNode) {
isNativeShadowDom(n.shadowRoot) &&
isNativeShadowDom(shadowRootEl) &&
(serializedChildNode.isShadow = true);
serializedNode.childNodes.push(serializedChildNode);
}
@@ -1100,11 +1102,8 @@ export function serializeNodeWithId(
}
}
if (
n.parentNode &&
isShadowRoot(n.parentNode) &&
isNativeShadowDom(n.parentNode)
) {
const parent = dom.parentNode(n);
if (parent && isShadowRoot(parent) && isNativeShadowDom(parent)) {
serializedNode.isShadow = true;
}

View File

@@ -11,6 +11,7 @@ import type {
textNode,
elementNode,
} from './types';
import dom from '@rrweb/utils';
import { NodeType } from './types';
export function isElement(n: Node): n is Element {
@@ -18,8 +19,13 @@ export function isElement(n: Node): n is Element {
}
export function isShadowRoot(n: Node): n is ShadowRoot {
const host: Element | null = (n as ShadowRoot)?.host;
return Boolean(host?.shadowRoot === n);
const hostEl: Element | null =
// anchor and textarea elements also have a `host` property
// but only shadow roots have a `mode` property
(n && 'host' in n && 'mode' in n && dom.host(n as ShadowRoot)) || null;
return Boolean(
hostEl && 'shadowRoot' in hostEl && dom.shadowRoot(hostEl) === n,
);
}
/**

View File

@@ -630,6 +630,218 @@ exports[`integration tests > [html file]: with-style-sheet-with-import.html 1`]
</body></html>"
`;
exports[`integration tests > should be able to record elements even when .childNodes has been monkey patched 1`] = `
"{
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {
\\"lang\\": \\"en\\"
},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"charset\\": \\"UTF-8\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"name\\": \\"viewport\\",
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
},
\\"childNodes\\": [],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 9
},
{
\\"type\\": 2,
\\"tagName\\": \\"title\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Document\\",
\\"id\\": 11
}
],
\\"id\\": 10
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 12
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 14
}
],
\\"id\\": 13
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 15
}
],
\\"id\\": 4
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 18
},
{
\\"type\\": 2,
\\"tagName\\": \\"ul\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 20
},
{
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"a\\",
\\"id\\": 22
}
],
\\"id\\": 21
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 23
},
{
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"b\\",
\\"id\\": 25
}
],
\\"id\\": 24
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 26
},
{
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"c\\",
\\"id\\": 28
}
],
\\"id\\": 27
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 29
},
{
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"d\\",
\\"id\\": 31
}
],
\\"id\\": 30
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 32
}
],
\\"id\\": 19
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 33
}
],
\\"id\\": 17
}
],
\\"id\\": 3
}
],
\\"id\\": 1
}"
`;
exports[`shadow DOM integration tests > snapshot shadow DOM 1`] = `
"{
\\"type\\": 0,

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script>
// monkey patches .childNodes to make them unaccessible
// emulates the effect of some frameworks, specifically salesforce lightning-ui
Object.defineProperty(Element.prototype, 'childNodes', {
get() {
throw new Error('childNodes was hijacked by framework');
},
});
Object.defineProperty(Node.prototype, 'childNodes', {
get() {
throw new Error('childNodes was hijacked by framework');
},
});
Node.prototype.contains = function () {
throw new Error('contains was hijacked by framework');
};
Node.prototype.getRootNode = function () {
throw new Error('getRootNode was hijacked by framework');
};
MutationObserver = class {
constructor() {
throw new Error('MutationObserver was hijacked by framework');
}
};
</script>
</head>
<body>
<ul>
<li>a</li>
<li>b</li>
<li>c</li>
<li>d</li>
</ul>
</body>
</html>

View File

@@ -120,6 +120,9 @@ describe('integration tests', function (this: ISuite) {
if (html.filePath.substring(html.filePath.length - 1) === '~') {
continue;
}
// monkey patching breaks rebuild code
if (html.filePath.includes('monkey-patched-elements.html')) continue;
const title = '[html file]: ' + html.filePath;
it(title, async () => {
const page: puppeteer.Page = await browser.newPage();
@@ -255,7 +258,6 @@ iframe.contentDocument.querySelector('center').clientHeight
it('correctly saves cross-origin images offline', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank', {
waitUntil: 'load',
});
@@ -368,7 +370,7 @@ iframe.contentDocument.querySelector('center').clientHeight
it('should save background-clip: text; as the more compatible -webkit-background-clip: test;', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto(`http://localhost:3030/html/background-clip-text.html`, {
await page.goto(`${serverURL}/html/background-clip-text.html`, {
waitUntil: 'load',
});
await waitForRAF(page); // wait for page to render
@@ -386,13 +388,10 @@ iframe.contentDocument.querySelector('center').clientHeight
it('images with inline onload should work', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto(
'http://localhost:3030/html/picture-with-inline-onload.html',
{
waitUntil: 'load',
},
);
await page.waitForSelector('img', { timeout: 1000 });
await page.goto(`${serverURL}/html/picture-with-inline-onload.html`, {
waitUntil: 'load',
});
await page.waitForSelector('img', { timeout: 2000 });
await page.evaluate(`${code}`);
await page.evaluate(`
var snapshot = rrwebSnapshot.snapshot(document, {
@@ -406,6 +405,22 @@ iframe.contentDocument.querySelector('center').clientHeight
)) as string;
assert(fnName === 'onload');
});
it('should be able to record elements even when .childNodes has been monkey patched', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto(`${serverURL}/html/monkey-patched-elements.html`, {
waitUntil: 'load',
});
await waitForRAF(page); // wait for page to render
const snapshotResult = JSON.stringify(
await page.evaluate(`${code};
rrwebSnapshot.snapshot(document);
`),
null,
2,
);
expect(snapshotResult).toMatchSnapshot();
});
});
describe('iframe integration tests', function (this: ISuite) {

View File

@@ -6,5 +6,9 @@
"rootDir": "src",
"tsBuildInfoFile": "./tsconfig.tsbuildinfo"
},
"references": []
"references": [
{
"path": "../utils"
}
]
}