diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 67e77c49cc..041f45929d 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -94,6 +94,7 @@ function record( keepIframeSrcFn = () => false, ignoreCSSAttributes = new Set([]), errorHandler, + onMutation, } = options; registerErrorHandler(errorHandler); @@ -328,6 +329,7 @@ function record( mutationCb: wrappedMutationEmit, scrollCb: wrappedScrollEmit, bypassOptions: { + onMutation, blockClass, blockSelector, maskAllText, @@ -442,6 +444,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 0d1c50ef46..2846897a62 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -122,7 +122,14 @@ export function initMutationObserver( const observer = new (mutationObserverCtor as new ( callback: MutationCallback, ) => MutationObserver)( - 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.bind(mutationBuffer)(mutations); + }), ); observer.observe(rootEl, { attributes: true, diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index e5593d3fb0..21ff1e8902 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -78,9 +78,11 @@ export type recordOptions = { mousemoveWait?: number; keepIframeSrcFn?: KeepIframeSrcFn; errorHandler?: ErrorHandler; + onMutation?: (mutations: MutationRecord[]) => boolean, }; export type observerParam = { + onMutation?: (mutations: MutationRecord[]) => boolean, mutationCb: mutationCallBack; mousemoveCb: mousemoveCallBack; mouseInteractionCb: mouseInteractionCallBack; @@ -137,6 +139,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 84e7728c1e..980739e863 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 correctly serialize a shader and multiple webgl contexts 1`] = ` "[ { @@ -3539,14 +3688,6 @@ exports[`record integration tests can record node mutations 1`] = ` ] } }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 2, - \\"type\\": 0, - \\"id\\": 70 - } - }, { \\"type\\": 3, \\"data\\": { @@ -3565,6 +3706,14 @@ exports[`record integration tests can record node mutations 1`] = ` \\"id\\": 35 } }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 0, + \\"id\\": 70 + } + }, { \\"type\\": 3, \\"data\\": { @@ -9333,14 +9482,6 @@ exports[`record integration tests should nest record iframe 1`] = ` \\"attributes\\": [], \\"isAttachIframe\\": true } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 4, - \\"width\\": 1920, - \\"height\\": 1080 - } } ]" `; diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index f936c41408..31df35bc5e 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -309,6 +309,36 @@ 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', { + // @ts-expect-error Need to stringify this for tests + 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/test/utils.ts b/packages/rrweb/test/utils.ts index d211296e78..22b957f06e 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -696,6 +696,7 @@ export function generateRecordSnippet(options: recordOptions) { maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, maskInputFn: ${options.maskInputFn}, userTriggeredOnInput: ${options.userTriggeredOnInput}, + onMutation: ${options.onMutation || undefined}, maskAttributeFn: ${options.maskAttributeFn}, maskTextFn: ${options.maskTextFn}, maskInputFn: ${options.maskInputFn},