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) {
|
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);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ export class IframeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private replace<T extends Record<string, unknown>>(
|
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,
|
blockClass,
|
||||||
ignoreClass,
|
ignoreClass,
|
||||||
ignoreSelector,
|
ignoreSelector,
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user