diff --git a/src/index.ts b/src/index.ts index 7883678d9a..e57b8e42ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,5 +11,6 @@ export { } from './types'; const { addCustomEvent } = record; +const { freezePage } = record; -export { record, addCustomEvent, Replayer, mirror, utils }; +export { record, addCustomEvent, freezePage, Replayer, mirror, utils }; diff --git a/src/record/index.ts b/src/record/index.ts index b5c508427a..fda4f32797 100644 --- a/src/record/index.ts +++ b/src/record/index.ts @@ -1,5 +1,5 @@ import { snapshot, MaskInputOptions } from 'rrweb-snapshot'; -import initObservers from './observer'; +import { initObservers, mutationBuffer } from './observer'; import { mirror, on, @@ -81,6 +81,19 @@ function record( let lastFullSnapshotEvent: eventWithTime; let incrementalSnapshotCount = 0; wrappedEmit = (e: eventWithTime, isCheckout?: boolean) => { + if ( + mutationBuffer.paused && + !( + e.type == EventType.IncrementalSnapshot && + e.data.source == IncrementalSource.Mutation + ) + ) { + // we've got a user initiated event so first we need to apply + // all DOM changes that have been buffering during paused state + mutationBuffer.emit(); + mutationBuffer.paused = false; + } + emit(((packFn ? packFn(e) : e) as unknown) as T, isCheckout); if (e.type === EventType.FullSnapshot) { lastFullSnapshotEvent = e; @@ -325,4 +338,8 @@ record.addCustomEvent = (tag: string, payload: T) => { ); }; +record.freezePage = () => { + mutationBuffer.paused = true; +}; + export default record; diff --git a/src/record/mutation.ts b/src/record/mutation.ts index ef781b9be7..7366e3d8a6 100644 --- a/src/record/mutation.ts +++ b/src/record/mutation.ts @@ -109,10 +109,11 @@ function isINode(n: Node | INode): n is INode { * controls behaviour of a MutationObserver */ export default class MutationBuffer { + public paused: boolean = false; + private texts: textCursor[] = []; private attributes: attributeCursor[] = []; private removes: removedNodeMutation[] = []; - private adds: addedNodeMutation[] = []; private movedMap: Record = {}; @@ -143,7 +144,7 @@ export default class MutationBuffer { private maskInputOptions: MaskInputOptions; private recordCanvas: boolean; - constructor( + public init( cb: mutationCallBack, blockClass: blockClass, inlineStylesheet: boolean, @@ -159,6 +160,14 @@ export default class MutationBuffer { public processMutations = (mutations: mutationRecord[]) => { mutations.forEach(this.processMutation); + if (!this.paused) { + this.emit(); + } + }; + + public emit = () => { + + const adds: addedNodeMutation[] = []; /** * Sometimes child node may be pushed before its newly added @@ -182,7 +191,7 @@ export default class MutationBuffer { if (parentId === -1 || nextId === -1) { return addList.addNode(n); } - this.adds.push({ + adds.push({ parentId, nextId, node: serializeNodeWithId( @@ -253,10 +262,6 @@ export default class MutationBuffer { pushAdd(node.value); } - this.emit(); - }; - - public emit = () => { const payload = { texts: this.texts .map((text) => ({ @@ -273,7 +278,7 @@ export default class MutationBuffer { // attribute mutation's id was not in the mirror map means the target node has been removed .filter((attribute) => mirror.has(attribute.id)), removes: this.removes, - adds: this.adds, + adds: adds, }; // payload may be empty if the mutations happened in some blocked elements if ( @@ -290,7 +295,6 @@ export default class MutationBuffer { this.texts = []; this.attributes = []; this.removes = []; - this.adds = []; this.addedSet = new Set(); this.movedSet = new Set(); this.droppedSet = new Set(); diff --git a/src/record/observer.ts b/src/record/observer.ts index 792cd7ff7f..a557ce9216 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -38,6 +38,8 @@ import { } from '../types'; import MutationBuffer from './mutation'; +export const mutationBuffer = new MutationBuffer(); + function initMutationObserver( cb: mutationCallBack, blockClass: blockClass, @@ -46,14 +48,16 @@ function initMutationObserver( recordCanvas: boolean, ): MutationObserver { // see mutation.ts for details - const mutationBuffer = new MutationBuffer( + mutationBuffer.init( cb, blockClass, inlineStylesheet, maskInputOptions, recordCanvas, ); - const observer = new MutationObserver(mutationBuffer.processMutations); + const observer = new MutationObserver( + mutationBuffer.processMutations.bind(mutationBuffer) + ); observer.observe(document, { attributes: true, attributeOldValue: true, @@ -560,7 +564,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { }; } -export default function initObservers( +export function initObservers( o: observerParam, hooks: hooksParam = {}, ): listenerHandler { diff --git a/test/__snapshots__/integration.test.ts.snap b/test/__snapshots__/integration.test.ts.snap index 18875bd175..3f89221028 100644 --- a/test/__snapshots__/integration.test.ts.snap +++ b/test/__snapshots__/integration.test.ts.snap @@ -1561,6 +1561,173 @@ exports[`form 1`] = ` ]" `; +exports[`frozen 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 3 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"p\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"mutation observer\\", + \\"id\\": 7 + } + ], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 8 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"ul\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 10 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"li\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 11 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + } + ], + \\"id\\": 9 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\\\n \\", + \\"id\\": 13 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 15 + } + ], + \\"id\\": 14 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 16 + } + ], + \\"id\\": 4 + } + ], + \\"id\\": 2 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 17, + \\"attributes\\": { + \\"foo\\": \\"bar\\" + } + }, + { + \\"id\\": 4, + \\"attributes\\": { + \\"test\\": \\"true\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"li\\", + \\"attributes\\": { + \\"foo\\": \\"bar\\" + }, + \\"childNodes\\": [], + \\"id\\": 17 + } + } + ] + } + } +]" +`; + exports[`ignore 1`] = ` "[ { diff --git a/test/integration.test.ts b/test/integration.test.ts index f308109cfe..394b4207f6 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -139,6 +139,32 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots, __filename, 'select2'); }); + it('can freeze mutations', async () => { + const page: puppeteer.Page = await this.browser.newPage(); + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, 'mutation-observer.html')); + + await page.evaluate(() => { + const li = document.createElement('li'); + const ul = document.querySelector('ul') as HTMLUListElement; + ul.appendChild(li); + li.setAttribute('foo', 'bar'); + document.body.setAttribute('test', 'true'); + }); + await page.evaluate('rrweb.freezePage()'); + await page.evaluate(() => { + document.body.setAttribute('test', 'bad'); + const ul = document.querySelector('ul') as HTMLUListElement; + const li = document.createElement('li'); + li.setAttribute('bad-attr', 'bad'); + li.innerText = 'bad text'; + ul.appendChild(li); + document.body.removeChild(ul); + }); + const snapshots = await page.evaluate('window.snapshots'); + assertSnapshot(snapshots, __filename, 'frozen'); + }); + it('should not record input events on ignored elements', async () => { const page: puppeteer.Page = await this.browser.newPage(); await page.goto('about:blank'); diff --git a/typings/record/index.d.ts b/typings/record/index.d.ts index 73e1f2c373..9c5e20436f 100644 --- a/typings/record/index.d.ts +++ b/typings/record/index.d.ts @@ -2,5 +2,6 @@ import { eventWithTime, recordOptions, listenerHandler } from '../types'; declare function record(options?: recordOptions): listenerHandler | undefined; declare namespace record { var addCustomEvent: (tag: string, payload: T) => void; + var freezePage: () => void; } export default record; diff --git a/typings/record/mutation.d.ts b/typings/record/mutation.d.ts index 9d92fd205e..f8e5a9c0b5 100644 --- a/typings/record/mutation.d.ts +++ b/typings/record/mutation.d.ts @@ -1,6 +1,7 @@ import { MaskInputOptions } from 'rrweb-snapshot'; import { mutationRecord, blockClass, mutationCallBack } from '../types'; export default class MutationBuffer { + paused: boolean; private texts; private attributes; private removes; @@ -14,7 +15,7 @@ export default class MutationBuffer { private inlineStylesheet; private maskInputOptions; private recordCanvas; - constructor(cb: mutationCallBack, blockClass: blockClass, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, recordCanvas: boolean); + init(cb: mutationCallBack, blockClass: blockClass, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, recordCanvas: boolean): void; processMutations: (mutations: mutationRecord[]) => void; emit: () => void; private processMutation; diff --git a/typings/record/observer.d.ts b/typings/record/observer.d.ts index 75cac487be..9958737880 100644 --- a/typings/record/observer.d.ts +++ b/typings/record/observer.d.ts @@ -1,3 +1,5 @@ import { observerParam, listenerHandler, hooksParam } from '../types'; +import MutationBuffer from './mutation'; +export declare const mutationBuffer: MutationBuffer; export declare const INPUT_TAGS: string[]; -export default function initObservers(o: observerParam, hooks?: hooksParam): listenerHandler; +export declare function initObservers(o: observerParam, hooks?: hooksParam): listenerHandler;