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:
5
.changeset/fluffy-planes-retire.md
Normal file
5
.changeset/fluffy-planes-retire.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'rrweb': patch
|
||||
---
|
||||
|
||||
Feat: Add support for replaying :defined pseudo-class of custom elements
|
||||
7
.changeset/smart-ears-refuse.md
Normal file
7
.changeset/smart-ears-refuse.md
Normal 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.
|
||||
@@ -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);
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
@@ -809,6 +816,7 @@ function serializeElementNode(
|
||||
isSVG: isSVGElement(n as Element) || undefined,
|
||||
needBlock,
|
||||
rootId,
|
||||
isCustom: isCustomElement,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -839,6 +839,7 @@ exports[`shadow DOM integration tests snapshot shadow DOM 1`] = `
|
||||
\\"isShadow\\": true
|
||||
}
|
||||
],
|
||||
\\"isCustom\\": true,
|
||||
\\"id\\": 16,
|
||||
\\"isShadowHost\\": true
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"moduleResolution": "Node",
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
|
||||
@@ -235,6 +235,7 @@ export class IframeManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private replace<T extends Record<string, unknown>>(
|
||||
|
||||
@@ -525,6 +525,17 @@ function record<T = eventWithTime>(
|
||||
}),
|
||||
);
|
||||
},
|
||||
customElementCb: (c) => {
|
||||
wrappedEmit(
|
||||
wrapEvent({
|
||||
type: EventType.IncrementalSnapshot,
|
||||
data: {
|
||||
source: IncrementalSource.CustomElement,
|
||||
...c,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
blockClass,
|
||||
ignoreClass,
|
||||
ignoreSelector,
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
IWindow,
|
||||
SelectionRange,
|
||||
selectionCallback,
|
||||
customElementCallback,
|
||||
} from '@rrweb/types';
|
||||
import MutationBuffer from './mutation';
|
||||
import { callbackWrapper } from './error-handler';
|
||||
@@ -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,
|
||||
@@ -1183,6 +1222,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
|
||||
canvasMutationCb,
|
||||
fontCb,
|
||||
selectionCb,
|
||||
customElementCb,
|
||||
} = o;
|
||||
o.mutationCb = (...p: Arguments<mutationCallBack>) => {
|
||||
if (hooks.mutation) {
|
||||
@@ -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(
|
||||
@@ -1302,6 +1348,7 @@ export function initObservers(
|
||||
}
|
||||
}
|
||||
const selectionObserver = initSelectionObserver(o);
|
||||
const customElementObserver = initCustomElementObserver(o);
|
||||
|
||||
// plugins
|
||||
const pluginHandlers: listenerHandler[] = [];
|
||||
@@ -1325,6 +1372,7 @@ export function initObservers(
|
||||
styleDeclarationObserver();
|
||||
fontObserver();
|
||||
selectionObserver();
|
||||
customElementObserver();
|
||||
pluginHandlers.forEach((h) => h());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
addedNodeMutation,
|
||||
blockClass,
|
||||
canvasMutationCallback,
|
||||
customElementCallback,
|
||||
eventWithTime,
|
||||
fontCallback,
|
||||
hooksParam,
|
||||
@@ -97,6 +98,7 @@ export type observerParam = {
|
||||
styleSheetRuleCb: styleSheetRuleCallback;
|
||||
styleDeclarationCb: styleDeclarationCallback;
|
||||
canvasMutationCb: canvasMutationCallback;
|
||||
customElementCb: customElementCallback;
|
||||
fontCb: fontCallback;
|
||||
sampling: SamplingStrategy;
|
||||
recordDOM: boolean;
|
||||
|
||||
89
packages/rrweb/test/events/custom-element-define-class.ts
Normal file
89
packages/rrweb/test/events/custom-element-define-class.ts
Normal 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;
|
||||
@@ -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 {
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ export enum IncrementalSource {
|
||||
StyleDeclaration,
|
||||
Selection,
|
||||
AdoptedStyleSheet,
|
||||
CustomElement,
|
||||
}
|
||||
|
||||
export type mutationData = {
|
||||
@@ -142,6 +143,10 @@ export type adoptedStyleSheetData = {
|
||||
source: IncrementalSource.AdoptedStyleSheet;
|
||||
} & adoptedStyleSheetParam;
|
||||
|
||||
export type customElementData = {
|
||||
source: IncrementalSource.CustomElement;
|
||||
} & customElementParam;
|
||||
|
||||
export type incrementalData =
|
||||
| mutationData
|
||||
| mousemoveData
|
||||
@@ -155,7 +160,8 @@ export type incrementalData =
|
||||
| fontData
|
||||
| selectionData
|
||||
| styleDeclarationData
|
||||
| adoptedStyleSheetData;
|
||||
| adoptedStyleSheetData
|
||||
| customElementData;
|
||||
|
||||
export type event =
|
||||
| domContentLoadedEvent
|
||||
@@ -262,6 +268,7 @@ export type hooksParam = {
|
||||
canvasMutation?: canvasMutationCallback;
|
||||
font?: fontCallback;
|
||||
selection?: selectionCallback;
|
||||
customElement?: customElementCallback;
|
||||
};
|
||||
|
||||
// https://dom.spec.whatwg.org/#interface-mutationrecord
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user