diff --git a/.changeset/tidy-yaks-joke.md b/.changeset/tidy-yaks-joke.md new file mode 100644 index 0000000000..bcd476a850 --- /dev/null +++ b/.changeset/tidy-yaks-joke.md @@ -0,0 +1,5 @@ +--- +'rrweb': patch +--- + +Fix: outdated ':hover' styles can't be removed from iframes or shadow doms diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 396efe76f5..a6591142c0 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -149,6 +149,9 @@ export class Replayer { private mousePos: mouseMovePos | null = null; private touchActive: boolean | null = null; + // Keep the rootNode of the last hovered element. So when hovering a new element, we can remove the last hovered element's :hover style. + private lastHoveredRootNode: Document | ShadowRoot; + // In the fast-forward mode, only the last selection data needs to be applied. private lastSelectionData: selectionData | null = null; @@ -2091,11 +2094,12 @@ export class Replayer { } private hoverElements(el: Element) { - this.iframe.contentDocument + (this.lastHoveredRootNode || this.iframe.contentDocument) ?.querySelectorAll('.\\:hover') .forEach((hoveredEl) => { hoveredEl.classList.remove(':hover'); }); + this.lastHoveredRootNode = el.getRootNode() as Document | ShadowRoot; let currentEl: Element | null = el; while (currentEl) { if (currentEl.classList) { diff --git a/packages/rrweb/test/events/iframe-shadowdom-hover.ts b/packages/rrweb/test/events/iframe-shadowdom-hover.ts new file mode 100644 index 0000000000..0e3671dcbf --- /dev/null +++ b/packages/rrweb/test/events/iframe-shadowdom-hover.ts @@ -0,0 +1,206 @@ +import { EventType, IncrementalSource } 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: 1200, + height: 500, + }, + timestamp: now + 100, + }, + { + type: EventType.FullSnapshot, + 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: 'body', + attributes: {}, + childNodes: [ + { + id: 6, + type: 2, + tagName: 'iframe', + attributes: {}, + childNodes: [], + }, + ], + }, + ], + }, + ], + }, + initialOffset: { top: 0, left: 0 }, + }, + timestamp: now + 200, + }, + // add iframe + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + adds: [ + { + parentId: 6, + nextId: null, + node: { + type: 0, + childNodes: [ + { + type: 1, + name: 'html', + publicId: '', + systemId: '', + rootId: 7, + id: 8, + }, + { + type: 2, + tagName: 'html', + attributes: { lang: 'en' }, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + rootId: 7, + id: 10, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [], + rootId: 7, + id: 13, + isShadow: true, + }, + ], + isShadowHost: true, + rootId: 7, + id: 12, + }, + { + type: 2, + tagName: 'span', + attributes: {}, + childNodes: [], + rootId: 7, + id: 14, + }, + { type: 3, textContent: '\t\n', rootId: 7, id: 15 }, + ], + rootId: 7, + id: 11, + }, + ], + rootId: 7, + id: 9, + }, + ], + id: 7, + }, + }, + ], + removes: [], + texts: [], + attributes: [], + isAttachIframe: true, + }, + timestamp: now + 500, + }, + // hover element in iframe + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.MouseMove, + positions: [ + { + x: 0, + y: 0, + id: 14, + timeOffset: 0, + }, + ], + }, + timestamp: now + 500, + }, + // hover element in shadow dom + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.MouseMove, + positions: [ + { + x: 0, + y: 0, + id: 13, + timeOffset: 0, + }, + ], + }, + timestamp: now + 1000, + }, + // hover element in iframe again + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.MouseMove, + positions: [ + { + x: 0, + y: 0, + id: 14, + timeOffset: 0, + }, + ], + }, + timestamp: now + 1500, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index 080c3fcce1..7756710410 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -21,6 +21,7 @@ import canvasInIframe from './events/canvas-in-iframe'; 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 { ReplayerEvents } from '@rrweb/types'; interface ISuite { @@ -1015,4 +1016,64 @@ describe('replayer', function () { // No errors should be thrown. expect(errorThrown).not.toHaveBeenCalled(); }); + + it('should remove outdated hover styles in iframes and shadow doms', async () => { + await page.evaluate(`events = ${JSON.stringify(hoverInIframeShadowDom)}`); + + await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(events); + replayer.pause(550); + `); + const replayerIframe = await page.$('iframe'); + const contentDocument = await replayerIframe!.contentFrame()!; + const iframe = await contentDocument!.$('iframe'); + expect(iframe).not.toBeNull(); + const docInIFrame = await iframe?.contentFrame(); + expect(docInIFrame).not.toBeNull(); + + // hover element in iframe at 500ms + expect( + await docInIFrame?.evaluate( + () => document.querySelector('span')?.className, + ), + ).toBe(':hover'); + // At this time, there should be no class name in shadow dom + expect( + await docInIFrame?.evaluate(() => { + const shadowRoot = document.querySelector('div')?.shadowRoot; + return (shadowRoot?.childNodes[0] as HTMLElement).className; + }), + ).toBe(''); + + // hover element in shadow dom at 1000ms + await page.evaluate('replayer.pause(1050);'); + // :hover style should be removed from iframe + expect( + await docInIFrame?.evaluate( + () => document.querySelector('span')?.className, + ), + ).toBe(''); + expect( + await docInIFrame?.evaluate(() => { + const shadowRoot = document.querySelector('div')?.shadowRoot; + return (shadowRoot?.childNodes[0] as HTMLElement).className; + }), + ).toBe(':hover'); + + // hover element in iframe at 1500ms again + await page.evaluate('replayer.pause(1550);'); + // hover style should be removed from shadow dom + expect( + await docInIFrame?.evaluate(() => { + const shadowRoot = document.querySelector('div')?.shadowRoot; + return (shadowRoot?.childNodes[0] as HTMLElement).className; + }), + ).toBe(''); + expect( + await docInIFrame?.evaluate( + () => document.querySelector('span')?.className, + ), + ).toBe(':hover'); + }); });