From a10c2a1bda71afe1268853af8521f3a17643b500 Mon Sep 17 00:00:00 2001 From: eoghan <eoghan@getthere.ie> Date: Tue, 26 May 2020 18:09:30 +0000 Subject: [PATCH 1/5] The `processMutations` function needed to be bound to the `mutationBuffer` object, as otherwise `this` referred to the `MutationObserver` object itself --- src/record/observer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/record/observer.ts b/src/record/observer.ts index 792cd7ff7f..27ff04d6d8 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -53,7 +53,9 @@ function initMutationObserver( maskInputOptions, recordCanvas, ); - const observer = new MutationObserver(mutationBuffer.processMutations); + const observer = new MutationObserver( + mutationBuffer.processMutations.bind(mutationBuffer) + ); observer.observe(document, { attributes: true, attributeOldValue: true, From 321502ace93f8efe95735cba5e955a12f8ce0035 Mon Sep 17 00:00:00 2001 From: eoghan <eoghan@getthere.ie> Date: Wed, 27 May 2020 16:25:58 +0000 Subject: [PATCH 2/5] Enable external pausing of mutation buffer emissions - no automatic pausing based on e.g. pageVisibility yet, assuming such a thing is desirable https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API - user code has to call new API method `freezePage` e.g. when page is hidden or after a timeout - automatically unpauses when the next user initiated event occurs (am assuming everything that isn't a mutation event counts as 'user initiated' either way think this is the correct thing to do until I see a counterexample of an event that shouldn't cause the mutations to be unbufferred) --- src/record/index.ts | 19 ++++++++++++++++++- src/record/mutation.ts | 8 ++++++-- src/record/observer.ts | 6 ++++-- typings/record/index.d.ts | 1 + typings/record/mutation.d.ts | 3 ++- typings/record/observer.d.ts | 4 +++- 6 files changed, 34 insertions(+), 7 deletions(-) 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<T = eventWithTime>( 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 = <T>(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..5aa12260b2 100644 --- a/src/record/mutation.ts +++ b/src/record/mutation.ts @@ -109,6 +109,8 @@ 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[] = []; @@ -143,7 +145,7 @@ export default class MutationBuffer { private maskInputOptions: MaskInputOptions; private recordCanvas: boolean; - constructor( + public init( cb: mutationCallBack, blockClass: blockClass, inlineStylesheet: boolean, @@ -253,7 +255,9 @@ export default class MutationBuffer { pushAdd(node.value); } - this.emit(); + if (!this.paused) { + this.emit(); + } }; public emit = () => { diff --git a/src/record/observer.ts b/src/record/observer.ts index 27ff04d6d8..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,7 +48,7 @@ function initMutationObserver( recordCanvas: boolean, ): MutationObserver { // see mutation.ts for details - const mutationBuffer = new MutationBuffer( + mutationBuffer.init( cb, blockClass, inlineStylesheet, @@ -562,7 +564,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { }; } -export default function initObservers( +export function initObservers( o: observerParam, hooks: hooksParam = {}, ): listenerHandler { 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<T = eventWithTime>(options?: recordOptions<T>): listenerHandler | undefined; declare namespace record { var addCustomEvent: <T>(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; From 18a6a5a18ef197b0e30a3a9fd7c27bb9ffb79eda Mon Sep 17 00:00:00 2001 From: eoghan <eoghan@getthere.ie> Date: Tue, 9 Jun 2020 15:13:31 +0000 Subject: [PATCH 3/5] Avoid a build up of duplicate `adds` by delaying pushing to adds until emission time --- src/record/mutation.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/record/mutation.ts b/src/record/mutation.ts index 5aa12260b2..7366e3d8a6 100644 --- a/src/record/mutation.ts +++ b/src/record/mutation.ts @@ -114,7 +114,6 @@ export default class MutationBuffer { private texts: textCursor[] = []; private attributes: attributeCursor[] = []; private removes: removedNodeMutation[] = []; - private adds: addedNodeMutation[] = []; private movedMap: Record<string, true> = {}; @@ -161,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 @@ -184,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( @@ -255,12 +262,6 @@ export default class MutationBuffer { pushAdd(node.value); } - if (!this.paused) { - this.emit(); - } - }; - - public emit = () => { const payload = { texts: this.texts .map((text) => ({ @@ -277,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 ( @@ -294,7 +295,6 @@ export default class MutationBuffer { this.texts = []; this.attributes = []; this.removes = []; - this.adds = []; this.addedSet = new Set<Node>(); this.movedSet = new Set<Node>(); this.droppedSet = new Set<Node>(); From fcc290d496b5b9189901b98c3da7a59350b9fd4d Mon Sep 17 00:00:00 2001 From: Eoghan Murray <eoghan@getthere.ie> Date: Fri, 12 Jun 2020 18:07:26 +0100 Subject: [PATCH 4/5] Need to export freezePage in order to use it from rrweb.min.js --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 }; From e8fcfcc3dd0fbe834935c08ea21c4dda64e4e002 Mon Sep 17 00:00:00 2001 From: Eoghan Murray <eoghan@getthere.ie> Date: Fri, 12 Jun 2020 18:29:05 +0100 Subject: [PATCH 5/5] Add a test to check if mutations can be turned off with the `freezePage` method --- test/__snapshots__/integration.test.ts.snap | 167 ++++++++++++++++++++ test/integration.test.ts | 26 +++ 2 files changed, 193 insertions(+) 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');