Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 11 commits into from
Nov 7, 2023
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
47 changes: 47 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,43 @@ function initSelectionObserver(param: observerParam): listenerHandler {
return on('selectionchange', updateSelection);
}

function initCustomElementObserver({
doc,
customElementCb,
}: observerParam): listenerHandler {
const win = doc.defaultView as IWindow;
if (!win || !win.customElements) return () => {};
Juice10 marked this conversation as resolved.
Show resolved Hide resolved
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 +1221,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 +1295,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 +1347,7 @@ export function initObservers(
}
}
const selectionObserver = initSelectionObserver(o);
const customElementObserver = initCustomElementObserver(o);

// plugins
const pluginHandlers: listenerHandler[] = [];
Expand All @@ -1325,6 +1371,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
Loading