diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 0c0158c30e..f93cc62035 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -93,6 +93,7 @@ function record( keepIframeSrcFn = () => false, ignoreCSSAttributes = new Set([]), errorHandler, + onMutation, } = options; registerErrorHandler(errorHandler); @@ -327,6 +328,7 @@ function record( mutationCb: wrappedMutationEmit, scrollCb: wrappedScrollEmit, bypassOptions: { + onMutation, blockClass, blockSelector, maskAllText, @@ -441,6 +443,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 cd8ec9b0cd..2675480e0e 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 b0f4070cee..e3e4c56de3 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -77,9 +77,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; @@ -135,6 +137,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 8235ec2bbd..57b64c0207 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`] = ` "[ { @@ -3582,14 +3731,6 @@ exports[`record integration tests can record node mutations 1`] = ` ] } }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 2, - \\"type\\": 0, - \\"id\\": 70 - } - }, { \\"type\\": 3, \\"data\\": { @@ -3608,6 +3749,14 @@ exports[`record integration tests can record node mutations 1`] = ` \\"id\\": 35 } }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 0, + \\"id\\": 70 + } + }, { \\"type\\": 3, \\"data\\": { @@ -9158,14 +9307,6 @@ exports[`record integration tests should nest record iframe 1`] = ` \\"attributes\\": [], \\"isAttachIframe\\": true } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 4, - \\"width\\": 1920, - \\"height\\": 1080 - } } ]" `; @@ -13802,14 +13943,6 @@ exports[`record integration tests should record images inside iframe with blob u \\"isAttachIframe\\": true } }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 4, - \\"width\\": 1920, - \\"height\\": 1080 - } - }, { \\"type\\": 3, \\"data\\": { @@ -16301,14 +16434,6 @@ exports[`record integration tests should record mutations in iframes accross pag \\"isAttachIframe\\": true } }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 4, - \\"width\\": 1920, - \\"height\\": 1080 - } - }, { \\"type\\": 3, \\"data\\": { @@ -16702,14 +16827,6 @@ exports[`record integration tests should record nested iframes and shadow doms 1 \\"isAttachIframe\\": true } }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 4, - \\"width\\": 1920, - \\"height\\": 1080 - } - }, { \\"type\\": 3, \\"data\\": { diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index b40e199ce2..48659299ab 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -254,6 +254,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 71a0ac003a..c9be9b518b 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -601,6 +601,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},