From 555d3fca04bf805f3adec1d1043ee313416c5f54 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Mon, 27 Feb 2023 14:30:48 +1100 Subject: [PATCH 1/7] Feat: Add support for replaying :defined pseudo-class of custom elements --- .changeset/fluffy-planes-retire.md | 5 ++ packages/rrweb-snapshot/src/rebuild.ts | 11 +++ packages/rrweb-snapshot/tsconfig.json | 1 + .../events/custom-element-define-class.ts | 88 +++++++++++++++++++ packages/rrweb/test/replayer.test.ts | 16 ++++ 5 files changed, 121 insertions(+) create mode 100644 .changeset/fluffy-planes-retire.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/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index ee01fcde97..2adbf6f2e6 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -142,6 +142,17 @@ 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.tagName.includes('-') && + doc.defaultView && + // 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/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/test/events/custom-element-define-class.ts b/packages/rrweb/test/events/custom-element-define-class.ts new file mode 100644 index 0000000000..f7763cad57 --- /dev/null +++ b/packages/rrweb/test/events/custom-element-define-class.ts @@ -0,0 +1,88 @@ +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: [], + }, + ], + }, + ], + }, + ], + }, + 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'); + }); }); From cef51f3b127e7910d967c8e84becf02552378546 Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Thu, 2 Mar 2023 11:23:54 +1100 Subject: [PATCH 2/7] add isCustom flag to serialized elements Applying Justin's review suggestion --- .changeset/smart-ears-refuse.md | 7 +++++++ packages/rrweb-snapshot/src/rebuild.ts | 5 +++-- packages/rrweb-snapshot/src/snapshot.ts | 8 ++++++++ packages/rrweb-snapshot/src/types.ts | 2 ++ .../test/__snapshots__/integration.test.ts.snap | 1 + packages/rrweb/test/events/custom-element-define-class.ts | 1 + 6 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 .changeset/smart-ears-refuse.md 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 2adbf6f2e6..92c96e3ea5 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -144,8 +144,9 @@ function buildNode( } else { if ( // If the tag name is a custom element name - n.tagName.includes('-') && - doc.defaultView && + 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) ) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 5d348a2108..0a67a1336d 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -783,6 +783,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, @@ -791,6 +798,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 dcbf04399b..cfc6d9142d 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 529a51eeff..eb94240008 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/test/events/custom-element-define-class.ts b/packages/rrweb/test/events/custom-element-define-class.ts index f7763cad57..3f9bd9fa6b 100644 --- a/packages/rrweb/test/events/custom-element-define-class.ts +++ b/packages/rrweb/test/events/custom-element-define-class.ts @@ -71,6 +71,7 @@ const events: eventWithTime[] = [ type: 2, tagName: 'custom-element', childNodes: [], + isCustom: true, }, ], }, From 53029122981f3b06503bb298e2fe9c44dad8f20f Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Sat, 4 Mar 2023 15:47:40 +1100 Subject: [PATCH 3/7] fix code lint error --- .changeset/young-timers-grow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/young-timers-grow.md b/.changeset/young-timers-grow.md index e2f9b6b76a..dbb00cc780 100644 --- a/.changeset/young-timers-grow.md +++ b/.changeset/young-timers-grow.md @@ -1,5 +1,5 @@ --- -"rrweb": bugfix +'rrweb': bugfix --- For users of userTriggeredOnInput setting: also set userTriggered to false on Input attribute modifications; this was previously empty this variant of IncrementalSource.Input From d5dae217211d6221486e2f6490cad0dcb3cdc1ee Mon Sep 17 00:00:00 2001 From: Yun Feng Date: Sun, 5 Mar 2023 11:37:54 +1100 Subject: [PATCH 4/7] add custom element event --- .changeset/young-timers-grow.md | 2 +- packages/rrweb/src/record/index.ts | 11 +++++++ packages/rrweb/src/record/observer.ts | 45 +++++++++++++++++++++++++++ packages/rrweb/src/types.ts | 2 ++ packages/types/src/index.ts | 16 +++++++++- 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/.changeset/young-timers-grow.md b/.changeset/young-timers-grow.md index dbb00cc780..4c710b3d3b 100644 --- a/.changeset/young-timers-grow.md +++ b/.changeset/young-timers-grow.md @@ -1,5 +1,5 @@ --- -'rrweb': bugfix +'rrweb': patch --- For users of userTriggeredOnInput setting: also set userTriggered to false on Input attribute modifications; this was previously empty this variant of IncrementalSource.Input diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index c69ee4b80d..a72cf91d5a 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -512,6 +512,17 @@ function record( }), ); }, + customElementCb: (c) => { + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.CustomElement, + ...c, + }, + }), + ); + }, blockClass, ignoreClass, maskTextClass, diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 2360078445..1a58d3e9ec 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -38,6 +38,7 @@ import { IWindow, SelectionRange, selectionCallback, + customElementCallback, } from '@rrweb/types'; import MutationBuffer from './mutation'; import ProcessedNodeManager from './processed-node-manager'; @@ -1015,6 +1016,41 @@ 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 () => {}; + 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) {} + return original.apply(this, [name, constructor, options]); + }; + }, + ); + return restoreHandler; +} + function mergeHooks(o: observerParam, hooks: hooksParam) { const { mutationCb, @@ -1029,6 +1065,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { canvasMutationCb, fontCb, selectionCb, + customElementCb, } = o; o.mutationCb = (...p: Arguments) => { if (hooks.mutation) { @@ -1102,6 +1139,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { } selectionCb(...p); }; + o.customElementCb = (...c: Arguments) => { + if (hooks.customElement) { + hooks.customElement(...c); + } + customElementCb(...c); + }; } export function initObservers( @@ -1135,6 +1178,7 @@ export function initObservers( // }; const selectionObserver = initSelectionObserver(o); + const customElementObserver = initCustomElementObserver(o); // plugins const pluginHandlers: listenerHandler[] = []; @@ -1158,6 +1202,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 ad35af0039..580b445055 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, @@ -93,6 +94,7 @@ export type observerParam = { styleSheetRuleCb: styleSheetRuleCallback; styleDeclarationCb: styleDeclarationCallback; canvasMutationCb: canvasMutationCallback; + customElementCb: customElementCallback; fontCb: fontCallback; sampling: SamplingStrategy; recordCanvas: boolean; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d2ed8ded5b..c5ff272abf 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 @@ -584,6 +590,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; From 4e8d4ba421e9e5ec68261f3fd78f174becf5bd12 Mon Sep 17 00:00:00 2001 From: Nafees Nehar Date: Tue, 7 Nov 2023 13:30:21 +0530 Subject: [PATCH 5/7] fix: tests (#1348) --- packages/rrweb/src/record/iframe-manager.ts | 1 + packages/types/src/index.ts | 1 + 2 files changed, 2 insertions(+) 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/types/src/index.ts b/packages/types/src/index.ts index 8bca0959cc..d4584847ee 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -268,6 +268,7 @@ export type hooksParam = { canvasMutation?: canvasMutationCallback; font?: fontCallback; selection?: selectionCallback; + customElement?: customElementCallback; }; // https://dom.spec.whatwg.org/#interface-mutationrecord From 8b6f799444dd5d2a35083c16d283b95995db1aa6 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Nov 2023 09:03:24 +0100 Subject: [PATCH 6/7] Update packages/rrweb/src/record/observer.ts --- packages/rrweb/src/record/observer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 6066f2f9ae..3091642db1 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -1197,7 +1197,9 @@ function initCustomElementObserver({ name, }, }); - } catch (e) {} + } catch (e) { + console.warn(`Custom element callback failed for ${name}`); + } return original.apply(this, [name, constructor, options]); }; }, From 8cb6266f8a355bea7ee8f03fb03f3a369cb3d83c Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Tue, 7 Nov 2023 09:05:37 +0100 Subject: [PATCH 7/7] Update packages/rrweb/src/record/observer.ts --- packages/rrweb/src/record/observer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 3091642db1..0aa0f9856b 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -1175,6 +1175,7 @@ function initCustomElementObserver({ 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,