Skip to content

Commit

Permalink
Feat: Add support for replaying :defined pseudo-class of custom eleme…
Browse files Browse the repository at this point in the history
…nts (rrweb-io#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 (rrweb-io#1348)

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

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

---------

Co-authored-by: Nafees Nehar <[email protected]>
Co-authored-by: Justin Halsall <[email protected]>
  • Loading branch information
3 people authored Nov 7, 2023
1 parent dbd15a9 commit 8aea5b0
Show file tree
Hide file tree
Showing 14 changed files with 219 additions and 1 deletion.
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

0 comments on commit 8aea5b0

Please sign in to comment.