diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index e38315bdbe..120780ef6a 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -75,6 +75,7 @@ function record( inlineImages = false, plugins, keepIframeSrcFn = () => false, + onMutation, } = options; // runtime checks for user options if (!emit) { @@ -233,6 +234,7 @@ function record( mutationCb: wrappedMutationEmit, scrollCb: wrappedScrollEmit, bypassOptions: { + onMutation, blockClass, blockSelector, unblockSelector, @@ -351,6 +353,7 @@ function record( const observe = (doc: Document) => { return callbackWrapper(initObservers)( { + onMutation, mutationCb: wrappedMutationEmit, mousemoveCb: (positions, source) => wrappedEmit( diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 2158a6a9da..562f5a5331 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -109,7 +109,14 @@ export function initMutationObserver( } const observer = new mutationObserverCtor( - callbackWrapper(mutationBuffer.processMutations.bind(mutationBuffer)), + callbackWrapper((mutations) => { + // If this callback returns `false`, we do not want to process the mutations + // This can be used to e.g. do a manual full snapshot when mutations become too large, or similar. + if (options.onMutation && options.onMutation(mutations) === false) { + return; + } + mutationBuffer.processMutations(mutations); + }), ); observer.observe(rootEl, { diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 7258cd9430..94df647d9d 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -244,9 +244,11 @@ export type recordOptions = { // departed, please use sampling options mousemoveWait?: number; keepIframeSrcFn?: KeepIframeSrcFn; + onMutation?: (mutations: MutationRecord[]) => boolean, }; export type observerParam = { + onMutation?: (mutations: MutationRecord[]) => boolean, mutationCb: mutationCallBack; mousemoveCb: mousemoveCallBack; mouseInteractionCb: mouseInteractionCallBack; @@ -293,6 +295,7 @@ export type observerParam = { export type MutationBufferParam = Pick< observerParam, + | 'onMutation' | 'mutationCb' | 'blockClass' | 'blockSelector' diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 782918e17f..5528b9fb1a 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -1,5 +1,154 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`record integration tests can configure onMutation 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": 1, + "name": "html", + "publicId": "", + "systemId": "", + "id": 2 + }, + { + "type": 2, + "tagName": "html", + "attributes": {}, + "childNodes": [ + { + "type": 2, + "tagName": "head", + "attributes": {}, + "childNodes": [], + "id": 4 + }, + { + "type": 2, + "tagName": "body", + "attributes": {}, + "childNodes": [ + { + "type": 3, + "textContent": "\\n ", + "id": 6 + }, + { + "type": 2, + "tagName": "p", + "attributes": {}, + "childNodes": [ + { + "type": 3, + "textContent": "mutation observer", + "id": 8 + } + ], + "id": 7 + }, + { + "type": 3, + "textContent": "\\n ", + "id": 9 + }, + { + "type": 2, + "tagName": "ul", + "attributes": {}, + "childNodes": [ + { + "type": 3, + "textContent": "\\n ", + "id": 11 + }, + { + "type": 2, + "tagName": "li", + "attributes": {}, + "childNodes": [], + "id": 12 + }, + { + "type": 3, + "textContent": "\\n ", + "id": 13 + } + ], + "id": 10 + }, + { + "type": 3, + "textContent": "\\n ", + "id": 14 + }, + { + "type": 2, + "tagName": "canvas", + "attributes": {}, + "childNodes": [], + "id": 15 + }, + { + "type": 3, + "textContent": "\\n\\n ", + "id": 16 + }, + { + "type": 2, + "tagName": "script", + "attributes": {}, + "childNodes": [ + { + "type": 3, + "textContent": "SCRIPT_PLACEHOLDER", + "id": 18 + } + ], + "id": 17 + }, + { + "type": 3, + "textContent": "\\n \\n \\n", + "id": 19 + } + ], + "id": 5 + } + ], + "id": 3 + } + ], + "id": 1 + }, + "initialOffset": { + "left": 0, + "top": 0 + } + } + } +]" +`; + exports[`record integration tests can freeze mutations 1`] = ` "[ { diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 9c09082a5a..48e7fa63b1 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -58,6 +58,7 @@ describe('record integration tests', function (this: ISuite) { maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, maskInputSelector: ${JSON.stringify(options.maskInputSelector)}, userTriggeredOnInput: ${options.userTriggeredOnInput}, + onMutation: ${options.onMutation || undefined}, maskAllText: ${options.maskAllText}, maskTextFn: ${options.maskTextFn}, unmaskTextSelector: ${JSON.stringify(options.unmaskTextSelector)}, @@ -216,6 +217,35 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots); }); + it('can configure onMutation', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + + await page.setContent( + getHtml.call(this, 'mutation-observer.html', { + onMutation: `(mutations) => { window.lastMutationsLength = mutations.length; return mutations.length < 500 }` + }), + ); + + await page.evaluate(() => { + const ul = document.querySelector('ul') as HTMLUListElement; + + for(let i = 0; i < 2000; i++) { + const li = document.createElement('li'); + ul.appendChild(li); + const p = document.querySelector('p') as HTMLParagraphElement; + p.appendChild(document.createElement('span')); + } + }); + + const snapshots = await page.evaluate('window.snapshots'); + assertSnapshot(snapshots); + + const lastMutationsLength = await page.evaluate('window.lastMutationsLength'); + expect(lastMutationsLength).toBe(4000); + }); + + it('can freeze mutations', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); diff --git a/packages/rrweb/typings/types.d.ts b/packages/rrweb/typings/types.d.ts index 28bbfb7f5f..fce4974a62 100644 --- a/packages/rrweb/typings/types.d.ts +++ b/packages/rrweb/typings/types.d.ts @@ -163,8 +163,10 @@ export type recordOptions = { plugins?: RecordPlugin[]; mousemoveWait?: number; keepIframeSrcFn?: KeepIframeSrcFn; + onMutation?: (mutations: MutationRecord[]) => boolean; }; export type observerParam = { + onMutation?: (mutations: MutationRecord[]) => boolean; mutationCb: mutationCallBack; mousemoveCb: mousemoveCallBack; mouseInteractionCb: mouseInteractionCallBack; @@ -208,7 +210,7 @@ export type observerParam = { options: unknown; }>; }; -export type MutationBufferParam = Pick; +export type MutationBufferParam = Pick; export type hooksParam = { mutation?: mutationCallBack; mousemove?: mousemoveCallBack;