Feat: Add support for replaying :defined pseudo-class of custom elements (#1155)

* Feat: Add support for replaying :defined pseudo-class of custom elements

* add isCustom flag to serialized elements

Applying Justin's review suggestion

* fix code lint error

* add custom element event

* fix: tests (#1348)

* Update packages/rrweb/src/record/observer.ts

* Update packages/rrweb/src/record/observer.ts

---------

Co-authored-by: Nafees Nehar <nafees87n@gmail.com>
Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
This commit is contained in:
Yun Feng
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 1647bc3875
commit 4157f28e7b
14 changed files with 219 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
'rrweb': patch
---
Feat: Add support for replaying :defined pseudo-class of custom elements

View File

@@ -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.

View File

@@ -142,6 +142,18 @@ function buildNode(
if (n.isSVG) { if (n.isSVG) {
node = doc.createElementNS('http://www.w3.org/2000/svg', tagName); node = doc.createElementNS('http://www.w3.org/2000/svg', tagName);
} else { } 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); node = doc.createElement(tagName);
} }
/** /**

View File

@@ -801,6 +801,13 @@ function serializeElementNode(
delete attributes.src; // prevent auto loading 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 { return {
type: NodeType.Element, type: NodeType.Element,
tagName, tagName,
@@ -809,6 +816,7 @@ function serializeElementNode(
isSVG: isSVGElement(n as Element) || undefined, isSVG: isSVGElement(n as Element) || undefined,
needBlock, needBlock,
rootId, rootId,
isCustom: isCustomElement,
}; };
} }

View File

@@ -38,6 +38,8 @@ export type elementNode = {
childNodes: serializedNodeWithId[]; childNodes: serializedNodeWithId[];
isSVG?: true; isSVG?: true;
needBlock?: boolean; needBlock?: boolean;
// This is a custom element or not.
isCustom?: true;
}; };
export type textNode = { export type textNode = {

View File

@@ -839,6 +839,7 @@ exports[`shadow DOM integration tests snapshot shadow DOM 1`] = `
\\"isShadow\\": true \\"isShadow\\": true
} }
], ],
\\"isCustom\\": true,
\\"id\\": 16, \\"id\\": 16,
\\"isShadowHost\\": true \\"isShadowHost\\": true
}, },

View File

@@ -2,6 +2,7 @@
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"module": "ESNext", "module": "ESNext",
"target": "ES6",
"moduleResolution": "Node", "moduleResolution": "Node",
"noImplicitAny": true, "noImplicitAny": true,
"strictNullChecks": true, "strictNullChecks": true,

View File

@@ -235,6 +235,7 @@ export class IframeManager {
} }
} }
} }
return false;
} }
private replace<T extends Record<string, unknown>>( private replace<T extends Record<string, unknown>>(

View File

@@ -525,6 +525,17 @@ function record<T = eventWithTime>(
}), }),
); );
}, },
customElementCb: (c) => {
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.CustomElement,
...c,
},
}),
);
},
blockClass, blockClass,
ignoreClass, ignoreClass,
ignoreSelector, ignoreSelector,

View File

@@ -46,6 +46,7 @@ import {
IWindow, IWindow,
SelectionRange, SelectionRange,
selectionCallback, selectionCallback,
customElementCallback,
} from '@rrweb/types'; } from '@rrweb/types';
import MutationBuffer from './mutation'; import MutationBuffer from './mutation';
import { callbackWrapper } from './error-handler'; import { callbackWrapper } from './error-handler';
@@ -1169,6 +1170,44 @@ function initSelectionObserver(param: observerParam): listenerHandler {
return on('selectionchange', updateSelection); return on('selectionchange', updateSelection);
} }
function initCustomElementObserver({
doc,
customElementCb,
}: observerParam): listenerHandler {
const win = doc.defaultView as IWindow;
// eslint-disable-next-line @typescript-eslint/no-empty-function
if (!win || !win.customElements) return () => {};
const restoreHandler = patch(
win.customElements,
'define',
function (
original: (
name: string,
constructor: CustomElementConstructor,
options?: ElementDefinitionOptions,
) => void,
) {
return function (
name: string,
constructor: CustomElementConstructor,
options?: ElementDefinitionOptions,
) {
try {
customElementCb({
define: {
name,
},
});
} catch (e) {
console.warn(`Custom element callback failed for ${name}`);
}
return original.apply(this, [name, constructor, options]);
};
},
);
return restoreHandler;
}
function mergeHooks(o: observerParam, hooks: hooksParam) { function mergeHooks(o: observerParam, hooks: hooksParam) {
const { const {
mutationCb, mutationCb,
@@ -1183,6 +1222,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
canvasMutationCb, canvasMutationCb,
fontCb, fontCb,
selectionCb, selectionCb,
customElementCb,
} = o; } = o;
o.mutationCb = (...p: Arguments<mutationCallBack>) => { o.mutationCb = (...p: Arguments<mutationCallBack>) => {
if (hooks.mutation) { if (hooks.mutation) {
@@ -1256,6 +1296,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
} }
selectionCb(...p); selectionCb(...p);
}; };
o.customElementCb = (...c: Arguments<customElementCallback>) => {
if (hooks.customElement) {
hooks.customElement(...c);
}
customElementCb(...c);
};
} }
export function initObservers( export function initObservers(
@@ -1302,6 +1348,7 @@ export function initObservers(
} }
} }
const selectionObserver = initSelectionObserver(o); const selectionObserver = initSelectionObserver(o);
const customElementObserver = initCustomElementObserver(o);
// plugins // plugins
const pluginHandlers: listenerHandler[] = []; const pluginHandlers: listenerHandler[] = [];
@@ -1325,6 +1372,7 @@ export function initObservers(
styleDeclarationObserver(); styleDeclarationObserver();
fontObserver(); fontObserver();
selectionObserver(); selectionObserver();
customElementObserver();
pluginHandlers.forEach((h) => h()); pluginHandlers.forEach((h) => h());
}); });
} }

View File

@@ -17,6 +17,7 @@ import type {
addedNodeMutation, addedNodeMutation,
blockClass, blockClass,
canvasMutationCallback, canvasMutationCallback,
customElementCallback,
eventWithTime, eventWithTime,
fontCallback, fontCallback,
hooksParam, hooksParam,
@@ -97,6 +98,7 @@ export type observerParam = {
styleSheetRuleCb: styleSheetRuleCallback; styleSheetRuleCb: styleSheetRuleCallback;
styleDeclarationCb: styleDeclarationCallback; styleDeclarationCb: styleDeclarationCallback;
canvasMutationCb: canvasMutationCallback; canvasMutationCb: canvasMutationCallback;
customElementCb: customElementCallback;
fontCb: fontCallback; fontCb: fontCallback;
sampling: SamplingStrategy; sampling: SamplingStrategy;
recordDOM: boolean; recordDOM: boolean;

View File

@@ -0,0 +1,89 @@
import { EventType } from '@rrweb/types';
import type { eventWithTime } from '@rrweb/types';
const now = Date.now();
const events: eventWithTime[] = [
{
type: EventType.DomContentLoaded,
data: {},
timestamp: now,
},
{
type: EventType.Load,
data: {},
timestamp: now + 100,
},
{
type: EventType.Meta,
data: {
href: 'http://localhost',
width: 1000,
height: 800,
},
timestamp: now + 100,
},
// full snapshot:
{
data: {
node: {
id: 1,
type: 0,
childNodes: [
{ id: 2, name: 'html', type: 1, publicId: '', systemId: '' },
{
id: 3,
type: 2,
tagName: 'html',
attributes: { lang: 'en' },
childNodes: [
{
id: 4,
type: 2,
tagName: 'head',
attributes: {},
childNodes: [
{
id: 5,
type: 2,
tagName: 'style',
childNodes: [
{
id: 6,
type: 3,
isStyle: true,
// Set style of defined custom element to display: block
// Set undefined custom element to display: none
textContent:
'custom-element:not(:defined) { display: none;} \n custom-element:defined { display: block; }',
},
],
},
],
},
{
id: 7,
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{
id: 8,
type: 2,
tagName: 'custom-element',
childNodes: [],
isCustom: true,
},
],
},
],
},
],
},
initialOffset: { top: 0, left: 0 },
},
type: EventType.FullSnapshot,
timestamp: now + 100,
},
];
export default events;

View File

@@ -22,6 +22,7 @@ import adoptedStyleSheet from './events/adopted-style-sheet';
import adoptedStyleSheetModification from './events/adopted-style-sheet-modification'; import adoptedStyleSheetModification from './events/adopted-style-sheet-modification';
import documentReplacementEvents from './events/document-replacement'; import documentReplacementEvents from './events/document-replacement';
import hoverInIframeShadowDom from './events/iframe-shadowdom-hover'; import hoverInIframeShadowDom from './events/iframe-shadowdom-hover';
import customElementDefineClass from './events/custom-element-define-class';
import { ReplayerEvents } from '@rrweb/types'; import { ReplayerEvents } from '@rrweb/types';
interface ISuite { interface ISuite {
@@ -1076,4 +1077,19 @@ describe('replayer', function () {
), ),
).toBe(':hover'); ).toBe(':hover');
}); });
it('should replay styles with :define pseudo-class', async () => {
await page.evaluate(`events = ${JSON.stringify(customElementDefineClass)}`);
const displayValue = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(200);
const customElement = replayer.iframe.contentDocument.querySelector('custom-element');
window.getComputedStyle(customElement).display;
`);
// If the custom element is not defined, the display value will be 'none'.
// If the custom element is defined, the display value will be 'block'.
expect(displayValue).toEqual('block');
});
}); });

View File

@@ -83,6 +83,7 @@ export enum IncrementalSource {
StyleDeclaration, StyleDeclaration,
Selection, Selection,
AdoptedStyleSheet, AdoptedStyleSheet,
CustomElement,
} }
export type mutationData = { export type mutationData = {
@@ -142,6 +143,10 @@ export type adoptedStyleSheetData = {
source: IncrementalSource.AdoptedStyleSheet; source: IncrementalSource.AdoptedStyleSheet;
} & adoptedStyleSheetParam; } & adoptedStyleSheetParam;
export type customElementData = {
source: IncrementalSource.CustomElement;
} & customElementParam;
export type incrementalData = export type incrementalData =
| mutationData | mutationData
| mousemoveData | mousemoveData
@@ -155,7 +160,8 @@ export type incrementalData =
| fontData | fontData
| selectionData | selectionData
| styleDeclarationData | styleDeclarationData
| adoptedStyleSheetData; | adoptedStyleSheetData
| customElementData;
export type event = export type event =
| domContentLoadedEvent | domContentLoadedEvent
@@ -262,6 +268,7 @@ export type hooksParam = {
canvasMutation?: canvasMutationCallback; canvasMutation?: canvasMutationCallback;
font?: fontCallback; font?: fontCallback;
selection?: selectionCallback; selection?: selectionCallback;
customElement?: customElementCallback;
}; };
// https://dom.spec.whatwg.org/#interface-mutationrecord // https://dom.spec.whatwg.org/#interface-mutationrecord
@@ -593,6 +600,14 @@ export type selectionParam = {
export type selectionCallback = (p: selectionParam) => void; export type selectionCallback = (p: selectionParam) => void;
export type customElementParam = {
define?: {
name: string;
};
};
export type customElementCallback = (c: customElementParam) => void;
export type DeprecatedMirror = { export type DeprecatedMirror = {
map: { map: {
[key: number]: INode; [key: number]: INode;