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..75393e33d5 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,20 @@ function record( let lastFullSnapshotEvent: eventWithTime; let incrementalSnapshotCount = 0; wrappedEmit = (e: eventWithTime, isCheckout?: boolean) => { + if ( + mutationBuffer.isFrozen() && + e.type !== EventType.FullSnapshot && + !( + 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.unfreeze(); + } + emit(((packFn ? packFn(e) : e) as unknown) as T, isCheckout); if (e.type === EventType.FullSnapshot) { lastFullSnapshotEvent = e; @@ -110,6 +124,9 @@ function record( }), isCheckout, ); + + let wasFrozen = mutationBuffer.isFrozen(); + mutationBuffer.freeze(); // don't allow any mirror modifications during snapshotting const [node, idNodeMap] = snapshot( document, blockClass, @@ -147,6 +164,10 @@ function record( }, }), ); + if (!wasFrozen) { + mutationBuffer.emit(); // emit anything queued up now + mutationBuffer.unfreeze(); + } } try { @@ -325,4 +346,8 @@ record.addCustomEvent = (tag: string, payload: T) => { ); }; +record.freezePage = () => { + mutationBuffer.freeze(); +}; + export default record; diff --git a/src/record/mutation.ts b/src/record/mutation.ts index ef781b9be7..ec68f89ba7 100644 --- a/src/record/mutation.ts +++ b/src/record/mutation.ts @@ -109,10 +109,12 @@ function isINode(n: Node | INode): n is INode { * controls behaviour of a MutationObserver */ export default class MutationBuffer { + private frozen: boolean = false; + private texts: textCursor[] = []; private attributes: attributeCursor[] = []; private removes: removedNodeMutation[] = []; - private adds: addedNodeMutation[] = []; + private mapRemoves: Node[] = []; private movedMap: Record = {}; @@ -143,7 +145,7 @@ export default class MutationBuffer { private maskInputOptions: MaskInputOptions; private recordCanvas: boolean; - constructor( + public init( cb: mutationCallBack, blockClass: blockClass, inlineStylesheet: boolean, @@ -157,8 +159,30 @@ export default class MutationBuffer { this.emissionCallback = cb; } + public freeze() { + this.frozen = true; + } + + public unfreeze() { + this.frozen = false; + } + + public isFrozen() { + return this.frozen; + } + public processMutations = (mutations: mutationRecord[]) => { mutations.forEach(this.processMutation); + if (!this.frozen) { + this.emit(); + } + }; + + public emit = () => { + // delay any modification of the mirror until this function + // so that the mirror for takeFullSnapshot doesn't get mutated while it's event is being processed + + const adds: addedNodeMutation[] = []; /** * Sometimes child node may be pushed before its newly added @@ -182,7 +206,7 @@ export default class MutationBuffer { if (parentId === -1 || nextId === -1) { return addList.addNode(n); } - this.adds.push({ + adds.push({ parentId, nextId, node: serializeNodeWithId( @@ -198,6 +222,10 @@ export default class MutationBuffer { }); }; + while (this.mapRemoves.length) { + mirror.removeNodeFromMap(this.mapRemoves.shift() as INode); + } + for (const n of this.movedSet) { pushAdd(n); } @@ -253,10 +281,6 @@ export default class MutationBuffer { pushAdd(node.value); } - this.emit(); - }; - - public emit = () => { const payload = { texts: this.texts .map((text) => ({ @@ -273,7 +297,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 ( @@ -284,17 +308,17 @@ export default class MutationBuffer { ) { return; } - this.emissionCallback(payload); // reset this.texts = []; this.attributes = []; this.removes = []; - this.adds = []; this.addedSet = new Set(); this.movedSet = new Set(); this.droppedSet = new Set(); this.movedMap = {}; + + this.emissionCallback(payload); }; private processMutation = (m: mutationRecord) => { @@ -373,7 +397,7 @@ export default class MutationBuffer { id: nodeId, }); } - mirror.removeNodeFromMap(n as INode); + this.mapRemoves.push(n); }); break; } 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..dfc80b8b51 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`] = ` "[ { @@ -4730,6 +4897,10 @@ exports[`select2 1`] = ` { \\"parentId\\": 25, \\"id\\": 36 + }, + { + \\"parentId\\": 45, + \\"id\\": 46 } ], \\"adds\\": [ 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/index.d.ts b/typings/index.d.ts index 094914918a..b6bf190e78 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -4,4 +4,5 @@ import { mirror } from './utils'; import * as utils from './utils'; export { EventType, IncrementalSource, MouseInteractions, ReplayerEvents, } from './types'; declare const addCustomEvent: (tag: string, payload: T) => void; -export { record, addCustomEvent, Replayer, mirror, utils }; +declare const freezePage: () => void; +export { record, addCustomEvent, freezePage, Replayer, mirror, utils }; 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..f57d7b8e54 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 { + private frozen; private texts; private attributes; private removes; @@ -14,7 +15,10 @@ 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; + freeze(): void; + unfreeze(): void; + isFrozen(): boolean; 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;