From f3d7fa3451f4e1ae7e3bb1f422b5e0a5ab0d8852 Mon Sep 17 00:00:00 2001 From: yz-yu Date: Wed, 10 Feb 2021 21:44:25 +0800 Subject: [PATCH] Impl record iframe (#481) * Impl record iframe * iframe observe * temp: add bundle file to git * update bundle * update with pick * update bundle * fix fragment map remove * feat: add an option to determine whether to pause CSS animation when playback is paused (#428) set pauseAnimation to true by default * fix: elements would lose some states like scroll position because of "virtual parent" optimization (#427) * fix: elements would lose some state like scroll position because of "virtual parent" optimization * refactor: the bugfix code bug: elements would lose some state like scroll position because of "virtual parent" optimization * fix: an error occured at applyMutation(remove nodes part) error message: Uncaught (in promise) DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node * pick fixes * revert ignore file * re-impl iframe record * re-impl iframe replay * code housekeeping * move multi layer dimension calculation to replay side * update test cases * teardown test server * upgrade rrweb-snapshot with iframe load timeout Co-authored-by: Lucky Feng --- package.json | 2 +- scripts/repl.ts | 8 +- src/record/iframe-manager.ts | 36 + src/record/index.ts | 302 +++++---- src/record/mutation.ts | 28 +- src/record/observer.ts | 42 +- src/replay/index.ts | 146 +++- src/replay/styles/inject-style.ts | 2 +- src/types.ts | 14 +- src/utils.ts | 41 +- test/__snapshots__/integration.test.ts.snap | 702 ++++++++++++++++++++ test/events/style-sheet-rule-events.ts | 146 ++-- test/html/frame1.html | 13 + test/html/frame2.html | 18 + test/html/main.html | 25 + test/integration.test.ts | 52 ++ test/replayer.test.ts | 24 +- typings/record/iframe-manager.d.ts | 13 + typings/record/mutation.d.ts | 6 +- typings/record/observer.d.ts | 2 +- typings/replay/index.d.ts | 4 + typings/types.d.ts | 13 +- typings/utils.d.ts | 13 +- yarn.lock | 8 +- 24 files changed, 1414 insertions(+), 246 deletions(-) create mode 100644 src/record/iframe-manager.ts create mode 100644 test/html/frame1.html create mode 100644 test/html/frame2.html create mode 100644 test/html/main.html create mode 100644 typings/record/iframe-manager.d.ts diff --git a/package.json b/package.json index bb860a7ad8..ff002950d7 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,6 @@ "@xstate/fsm": "^1.4.0", "fflate": "^0.4.4", "mitt": "^1.1.3", - "rrweb-snapshot": "^1.0.4" + "rrweb-snapshot": "^1.0.6" } } diff --git a/scripts/repl.ts b/scripts/repl.ts index 417552540a..1b933079a5 100644 --- a/scripts/repl.ts +++ b/scripts/repl.ts @@ -81,7 +81,11 @@ function getCode(): string { width: 1600, height: 900, }, - args: ['--start-maximized', '--ignore-certificate-errors'], + args: [ + '--start-maximized', + '--ignore-certificate-errors', + '--no-sandbox', + ], }); const page = await browser.newPage(); await page.goto(url, { @@ -128,7 +132,7 @@ function getCode(): string { width: 1600, height: 900, }, - args: ['--start-maximized'], + args: ['--start-maximized', '--no-sandbox'], }); const page = await browser.newPage(); await page.goto('about:blank'); diff --git a/src/record/iframe-manager.ts b/src/record/iframe-manager.ts new file mode 100644 index 0000000000..b5eefc5987 --- /dev/null +++ b/src/record/iframe-manager.ts @@ -0,0 +1,36 @@ +import { serializedNodeWithId, INode } from 'rrweb-snapshot'; +import { mutationCallBack } from '../types'; + +export class IframeManager { + private iframes: WeakMap = new WeakMap(); + private mutationCb: mutationCallBack; + private loadListener?: (iframeEl: HTMLIFrameElement) => unknown; + + constructor(options: { mutationCb: mutationCallBack }) { + this.mutationCb = options.mutationCb; + } + + public addIframe(iframeEl: HTMLIFrameElement) { + this.iframes.set(iframeEl, true); + } + + public addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown) { + this.loadListener = cb; + } + + public attachIframe(iframeEl: INode, childSn: serializedNodeWithId) { + this.mutationCb({ + adds: [ + { + parentId: iframeEl.__sn.id, + nextId: null, + node: childSn, + }, + ], + removes: [], + texts: [], + attributes: [], + }); + this.loadListener?.((iframeEl as unknown) as HTMLIFrameElement); + } +} diff --git a/src/record/index.ts b/src/record/index.ts index 62e3adf85b..70333c0747 100644 --- a/src/record/index.ts +++ b/src/record/index.ts @@ -1,11 +1,12 @@ import { snapshot, MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot'; -import { initObservers, mutationBuffer } from './observer'; +import { initObservers, mutationBuffers } from './observer'; import { mirror, on, getWindowWidth, getWindowHeight, polyfill, + isIframeINode, } from '../utils'; import { EventType, @@ -16,6 +17,7 @@ import { listenerHandler, LogRecordOptions, } from '../types'; +import { IframeManager } from './iframe-manager'; function wrapEvent(e: event): eventWithTime { return { @@ -138,7 +140,7 @@ function record( let incrementalSnapshotCount = 0; wrappedEmit = (e: eventWithTime, isCheckout?: boolean) => { if ( - mutationBuffer.isFrozen() && + mutationBuffers[0]?.isFrozen() && e.type !== EventType.FullSnapshot && !( e.type === EventType.IncrementalSnapshot && @@ -147,7 +149,7 @@ function record( ) { // we've got a user initiated event so first we need to apply // all DOM changes that have been buffering during paused state - mutationBuffer.unfreeze(); + mutationBuffers.forEach((buf) => buf.unfreeze()); } emit(((packFn ? packFn(e) : e) as unknown) as T, isCheckout); @@ -167,6 +169,19 @@ function record( } }; + const iframeManager = new IframeManager({ + mutationCb: (m) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + ...m, + }, + }), + ), + }); + function takeFullSnapshot(isCheckout = false) { wrappedEmit( wrapEvent({ @@ -180,8 +195,7 @@ function record( isCheckout, ); - let wasFrozen = mutationBuffer.isFrozen(); - mutationBuffer.lock(); // don't allow any mirror modifications during snapshotting + mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting const [node, idNodeMap] = snapshot(document, { blockClass, blockSelector, @@ -189,6 +203,14 @@ function record( maskAllInputs: maskInputOptions, slimDOM: slimDOMOptions, recordCanvas, + onSerialize: (n) => { + if (isIframeINode(n)) { + iframeManager.addIframe(n); + } + }, + onIframeLoad: (iframe, childSn) => { + iframeManager.attachIframe(iframe, childSn); + }, }); if (!node) { @@ -220,7 +242,7 @@ function record( }, }), ); - mutationBuffer.unlock(); // generate & emit any mutations that happened during snapshotting, as can now apply against the newly built mirror + mutationBuffers.forEach((buf) => buf.unlock()); // generate & emit any mutations that happened during snapshotting, as can now apply against the newly built mirror } try { @@ -235,138 +257,146 @@ function record( ); }), ); - const init = () => { - takeFullSnapshot(); - handlers.push( - initObservers( - { - mutationCb: (m) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.Mutation, - ...m, - }, - }), - ), - mousemoveCb: (positions, source) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source, - positions, - }, - }), - ), - mouseInteractionCb: (d) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.MouseInteraction, - ...d, - }, - }), - ), - scrollCb: (p) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.Scroll, - ...p, - }, - }), - ), - viewportResizeCb: (d) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.ViewportResize, - ...d, - }, - }), - ), - inputCb: (v) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.Input, - ...v, - }, - }), - ), - mediaInteractionCb: (p) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.MediaInteraction, - ...p, - }, - }), - ), - styleSheetRuleCb: (r) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.StyleSheetRule, - ...r, - }, - }), - ), - canvasMutationCb: (p) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.CanvasMutation, - ...p, - }, - }), - ), - fontCb: (p) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.Font, - ...p, - }, - }), - ), - logCb: (p) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.Log, - ...p, - }, - }), - ), - blockClass, - blockSelector, - ignoreClass, - maskInputOptions, - maskInputFn, - inlineStylesheet, - sampling, - recordCanvas, - collectFonts, - slimDOMOptions, - logOptions, - }, - hooks, - ), + const observe = (doc: Document) => { + return initObservers( + { + mutationCb: (m) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + ...m, + }, + }), + ), + mousemoveCb: (positions, source) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source, + positions, + }, + }), + ), + mouseInteractionCb: (d) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.MouseInteraction, + ...d, + }, + }), + ), + scrollCb: (p) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Scroll, + ...p, + }, + }), + ), + viewportResizeCb: (d) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.ViewportResize, + ...d, + }, + }), + ), + inputCb: (v) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Input, + ...v, + }, + }), + ), + mediaInteractionCb: (p) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.MediaInteraction, + ...p, + }, + }), + ), + styleSheetRuleCb: (r) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + ...r, + }, + }), + ), + canvasMutationCb: (p) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.CanvasMutation, + ...p, + }, + }), + ), + fontCb: (p) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Font, + ...p, + }, + }), + ), + logCb: (p) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Log, + ...p, + }, + }), + ), + blockClass, + ignoreClass, + maskInputOptions, + inlineStylesheet, + sampling, + recordCanvas, + collectFonts, + doc, + maskInputFn, + logOptions, + blockSelector, + slimDOMOptions, + iframeManager, + }, + hooks, ); }; + + iframeManager.addLoadListener((iframeEl) => { + handlers.push(observe(iframeEl.contentDocument!)); + }); + + const init = () => { + takeFullSnapshot(); + handlers.push(observe(document)); + }; if ( document.readyState === 'interactive' || document.readyState === 'complete' @@ -414,7 +444,7 @@ record.addCustomEvent = (tag: string, payload: T) => { }; record.freezePage = () => { - mutationBuffer.freeze(); + mutationBuffers.forEach((buf) => buf.freeze()); }; export default record; diff --git a/src/record/mutation.ts b/src/record/mutation.ts index bc89342fca..bcdf7591ea 100644 --- a/src/record/mutation.ts +++ b/src/record/mutation.ts @@ -5,6 +5,7 @@ import { MaskInputOptions, SlimDOMOptions, IGNORED_NODE, + NodeType, } from 'rrweb-snapshot'; import { mutationRecord, @@ -16,6 +17,7 @@ import { addedNodeMutation, } from '../types'; import { mirror, isBlocked, isAncestorRemoved, isIgnored } from '../utils'; +import { IframeManager } from './iframe-manager'; type DoubleLinkedListNode = { previous: DoubleLinkedListNode | null; @@ -149,6 +151,9 @@ export default class MutationBuffer { private maskInputOptions: MaskInputOptions; private recordCanvas: boolean; private slimDOMOptions: SlimDOMOptions; + private doc: Document; + + private iframeManager: IframeManager; public init( cb: mutationCallBack, @@ -158,6 +163,8 @@ export default class MutationBuffer { maskInputOptions: MaskInputOptions, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, + doc: Document, + iframeManager: IframeManager, ) { this.blockClass = blockClass; this.blockSelector = blockSelector; @@ -166,6 +173,8 @@ export default class MutationBuffer { this.recordCanvas = recordCanvas; this.slimDOMOptions = slimDOMOptions; this.emissionCallback = cb; + this.doc = doc; + this.iframeManager = iframeManager; } public freeze() { @@ -223,7 +232,7 @@ export default class MutationBuffer { return nextId; }; const pushAdd = (n: Node) => { - if (!n.parentNode || !document.contains(n)) { + if (!n.parentNode || !this.doc.contains(n)) { return; } const parentId = mirror.getId((n.parentNode as Node) as INode); @@ -232,7 +241,7 @@ export default class MutationBuffer { return addList.addNode(n); } let sn = serializeNodeWithId(n, { - doc: document, + doc: this.doc, map: mirror.map, blockClass: this.blockClass, blockSelector: this.blockSelector, @@ -241,6 +250,19 @@ export default class MutationBuffer { maskInputOptions: this.maskInputOptions, slimDOMOptions: this.slimDOMOptions, recordCanvas: this.recordCanvas, + onSerialize: (currentN) => { + if ( + currentN.__sn.type === NodeType.Element && + currentN.__sn.tagName === 'iframe' + ) { + this.iframeManager.addIframe( + (currentN as unknown) as HTMLIFrameElement, + ); + } + }, + onIframeLoad: (iframe, childSn) => { + this.iframeManager.attachIframe(iframe, childSn); + }, }); if (sn) { adds.push({ @@ -391,7 +413,7 @@ export default class MutationBuffer { } // overwrite attribute if the mutations was triggered in same time item.attributes[m.attributeName!] = transformAttribute( - document, + this.doc, m.attributeName!, value!, ); diff --git a/src/record/observer.ts b/src/record/observer.ts index 13dea21cd0..d71a8b0fd1 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -43,6 +43,7 @@ import { } from '../types'; import MutationBuffer from './mutation'; import { stringify } from './stringify'; +import { IframeManager } from './iframe-manager'; type WindowWithStoredMutationObserver = Window & { __rrMutationObserver?: MutationObserver; @@ -53,17 +54,21 @@ type WindowWithAngularZone = Window & { }; }; -export const mutationBuffer = new MutationBuffer(); +export const mutationBuffers: MutationBuffer[] = []; function initMutationObserver( cb: mutationCallBack, + doc: Document, blockClass: blockClass, blockSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, + iframeManager: IframeManager, ): MutationObserver { + const mutationBuffer = new MutationBuffer(); + mutationBuffers.push(mutationBuffer); // see mutation.ts for details mutationBuffer.init( cb, @@ -73,8 +78,10 @@ function initMutationObserver( maskInputOptions, recordCanvas, slimDOMOptions, + doc, + iframeManager, ); - let mutationBufferCtor = + let mutationObserverCtor = window.MutationObserver || /** * Some websites may disable MutationObserver by removing it from the window object. @@ -94,15 +101,15 @@ function initMutationObserver( angularZoneSymbol ] ) { - mutationBufferCtor = ((window as unknown) as Record< + mutationObserverCtor = ((window as unknown) as Record< string, typeof MutationObserver >)[angularZoneSymbol]; } - const observer = new mutationBufferCtor( + const observer = new mutationObserverCtor( mutationBuffer.processMutations.bind(mutationBuffer), ); - observer.observe(document, { + observer.observe(doc, { attributes: true, attributeOldValue: true, characterData: true, @@ -116,6 +123,7 @@ function initMutationObserver( function initMoveObserver( cb: mousemoveCallBack, sampling: SamplingStrategy, + doc: Document, ): listenerHandler { if (sampling.mousemove === false) { return () => {}; @@ -161,8 +169,8 @@ function initMoveObserver( }, ); const handlers = [ - on('mousemove', updatePosition), - on('touchmove', updatePosition), + on('mousemove', updatePosition, doc), + on('touchmove', updatePosition, doc), ]; return () => { handlers.forEach((h) => h()); @@ -171,6 +179,7 @@ function initMoveObserver( function initMouseInteractionObserver( cb: mouseInteractionCallBack, + doc: Document, blockClass: blockClass, sampling: SamplingStrategy, ): listenerHandler { @@ -211,7 +220,7 @@ function initMouseInteractionObserver( .forEach((eventKey: keyof typeof MouseInteractions) => { const eventName = eventKey.toLowerCase(); const handler = getHandler(eventKey); - handlers.push(on(eventName, handler)); + handlers.push(on(eventName, handler, doc)); }); return () => { handlers.forEach((h) => h()); @@ -220,6 +229,7 @@ function initMouseInteractionObserver( function initScrollObserver( cb: scrollCallback, + doc: Document, blockClass: blockClass, sampling: SamplingStrategy, ): listenerHandler { @@ -228,8 +238,8 @@ function initScrollObserver( return; } const id = mirror.getId(evt.target as INode); - if (evt.target === document) { - const scrollEl = (document.scrollingElement || document.documentElement)!; + if (evt.target === doc) { + const scrollEl = (doc.scrollingElement || doc.documentElement)!; cb({ id, x: scrollEl.scrollLeft, @@ -270,6 +280,7 @@ export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT']; const lastInputValueMap: WeakMap = new WeakMap(); function initInputObserver( cb: inputCallback, + doc: Document, blockClass: blockClass, ignoreClass: string, maskInputOptions: MaskInputOptions, @@ -314,7 +325,7 @@ function initInputObserver( // the other radios with the same name attribute will be unchecked. const name: string | undefined = (target as HTMLInputElement).name; if (type === 'radio' && name && isChecked) { - document + doc .querySelectorAll(`input[type="radio"][name="${name}"]`) .forEach((el) => { if (el !== target) { @@ -344,7 +355,7 @@ function initInputObserver( const events = sampling.input === 'last' ? ['change'] : ['input', 'change']; const handlers: Array< listenerHandler | hookResetter - > = events.map((eventName) => on(eventName, eventHandler)); + > = events.map((eventName) => on(eventName, eventHandler, doc)); const propertyDescriptor = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, 'value', @@ -727,27 +738,32 @@ export function initObservers( mergeHooks(o, hooks); const mutationObserver = initMutationObserver( o.mutationCb, + o.doc, o.blockClass, o.blockSelector, o.inlineStylesheet, o.maskInputOptions, o.recordCanvas, o.slimDOMOptions, + o.iframeManager, ); - const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling); + const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling, o.doc); const mouseInteractionHandler = initMouseInteractionObserver( o.mouseInteractionCb, + o.doc, o.blockClass, o.sampling, ); const scrollHandler = initScrollObserver( o.scrollCb, + o.doc, o.blockClass, o.sampling, ); const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb); const inputHandler = initInputObserver( o.inputCb, + o.doc, o.blockClass, o.ignoreClass, o.maskInputOptions, diff --git a/src/replay/index.ts b/src/replay/index.ts index 25f644cebe..1a79d5f982 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -11,7 +11,7 @@ import { MouseInteractions, playerConfig, playerMetaData, - viewportResizeDimention, + viewportResizeDimension, missingNodeMap, addedNodeMutation, missingNode, @@ -37,6 +37,9 @@ import { TreeIndex, queueToResolveTrees, iterateResolveTree, + AppendedIframe, + isIframeINode, + getBaseDimension, } from '../utils'; import getInjectStyleRules from './styles/inject-style'; import './styles/style.css'; @@ -49,6 +52,7 @@ const SKIP_TIME_INTERVAL = 5 * 1000; const mitt = (mittProxy as any).default || mittProxy; const REPLAY_CONSOLE_PREFIX = '[replayer]'; +const SCROLL_ATTRIBUTE_NAME = '__rrweb_scroll__'; const defaultMouseTailConfig = { duration: 500, @@ -111,6 +115,8 @@ export class Replayer { private imageMap: Map = new Map(); + private newDocumentQueue: addedNodeMutation[] = []; + constructor( events: Array, config?: Partial, @@ -233,6 +239,10 @@ export class Replayer { } if (firstFullsnapshot) { setTimeout(() => { + // when something has been played, there is no need to rebuild poster + if (this.timer.timeOffset > 0) { + return; + } this.rebuildFullSnapshot( firstFullsnapshot as fullSnapshotEvent & { timestamp: number }, ); @@ -406,7 +416,7 @@ export class Replayer { } } - private handleResize(dimension: viewportResizeDimention) { + private handleResize(dimension: viewportResizeDimension) { this.iframe.style.display = 'inherit'; for (const el of [this.mouseTail, this.iframe]) { if (!el) { @@ -534,11 +544,44 @@ export class Replayer { ); } this.legacy_missingNodeRetryMap = {}; + const collected: AppendedIframe[] = []; mirror.map = rebuild(event.data.node, { doc: this.iframe.contentDocument, + afterAppend: (builtNode) => { + this.collectIframeAndAttachDocument(collected, builtNode); + }, })[1]; - const styleEl = document.createElement('style'); + for (const { mutationInQueue, builtNode } of collected) { + this.attachDocumentToIframe(mutationInQueue, builtNode); + this.newDocumentQueue = this.newDocumentQueue.filter( + (m) => m !== mutationInQueue, + ); + if (builtNode.contentDocument) { + const { documentElement, head } = builtNode.contentDocument; + this.insertStyleRules(documentElement, head); + } + } const { documentElement, head } = this.iframe.contentDocument; + this.insertStyleRules(documentElement, head); + if (!this.service.state.matches('playing')) { + this.iframe.contentDocument + .getElementsByTagName('html')[0] + .classList.add('rrweb-paused'); + } + this.emitter.emit(ReplayerEvents.FullsnapshotRebuilded, event); + if (!isSync) { + this.waitForStylesheetLoad(); + } + if (this.config.UNSAFE_replayCanvas) { + this.preloadAllImages(); + } + } + + private insertStyleRules( + documentElement: HTMLElement, + head: HTMLHeadElement, + ) { + const styleEl = document.createElement('style'); documentElement!.insertBefore(styleEl, head); const injectStylesRules = getInjectStyleRules( this.config.blockClass, @@ -548,20 +591,48 @@ export class Replayer { 'html.rrweb-paused * { animation-play-state: paused !important; }', ); } - if (!this.service.state.matches('playing')) { - this.iframe.contentDocument - .getElementsByTagName('html')[0] - .classList.add('rrweb-paused'); - } for (let idx = 0; idx < injectStylesRules.length; idx++) { (styleEl.sheet! as CSSStyleSheet).insertRule(injectStylesRules[idx], idx); } - this.emitter.emit(ReplayerEvents.FullsnapshotRebuilded, event); - if (!isSync) { - this.waitForStylesheetLoad(); + } + + private attachDocumentToIframe( + mutation: addedNodeMutation, + iframeEl: HTMLIFrameElement, + ) { + const collected: AppendedIframe[] = []; + buildNodeWithSN(mutation.node, { + doc: iframeEl.contentDocument!, + map: mirror.map, + hackCss: true, + skipChild: false, + afterAppend: (builtNode) => { + this.collectIframeAndAttachDocument(collected, builtNode); + }, + }); + for (const { mutationInQueue, builtNode } of collected) { + this.attachDocumentToIframe(mutationInQueue, builtNode); + this.newDocumentQueue = this.newDocumentQueue.filter( + (m) => m !== mutationInQueue, + ); + if (builtNode.contentDocument) { + const { documentElement, head } = builtNode.contentDocument; + this.insertStyleRules(documentElement, head); + } } - if (this.config.UNSAFE_replayCanvas) { - this.preloadAllImages(); + } + + private collectIframeAndAttachDocument( + collected: AppendedIframe[], + builtNode: INode, + ) { + if (isIframeINode(builtNode)) { + const mutationInQueue = this.newDocumentQueue.find( + (m) => m.parentId === builtNode.__sn.id, + ); + if (mutationInQueue) { + collected.push({ mutationInQueue, builtNode }); + } } } @@ -1021,6 +1092,10 @@ export class Replayer { } let parent = mirror.getNode(mutation.parentId); if (!parent) { + if (mutation.node.type === NodeType.Document) { + // is newly added document, maybe the document node of an iframe + return this.newDocumentQueue.push(mutation); + } return queue.push(mutation); } @@ -1059,12 +1134,23 @@ export class Replayer { return queue.push(mutation); } + if (mutation.node.rootId && !mirror.getNode(mutation.node.rootId)) { + return; + } + + const targetDoc = mutation.node.rootId + ? mirror.getNode(mutation.node.rootId) + : this.iframe.contentDocument; + if (isIframeINode(parent)) { + this.attachDocumentToIframe(mutation, parent); + return; + } const target = buildNodeWithSN(mutation.node, { - doc: this.iframe.contentDocument, + doc: targetDoc as Document, map: mirror.map, skipChild: true, hackCss: true, - }) as Node; + }) as INode; // legacy data, we should not have -1 siblings any more if (mutation.previousId === -1 || mutation.nextId === -1) { @@ -1087,6 +1173,22 @@ export class Replayer { parent.appendChild(target); } + if (isIframeINode(target)) { + const mutationInQueue = this.newDocumentQueue.find( + (m) => m.parentId === target.__sn.id, + ); + if (mutationInQueue) { + this.attachDocumentToIframe(mutationInQueue, target); + this.newDocumentQueue = this.newDocumentQueue.filter( + (m) => m !== mutationInQueue, + ); + } + if (target.contentDocument) { + const { documentElement, head } = target.contentDocument; + this.insertStyleRules(documentElement, head); + } + } + if (mutation.previousId || mutation.nextId) { this.legacy_resolveMissingNode( legacy_missingNodeMap, @@ -1228,7 +1330,7 @@ export class Replayer { * generate a console log replayer which implement the interface ReplayLogger */ private getConsoleLogger(): ReplayLogger { - const rrwebOriginal = '__rrweb_original__'; + const rrwebOriginal = SCROLL_ATTRIBUTE_NAME; const replayLogger: ReplayLogger = {}; for (const level of this.config.logConfig.level!) if (level === 'trace') @@ -1284,14 +1386,18 @@ export class Replayer { } private moveAndHover(d: incrementalData, x: number, y: number, id: number) { - this.mouse.style.left = `${x}px`; - this.mouse.style.top = `${y}px`; - this.drawMouseTail({ x, y }); - const target = mirror.getNode(id); if (!target) { return this.debugNodeNotFound(d, id); } + + const base = getBaseDimension(target); + const _x = x + base.x; + const _y = y + base.y; + + this.mouse.style.left = `${_x}px`; + this.mouse.style.top = `${_y}px`; + this.drawMouseTail({ x: _x, y: _y }); this.hoverElements((target as Node) as Element); } diff --git a/src/replay/styles/inject-style.ts b/src/replay/styles/inject-style.ts index a0ac939c34..b8b7f09f75 100644 --- a/src/replay/styles/inject-style.ts +++ b/src/replay/styles/inject-style.ts @@ -1,5 +1,5 @@ const rules: (blockClass: string) => string[] = (blockClass: string) => [ - `iframe, .${blockClass} { background: #ccc }`, + `.${blockClass} { background: #ccc }`, 'noscript { display: none !important; }', ]; diff --git a/src/types.ts b/src/types.ts index 02f5ce1c0d..907aea78d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,7 @@ import { } from 'rrweb-snapshot'; import { PackFn, UnpackFn } from './packer/base'; import { FontFaceDescriptors } from 'css-font-loading-module'; +import { IframeManager } from './record/iframe-manager'; export enum EventType { DomContentLoaded, @@ -101,7 +102,7 @@ export type scrollData = { export type viewportResizeData = { source: IncrementalSource.ViewportResize; -} & viewportResizeDimention; +} & viewportResizeDimension; export type inputData = { source: IncrementalSource.Input; @@ -224,6 +225,8 @@ export type observerParam = { recordCanvas: boolean; collectFonts: boolean; slimDOMOptions: SlimDOMOptions; + doc: Document; + iframeManager: IframeManager; }; export type hooksParam = { @@ -430,12 +433,12 @@ export type fontCallback = (p: fontParam) => void; export type logCallback = (p: LogParam) => void; -export type viewportResizeDimention = { +export type viewportResizeDimension = { width: number; height: number; }; -export type viewportResizeCallback = (d: viewportResizeDimention) => void; +export type viewportResizeCallback = (d: viewportResizeDimension) => void; export type inputValue = { text: string; @@ -456,6 +459,11 @@ export type mediaInteractionParam = { export type mediaInteractionCallback = (p: mediaInteractionParam) => void; +export type DocumentDimension = { + x: number; + y: number; +}; + export type Mirror = { map: idNodeMap; getId: (n: INode) => number; diff --git a/src/utils.ts b/src/utils.ts index 5b70dd9da6..9f3c8551fb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,8 +14,14 @@ import { mutationData, scrollData, inputData, + DocumentDimension, } from './types'; -import { INode, IGNORED_NODE } from 'rrweb-snapshot'; +import { + INode, + IGNORED_NODE, + serializedNodeWithId, + NodeType, +} from 'rrweb-snapshot'; export function on( type: string, @@ -527,3 +533,36 @@ export function iterateResolveTree( iterateResolveTree(tree.children[i], cb); } } + +type HTMLIFrameINode = HTMLIFrameElement & { + __sn: serializedNodeWithId; +}; +export type AppendedIframe = { + mutationInQueue: addedNodeMutation; + builtNode: HTMLIFrameINode; +}; + +export function isIframeINode(node: INode): node is HTMLIFrameINode { + // node can be document fragment when using the virtual parent feature + if (!node.__sn) { + return false; + } + return node.__sn.type === NodeType.Element && node.__sn.tagName === 'iframe'; +} + +export function getBaseDimension(node: Node): DocumentDimension { + const frameElement = node.ownerDocument?.defaultView?.frameElement; + if (!frameElement) { + return { + x: 0, + y: 0, + }; + } + + const frameDimension = frameElement.getBoundingClientRect(); + const frameBaseDimension = getBaseDimension(frameElement); + return { + x: frameDimension.x + frameBaseDimension.x, + y: frameDimension.y + frameBaseDimension.y, + }; +} diff --git a/test/__snapshots__/integration.test.ts.snap b/test/__snapshots__/integration.test.ts.snap index ab6098d2cb..f205fe9562 100644 --- a/test/__snapshots__/integration.test.ts.snap +++ b/test/__snapshots__/integration.test.ts.snap @@ -1727,6 +1727,708 @@ exports[`frozen 1`] = ` ]" `; +exports[`iframe 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 19, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 20, + \\"id\\": 22 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 20, + \\"id\\": 23 + } + ], + \\"rootId\\": 20, + \\"id\\": 21 + } + ], + \\"id\\": 20 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [] + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Main\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"style\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n iframe {\\\\n width: 500px;\\\\n height: 500px;\\\\n }\\\\n \\", + \\"isStyle\\": true, + \\"id\\": 14 + } + ], + \\"id\\": 13 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 16 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 18 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"one\\" + }, + \\"childNodes\\": [], + \\"id\\": 19 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 24 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 26 + } + ], + \\"id\\": 25 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 27 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 29 + } + ], + \\"id\\": 28 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\\\n\\", + \\"id\\": 30 + } + ], + \\"id\\": 17 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 17, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"two\\" + }, + \\"childNodes\\": [], + \\"id\\": 31 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 47, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 48, + \\"id\\": 50 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 48, + \\"id\\": 51 + } + ], + \\"rootId\\": 48, + \\"id\\": 49 + } + ], + \\"id\\": 48 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 53, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"rootId\\": 54, + \\"id\\": 55 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 54, + \\"id\\": 58 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 54, + \\"id\\": 59 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 54, + \\"id\\": 60 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 54, + \\"id\\": 61 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 54, + \\"id\\": 62 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Frame 2\\", + \\"rootId\\": 54, + \\"id\\": 64 + } + ], + \\"rootId\\": 54, + \\"id\\": 63 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 54, + \\"id\\": 65 + } + ], + \\"rootId\\": 54, + \\"id\\": 57 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 54, + \\"id\\": 66 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n frame 2\\\\n \\\\n \\", + \\"rootId\\": 54, + \\"id\\": 68 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"rootId\\": 54, + \\"id\\": 70 + } + ], + \\"rootId\\": 54, + \\"id\\": 69 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\\\n\\", + \\"rootId\\": 54, + \\"id\\": 71 + } + ], + \\"rootId\\": 54, + \\"id\\": 67 + } + ], + \\"rootId\\": 54, + \\"id\\": 56 + } + ], + \\"id\\": 54 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 31, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"rootId\\": 32, + \\"id\\": 33 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 32, + \\"id\\": 36 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 32, + \\"id\\": 37 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 32, + \\"id\\": 38 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 32, + \\"id\\": 39 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 32, + \\"id\\": 40 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Frame 1\\", + \\"rootId\\": 32, + \\"id\\": 42 + } + ], + \\"rootId\\": 32, + \\"id\\": 41 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 32, + \\"id\\": 43 + } + ], + \\"rootId\\": 32, + \\"id\\": 35 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 32, + \\"id\\": 44 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n frame 1\\\\n \\", + \\"rootId\\": 32, + \\"id\\": 46 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"three\\", + \\"frameborder\\": \\"0\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 32, + \\"id\\": 47 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 32, + \\"id\\": 52 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"four\\", + \\"frameborder\\": \\"0\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 32, + \\"id\\": 53 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n\\\\n\\", + \\"rootId\\": 32, + \\"id\\": 72 + } + ], + \\"rootId\\": 32, + \\"id\\": 45 + } + ], + \\"rootId\\": 32, + \\"id\\": 34 + } + ], + \\"id\\": 32 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 73, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 74, + \\"id\\": 76 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 74, + \\"id\\": 77 + } + ], + \\"rootId\\": 74, + \\"id\\": 75 + } + ], + \\"id\\": 74 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 67, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"five\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 54, + \\"id\\": 73 + } + } + ] + } + } +]" +`; + exports[`ignore 1`] = ` "[ { diff --git a/test/events/style-sheet-rule-events.ts b/test/events/style-sheet-rule-events.ts index e0e9682e42..5740f50215 100644 --- a/test/events/style-sheet-rule-events.ts +++ b/test/events/style-sheet-rule-events.ts @@ -1,8 +1,4 @@ -import { - EventType, - eventWithTime, - IncrementalSource -} from '../../src/types'; +import { EventType, eventWithTime, IncrementalSource } from '../../src/types'; const now = Date.now(); const events: eventWithTime[] = [ @@ -27,68 +23,124 @@ const events: eventWithTime[] = [ }, // 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": [ + 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": 101, "type": 2, "tagName": "style", "attributes": { "data-jss": "", "data-meta": "sk, Unthemed, Static" }, "childNodes": [{ "id": 102, "type": 3, "isStyle": true, "textContent": "\n.c01x {\n opacity: 1;\n transform: translateX(0);\n}\n" }] + id: 4, + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [ + { + id: 101, + type: 2, + tagName: 'style', + attributes: { + 'data-jss': '', + 'data-meta': 'sk, Unthemed, Static', + }, + childNodes: [ + { + id: 102, + type: 3, + isStyle: true, + textContent: + '\n.c01x {\n opacity: 1;\n transform: translateX(0);\n}\n', + }, + ], + }, + { + id: 105, + type: 2, + tagName: 'style', + attributes: { + _cssText: + '.css-1uxxxx3 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }.css-1c9xxxx { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }.css-lsxxx { padding-left: 4rem; }', + 'data-emotion': 'css', + }, + childNodes: [ + { id: 106, type: 3, isStyle: true, textContent: '' }, + ], + }, + ], }, { - "id": 105, "type": 2, "tagName": "style", "attributes": - { "_cssText": ".css-1uxxxx3 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }.css-1c9xxxx { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }.css-lsxxx { padding-left: 4rem; }", "data-emotion": "css" }, "childNodes": [{ "id": 106, "type": 3, "isStyle": true, "textContent": "" }] - }] - }, { - "id": 107, "type": 2, "tagName": "body", "attributes": {}, "childNodes": [] - }] - }] - }, "initialOffset": { "top": 0, "left": 0 } + id: 107, + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [], + }, + ], + }, + ], + }, + initialOffset: { top: 0, left: 0 }, }, - "type": EventType.FullSnapshot, - "timestamp": now + 100 + type: EventType.FullSnapshot, + timestamp: now + 100, }, // mutation that adds stylesheet { - "data": { - "adds": [ + data: { + adds: [ { - "node": { - "id": 255, "type": 2, "tagName": "style", "attributes": { "data-jss": "", "data-meta": "Col, Themed, Dynamic" }, "childNodes": [] + node: { + id: 255, + type: 2, + tagName: 'style', + attributes: { 'data-jss': '', 'data-meta': 'Col, Themed, Dynamic' }, + childNodes: [], }, - "nextId": 101, - "parentId": 4 + nextId: 101, + parentId: 4, }, { - "node": { - "id": 256, "type": 3, "isStyle": true, "textContent": "\n.c011xx {\n padding: 1.3125rem;\n flex: none;\n width: 100%;\n}\n" + node: { + id: 256, + type: 3, + isStyle: true, + textContent: + '\n.c011xx {\n padding: 1.3125rem;\n flex: none;\n width: 100%;\n}\n', }, - "nextId": null, - "parentId": 255 + nextId: null, + parentId: 255, }, ], - "texts": [], - "source": IncrementalSource.Mutation, - "removes": [], - "attributes": [] + texts: [], + source: IncrementalSource.Mutation, + removes: [], + attributes: [], }, - "type": EventType.IncrementalSnapshot, - "timestamp": now + 500 + type: EventType.IncrementalSnapshot, + timestamp: now + 500, }, // adds StyleSheetRule { - "data": { - "id": 105, "adds": [ + data: { + id: 105, + adds: [ { - "rule": ".css-1fbxx79{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;min-width:60rem;min-height:100vh;}", - "index": 2 - } + rule: + '.css-1fbxx79{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;min-width:60rem;min-height:100vh;}', + index: 2, + }, ], - "source": IncrementalSource.StyleSheetRule + source: IncrementalSource.StyleSheetRule, }, - "type": EventType.IncrementalSnapshot, - "timestamp": now + 1000 - } + type: EventType.IncrementalSnapshot, + timestamp: now + 1000, + }, ]; -export default events; \ No newline at end of file +export default events; diff --git a/test/html/frame1.html b/test/html/frame1.html new file mode 100644 index 0000000000..8810af4608 --- /dev/null +++ b/test/html/frame1.html @@ -0,0 +1,13 @@ + + + + + + Frame 1 + + + frame 1 + + + + diff --git a/test/html/frame2.html b/test/html/frame2.html new file mode 100644 index 0000000000..6344438ca2 --- /dev/null +++ b/test/html/frame2.html @@ -0,0 +1,18 @@ + + + + + + Frame 2 + + + frame 2 + + + diff --git a/test/html/main.html b/test/html/main.html new file mode 100644 index 0000000000..a37d9bea35 --- /dev/null +++ b/test/html/main.html @@ -0,0 +1,25 @@ + + + + + + Main + + + + + + + diff --git a/test/integration.test.ts b/test/integration.test.ts index 767dc413c0..24ece5c096 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,5 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as http from 'http'; +import * as url from 'url'; import * as puppeteer from 'puppeteer'; import { assertSnapshot, launchPuppeteer } from './utils'; import { Suite } from 'mocha'; @@ -8,10 +10,48 @@ import { recordOptions, eventWithTime, EventType } from '../src/types'; import { visitSnapshot, NodeType } from 'rrweb-snapshot'; interface ISuite extends Suite { + server: http.Server; code: string; browser: puppeteer.Browser; } +interface IMimeType { + [key: string]: string; +} + +const server = () => + new Promise((resolve) => { + const mimeType: IMimeType = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + }; + const s = http.createServer((req, res) => { + const parsedUrl = url.parse(req.url!); + const sanitizePath = path + .normalize(parsedUrl.pathname!) + .replace(/^(\.\.[\/\\])+/, ''); + let pathname = path.join(__dirname, sanitizePath); + try { + const data = fs.readFileSync(pathname); + const ext = path.parse(pathname).ext; + res.setHeader('Content-type', mimeType[ext] || 'text/plain'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET'); + res.setHeader('Access-Control-Allow-Headers', 'Content-type'); + setTimeout(() => { + res.end(data); + // mock delay + }, 100); + } catch (error) { + res.end(); + } + }); + s.listen(3030).on('listening', () => { + resolve(s); + }); + }); + describe('record integration tests', function (this: ISuite) { this.timeout(10_000); @@ -44,6 +84,7 @@ describe('record integration tests', function (this: ISuite) { }; before(async () => { + this.server = await server(); this.browser = await launchPuppeteer(); const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js'); @@ -52,6 +93,7 @@ describe('record integration tests', function (this: ISuite) { after(async () => { await this.browser.close(); + this.server.close(); }); it('can record form interactions', async () => { @@ -367,4 +409,14 @@ describe('record integration tests', function (this: ISuite) { const snapshots = await page.evaluate('window.snapshots'); assertSnapshot(snapshots, __filename, 'log'); }); + + it('should nest record iframe', async () => { + const page: puppeteer.Page = await this.browser.newPage(); + await page.goto(`http://localhost:3030/html`); + await page.setContent(getHtml.call(this, 'main.html')); + + await page.waitFor(500); + const snapshots = await page.evaluate('window.snapshots'); + assertSnapshot(snapshots, __filename, 'iframe'); + }); }); diff --git a/test/replayer.test.ts b/test/replayer.test.ts index af7a32433d..e61c3d3714 100644 --- a/test/replayer.test.ts +++ b/test/replayer.test.ts @@ -8,7 +8,7 @@ import { Suite } from 'mocha'; import { launchPuppeteer, sampleEvents as events, - sampleStyleSheetRemoveEvents as stylesheetRemoveEvents + sampleStyleSheetRemoveEvents as stylesheetRemoveEvents, } from './utils'; import styleSheetRuleEvents from './events/style-sheet-rule-events'; @@ -127,17 +127,19 @@ describe('replayer', function (this: ISuite) { `); const currentTime = await this.page.evaluate(` replayer.getCurrentTime(); - `) + `); const currentState = await this.page.evaluate(` replayer['service']['state']['value']; - `) - expect(actionLength).to.equal(0) + `); + expect(actionLength).to.equal(0); expect(currentTime).to.equal(2500); expect(currentState).to.equal('paused'); }); it('can fast forward past StyleSheetRule changes on virtual elements', async () => { - await this.page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`); + await this.page.evaluate( + `events = ${JSON.stringify(styleSheetRuleEvents)}`, + ); const actionLength = await this.page.evaluate(` const { Replayer } = rrweb; const replayer = new Replayer(events); @@ -145,12 +147,16 @@ describe('replayer', function (this: ISuite) { replayer['timer']['actions'].length; `); expect(actionLength).to.equal( - styleSheetRuleEvents.filter((e) => e.timestamp - styleSheetRuleEvents[0].timestamp >= 1500).length, + styleSheetRuleEvents.filter( + (e) => e.timestamp - styleSheetRuleEvents[0].timestamp >= 1500, + ).length, ); }); it('can handle removing style elements', async () => { - await this.page.evaluate(`events = ${JSON.stringify(stylesheetRemoveEvents)}`); + await this.page.evaluate( + `events = ${JSON.stringify(stylesheetRemoveEvents)}`, + ); const actionLength = await this.page.evaluate(` const { Replayer } = rrweb; const replayer = new Replayer(events); @@ -158,7 +164,9 @@ describe('replayer', function (this: ISuite) { replayer['timer']['actions'].length; `); expect(actionLength).to.equal( - stylesheetRemoveEvents.filter((e) => e.timestamp - stylesheetRemoveEvents[0].timestamp >= 2500).length, + stylesheetRemoveEvents.filter( + (e) => e.timestamp - stylesheetRemoveEvents[0].timestamp >= 2500, + ).length, ); }); diff --git a/typings/record/iframe-manager.d.ts b/typings/record/iframe-manager.d.ts new file mode 100644 index 0000000000..4300a7abce --- /dev/null +++ b/typings/record/iframe-manager.d.ts @@ -0,0 +1,13 @@ +import { serializedNodeWithId, INode } from 'rrweb-snapshot'; +import { mutationCallBack } from '../types'; +export declare class IframeManager { + private iframes; + private mutationCb; + private loadListener?; + constructor(options: { + mutationCb: mutationCallBack; + }); + addIframe(iframeEl: HTMLIFrameElement): void; + addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown): void; + attachIframe(iframeEl: INode, childSn: serializedNodeWithId): void; +} diff --git a/typings/record/mutation.d.ts b/typings/record/mutation.d.ts index 72938fb193..440d81a575 100644 --- a/typings/record/mutation.d.ts +++ b/typings/record/mutation.d.ts @@ -1,7 +1,9 @@ import { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot'; import { mutationRecord, blockClass, mutationCallBack } from '../types'; +import { IframeManager } from './iframe-manager'; export default class MutationBuffer { private frozen; + private locked; private texts; private attributes; private removes; @@ -17,7 +19,9 @@ export default class MutationBuffer { private maskInputOptions; private recordCanvas; private slimDOMOptions; - init(cb: mutationCallBack, blockClass: blockClass, blockSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions): void; + private doc; + private iframeManager; + init(cb: mutationCallBack, blockClass: blockClass, blockSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, doc: Document, iframeManager: IframeManager): void; freeze(): void; unfreeze(): void; isFrozen(): boolean; diff --git a/typings/record/observer.d.ts b/typings/record/observer.d.ts index 9958737880..60eb7178a7 100644 --- a/typings/record/observer.d.ts +++ b/typings/record/observer.d.ts @@ -1,5 +1,5 @@ import { observerParam, listenerHandler, hooksParam } from '../types'; import MutationBuffer from './mutation'; -export declare const mutationBuffer: MutationBuffer; +export declare const mutationBuffers: MutationBuffer[]; export declare const INPUT_TAGS: string[]; export declare function initObservers(o: observerParam, hooks?: hooksParam): listenerHandler; diff --git a/typings/replay/index.d.ts b/typings/replay/index.d.ts index f3720ce277..811c51fdbd 100644 --- a/typings/replay/index.d.ts +++ b/typings/replay/index.d.ts @@ -19,6 +19,7 @@ export declare class Replayer { private fragmentParentMap; private elementStateMap; private imageMap; + private newDocumentQueue; constructor(events: Array, config?: Partial); on(event: string, handler: Handler): this; setConfig(config: Partial): void; @@ -36,6 +37,9 @@ export declare class Replayer { private handleResize; private getCastFn; private rebuildFullSnapshot; + private insertStyleRules; + private attachDocumentToIframe; + private collectIframeAndAttachDocument; private waitForStylesheetLoad; private preloadAllImages; private applyIncremental; diff --git a/typings/types.d.ts b/typings/types.d.ts index 35542ff57a..d9671729b5 100644 --- a/typings/types.d.ts +++ b/typings/types.d.ts @@ -2,6 +2,7 @@ import { serializedNodeWithId, idNodeMap, INode, MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot'; import { PackFn, UnpackFn } from './packer/base'; import { FontFaceDescriptors } from 'css-font-loading-module'; +import { IframeManager } from './record/iframe-manager'; export declare enum EventType { DomContentLoaded = 0, Load = 1, @@ -81,7 +82,7 @@ export declare type scrollData = { } & scrollPosition; export declare type viewportResizeData = { source: IncrementalSource.ViewportResize; -} & viewportResizeDimention; +} & viewportResizeDimension; export declare type inputData = { source: IncrementalSource.Input; id: number; @@ -157,6 +158,8 @@ export declare type observerParam = { recordCanvas: boolean; collectFonts: boolean; slimDOMOptions: SlimDOMOptions; + doc: Document; + iframeManager: IframeManager; }; export declare type hooksParam = { mutation?: mutationCallBack; @@ -304,11 +307,11 @@ export declare type LogParam = { }; export declare type fontCallback = (p: fontParam) => void; export declare type logCallback = (p: LogParam) => void; -export declare type viewportResizeDimention = { +export declare type viewportResizeDimension = { width: number; height: number; }; -export declare type viewportResizeCallback = (d: viewportResizeDimention) => void; +export declare type viewportResizeCallback = (d: viewportResizeDimension) => void; export declare type inputValue = { text: string; isChecked: boolean; @@ -325,6 +328,10 @@ export declare type mediaInteractionParam = { id: number; }; export declare type mediaInteractionCallback = (p: mediaInteractionParam) => void; +export declare type DocumentDimension = { + x: number; + y: number; +}; export declare type Mirror = { map: idNodeMap; getId: (n: INode) => number; diff --git a/typings/utils.d.ts b/typings/utils.d.ts index e87dee5375..ba1050b4ed 100644 --- a/typings/utils.d.ts +++ b/typings/utils.d.ts @@ -1,5 +1,5 @@ -import { Mirror, throttleOptions, listenerHandler, hookResetter, blockClass, eventWithTime, addedNodeMutation, removedNodeMutation, textMutation, attributeMutation, mutationData, scrollData, inputData } from './types'; -import { INode } from 'rrweb-snapshot'; +import { Mirror, throttleOptions, listenerHandler, hookResetter, blockClass, eventWithTime, addedNodeMutation, removedNodeMutation, textMutation, attributeMutation, mutationData, scrollData, inputData, DocumentDimension } from './types'; +import { INode, serializedNodeWithId } from 'rrweb-snapshot'; export declare function on(type: string, fn: EventListenerOrEventListenerObject, target?: Document | Window): listenerHandler; export declare const mirror: Mirror; export declare function throttle(func: (arg: T) => void, wait: number, options?: throttleOptions): (arg: T) => void; @@ -53,4 +53,13 @@ declare type ResolveTree = { }; export declare function queueToResolveTrees(queue: addedNodeMutation[]): ResolveTree[]; export declare function iterateResolveTree(tree: ResolveTree, cb: (mutation: addedNodeMutation) => unknown): void; +declare type HTMLIFrameINode = HTMLIFrameElement & { + __sn: serializedNodeWithId; +}; +export declare type AppendedIframe = { + mutationInQueue: addedNodeMutation; + builtNode: HTMLIFrameINode; +}; +export declare function isIframeINode(node: INode): node is HTMLIFrameINode; +export declare function getBaseDimension(node: Node): DocumentDimension; export {}; diff --git a/yarn.lock b/yarn.lock index 17790fd7ca..d9eb371397 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2749,10 +2749,10 @@ rollup@^2.3.3: optionalDependencies: fsevents "~2.1.2" -rrweb-snapshot@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-1.0.4.tgz#608acf0f066e4f72213109ee6bfe25d291e7eb0b" - integrity sha512-I9oeK+LPeFE8N8MT011gBLEb+3VjnU3TJNHphzqDu5XJLna8bvMx9tkTiyD9MOlVCX80Le/MwSBxoJMpCX9uQA== +rrweb-snapshot@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-1.0.6.tgz#2b1143d2e8b5bba54c54bd63e485cfc253763b8e" + integrity sha512-ctwaNjFnomNFe446gOcMusSzIxLUnF0kq6ev0iuKa1W1lhiVcBoRdfpE8zDDRKee2MxHxt0rq2T5DMKSIAD6fQ== run-async@^2.2.0: version "2.4.1"