Suspend mutations during snapshot (#385)
* The `processMutations` function needed to be bound to the `mutationBuffer` object, as otherwise `this` referred to the `MutationObserver` object itself * 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) * Avoid a build up of duplicate `adds` by delaying pushing to adds until emission time * Need to export freezePage in order to use it from rrweb.min.js * Add a test to check if mutations can be turned off with the `freezePage` method * I noticed out of order ids (in terms of a DOM walk) in a FullSnapshot. A DOM mutation was executed against the mirror asynchronously before it could be fully processed. This would lead to a situation in replay where a mutation is executed against a DOM tree that already has the mutation applied. This changeset fixes that by freezing any mutations until the snapshot is completed. * Remove attribute modifications from a mutation event that were incorrect in that they were repeating the attributes of those nodes present in the 'adds' array of the same mutation * I've manually verified that this empty text node is actually removed when the dropdown is opened: document.getElementById('select2-results-1').childNodes NodeList(2) [li.select2-results-dept-0.select2-result.select2-result-selectable.select2-highlighted, li.select2-results-dept-0.select2-result.select2-result-selectable] and also that it is not reinstated after the second `await page.click('.select2-container');` * Rearrange when removal happens in order to satisfy tests. I'm also reverting a recent test change (2600fe7) so that tests pass after this rearrangement; I believe that test change to still be the correct way of doing it, but maybe it is not strictly important that there are extra mutations on attributes of just added nodes * As mutations are now paused during FullSnapshots, we shouldn't be counting this as a 'user emission'. We automatically emit mutations after unpause anyway ('emit anything queued up now') * Ensure that we clear arrays before emitting, as the mutation could have the side effect of triggering a FullSnapshot (checkoutEveryNth), which would otherwise re-trigger emission of same mutation (through the new pause/fullsnapshot/mutationemit/unpause process) * Don't let the programattic pausing during TakeFullSnapshot accidentally unpause a manual call to the API method `freezePage` * Rename paused -> frozen for consistency and change to use getter/setter access methods
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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<T = eventWithTime>(
|
||||
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<T = eventWithTime>(
|
||||
}),
|
||||
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<T = eventWithTime>(
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!wasFrozen) {
|
||||
mutationBuffer.emit(); // emit anything queued up now
|
||||
mutationBuffer.unfreeze();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -325,4 +346,8 @@ record.addCustomEvent = <T>(tag: string, payload: T) => {
|
||||
);
|
||||
};
|
||||
|
||||
record.freezePage = () => {
|
||||
mutationBuffer.freeze();
|
||||
};
|
||||
|
||||
export default record;
|
||||
|
||||
@@ -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<string, true> = {};
|
||||
|
||||
@@ -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<Node>();
|
||||
this.movedSet = new Set<Node>();
|
||||
this.droppedSet = new Set<Node>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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\\": [
|
||||
|
||||
@@ -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');
|
||||
|
||||
3
typings/index.d.ts
vendored
3
typings/index.d.ts
vendored
@@ -4,4 +4,5 @@ import { mirror } from './utils';
|
||||
import * as utils from './utils';
|
||||
export { EventType, IncrementalSource, MouseInteractions, ReplayerEvents, } from './types';
|
||||
declare const addCustomEvent: <T>(tag: string, payload: T) => void;
|
||||
export { record, addCustomEvent, Replayer, mirror, utils };
|
||||
declare const freezePage: () => void;
|
||||
export { record, addCustomEvent, freezePage, Replayer, mirror, utils };
|
||||
|
||||
1
typings/record/index.d.ts
vendored
1
typings/record/index.d.ts
vendored
@@ -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;
|
||||
|
||||
6
typings/record/mutation.d.ts
vendored
6
typings/record/mutation.d.ts
vendored
@@ -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;
|
||||
|
||||
4
typings/record/observer.d.ts
vendored
4
typings/record/observer.d.ts
vendored
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user