From 8aea5b00a4dfe5a6f59bd2ae72bb624f45e51e81 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Wed, 8 Nov 2023 01:26:13 +1100 Subject: [PATCH] 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 Co-authored-by: Justin Halsall --- .changeset/fluffy-planes-retire.md | 5 ++ .changeset/smart-ears-refuse.md | 7 ++ packages/rrweb-snapshot/src/rebuild.ts | 12 +++ packages/rrweb-snapshot/src/snapshot.ts | 8 ++ packages/rrweb-snapshot/src/types.ts | 2 + .../__snapshots__/integration.test.ts.snap | 1 + packages/rrweb-snapshot/tsconfig.json | 1 + packages/rrweb/src/record/iframe-manager.ts | 1 + packages/rrweb/src/record/index.ts | 11 +++ packages/rrweb/src/record/observer.ts | 48 ++++++++++ packages/rrweb/src/types.ts | 2 + .../events/custom-element-define-class.ts | 89 +++++++++++++++++++ packages/rrweb/test/replayer.test.ts | 16 ++++ packages/types/src/index.ts | 17 +++- 14 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 .changeset/fluffy-planes-retire.md create mode 100644 .changeset/smart-ears-refuse.md create mode 100644 packages/rrweb/test/events/custom-element-define-class.ts diff --git a/.changeset/fluffy-planes-retire.md b/.changeset/fluffy-planes-retire.md new file mode 100644 index 0000000000..41e9601704 --- /dev/null +++ b/.changeset/fluffy-planes-retire.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +Feat: Add support for replaying :defined pseudo-class of custom elements diff --git a/.changeset/smart-ears-refuse.md b/.changeset/smart-ears-refuse.md new file mode 100644 index 0000000000..0aaaabcf0f --- /dev/null +++ b/.changeset/smart-ears-refuse.md @@ -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. diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 5cf52ebd38..c3b15babba 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -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); } /** diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 0963e6cfef..e6b25dc92b 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -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, }; } diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index e573dfc1e0..90d31c171a 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -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 = { diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index a50f27cebe..77beb3be81 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -839,6 +839,7 @@ exports[`shadow DOM integration tests snapshot shadow DOM 1`] = ` \\"isShadow\\": true } ], + \\"isCustom\\": true, \\"id\\": 16, \\"isShadowHost\\": true }, diff --git a/packages/rrweb-snapshot/tsconfig.json b/packages/rrweb-snapshot/tsconfig.json index 879f459ae4..58577aa6a9 100644 --- a/packages/rrweb-snapshot/tsconfig.json +++ b/packages/rrweb-snapshot/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "composite": true, "module": "ESNext", + "target": "ES6", "moduleResolution": "Node", "noImplicitAny": true, "strictNullChecks": true, diff --git a/packages/rrweb/src/record/iframe-manager.ts b/packages/rrweb/src/record/iframe-manager.ts index 377b7bc0ff..26985cc49a 100644 --- a/packages/rrweb/src/record/iframe-manager.ts +++ b/packages/rrweb/src/record/iframe-manager.ts @@ -235,6 +235,7 @@ export class IframeManager { } } } + return false; } private replace>( diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 7308ac6d04..3b4475cfc9 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -525,6 +525,17 @@ function record( }), ); }, + customElementCb: (c) => { + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.CustomElement, + ...c, + }, + }), + ); + }, blockClass, ignoreClass, ignoreSelector, diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index a8dde3319f..0aa0f9856b 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -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) => { if (hooks.mutation) { @@ -1256,6 +1296,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { } selectionCb(...p); }; + o.customElementCb = (...c: Arguments) => { + 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()); }); } diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 1ceb44222b..e815ad753b 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -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; diff --git a/packages/rrweb/test/events/custom-element-define-class.ts b/packages/rrweb/test/events/custom-element-define-class.ts new file mode 100644 index 0000000000..3f9bd9fa6b --- /dev/null +++ b/packages/rrweb/test/events/custom-element-define-class.ts @@ -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; diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index 7756710410..183d2417fb 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -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'); + }); }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e6f6f15cd3..d4584847ee 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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;