Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fluffy-planes-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rrweb': patch
---

Feat: Add support for replaying :defined pseudo-class of custom elements
7 changes: 7 additions & 0 deletions .changeset/smart-ears-refuse.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/rrweb-snapshot/src/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
/**
Expand Down
8 changes: 8 additions & 0 deletions packages/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,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,
Expand All @@ -809,6 +816,7 @@ function serializeElementNode(
isSVG: isSVGElement(n as Element) || undefined,
needBlock,
rootId,
isCustom: isCustomElement,
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/rrweb-snapshot/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,7 @@ exports[`shadow DOM integration tests snapshot shadow DOM 1`] = `
\\"isShadow\\": true
}
],
\\"isCustom\\": true,
\\"id\\": 16,
\\"isShadowHost\\": true
},
Expand Down
1 change: 1 addition & 0 deletions packages/rrweb-snapshot/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"compilerOptions": {
"composite": true,
"module": "ESNext",
"target": "ES6",
"moduleResolution": "Node",
"noImplicitAny": true,
"strictNullChecks": true,
Expand Down
1 change: 1 addition & 0 deletions packages/rrweb/src/record/iframe-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export class IframeManager {
}
}
}
return false;
}

private replace<T extends Record<string, unknown>>(
Expand Down
11 changes: 11 additions & 0 deletions packages/rrweb/src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,17 @@ function record<T = eventWithTime>(
}),
);
},
customElementCb: (c) => {
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.CustomElement,
...c,
},
}),
);
},
blockClass,
ignoreClass,
ignoreSelector,
Expand Down
48 changes: 48 additions & 0 deletions packages/rrweb/src/record/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
IWindow,
SelectionRange,
selectionCallback,
customElementCallback,
} from '@rrweb/types';
import MutationBuffer from './mutation';
import { callbackWrapper } from './error-handler';
Expand Down Expand Up @@ -1169,6 +1170,44 @@ function initSelectionObserver(param: observerParam): listenerHandler {
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) {
const {
mutationCb,
Expand All @@ -1183,6 +1222,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
canvasMutationCb,
fontCb,
selectionCb,
customElementCb,
} = o;
o.mutationCb = (...p: Arguments<mutationCallBack>) => {
if (hooks.mutation) {
Expand Down Expand Up @@ -1256,6 +1296,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
}
selectionCb(...p);
};
o.customElementCb = (...c: Arguments<customElementCallback>) => {
if (hooks.customElement) {
hooks.customElement(...c);
}
customElementCb(...c);
};
}

export function initObservers(
Expand Down Expand Up @@ -1302,6 +1348,7 @@ export function initObservers(
}
}
const selectionObserver = initSelectionObserver(o);
const customElementObserver = initCustomElementObserver(o);

// plugins
const pluginHandlers: listenerHandler[] = [];
Expand All @@ -1325,6 +1372,7 @@ export function initObservers(
styleDeclarationObserver();
fontObserver();
selectionObserver();
customElementObserver();
pluginHandlers.forEach((h) => h());
});
}
Expand Down
2 changes: 2 additions & 0 deletions packages/rrweb/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
addedNodeMutation,
blockClass,
canvasMutationCallback,
customElementCallback,
eventWithTime,
fontCallback,
hooksParam,
Expand Down Expand Up @@ -97,6 +98,7 @@ export type observerParam = {
styleSheetRuleCb: styleSheetRuleCallback;
styleDeclarationCb: styleDeclarationCallback;
canvasMutationCb: canvasMutationCallback;
customElementCb: customElementCallback;
fontCb: fontCallback;
sampling: SamplingStrategy;
recordDOM: boolean;
Expand Down
89 changes: 89 additions & 0 deletions packages/rrweb/test/events/custom-element-define-class.ts
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 16 additions & 0 deletions packages/rrweb/test/replayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import adoptedStyleSheet from './events/adopted-style-sheet';
import adoptedStyleSheetModification from './events/adopted-style-sheet-modification';
import documentReplacementEvents from './events/document-replacement';
import hoverInIframeShadowDom from './events/iframe-shadowdom-hover';
import customElementDefineClass from './events/custom-element-define-class';
import { ReplayerEvents } from '@rrweb/types';

interface ISuite {
Expand Down Expand Up @@ -1076,4 +1077,19 @@ describe('replayer', function () {
),
).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');
});
});
17 changes: 16 additions & 1 deletion packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export enum IncrementalSource {
StyleDeclaration,
Selection,
AdoptedStyleSheet,
CustomElement,
}

export type mutationData = {
Expand Down Expand Up @@ -142,6 +143,10 @@ export type adoptedStyleSheetData = {
source: IncrementalSource.AdoptedStyleSheet;
} & adoptedStyleSheetParam;

export type customElementData = {
source: IncrementalSource.CustomElement;
} & customElementParam;

export type incrementalData =
| mutationData
| mousemoveData
Expand All @@ -155,7 +160,8 @@ export type incrementalData =
| fontData
| selectionData
| styleDeclarationData
| adoptedStyleSheetData;
| adoptedStyleSheetData
| customElementData;

export type event =
| domContentLoadedEvent
Expand Down Expand Up @@ -262,6 +268,7 @@ export type hooksParam = {
canvasMutation?: canvasMutationCallback;
font?: fontCallback;
selection?: selectionCallback;
customElement?: customElementCallback;
};

// https://dom.spec.whatwg.org/#interface-mutationrecord
Expand Down Expand Up @@ -593,6 +600,14 @@ export type selectionParam = {

export type selectionCallback = (p: selectionParam) => void;

export type customElementParam = {
define?: {
name: string;
};
};

export type customElementCallback = (c: customElementParam) => void;

export type DeprecatedMirror = {
map: {
[key: number]: INode;
Expand Down