Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/media-autoplay-attribute-case.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': patch
---

Stop recording `autoplay` attribute mutations on `<video>` and `<audio>` during session replay. The check compared a lowercase tag name against `Element.tagName`, which is uppercase for HTML elements, so a looping background video emitted a mutation for every `autoplay` toggle.
7 changes: 5 additions & 2 deletions packages/rrweb/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,14 @@ export function transformAttribute(
}

export function ignoreAttribute(
tagName: string,
tagName: Lowercase<string>,
name: string,
_value: unknown,
): boolean {
return (tagName === 'video' || tagName === 'audio') && name === 'autoplay';
return (
(tagName === 'video' || tagName === 'audio') &&
toLowerCase(name) === 'autoplay'
);
}

export function _isBlockedElement(
Expand Down
31 changes: 16 additions & 15 deletions packages/rrweb/rrweb/src/record/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,7 @@ export default class MutationBuffer {
}
case 'attributes': {
const target = m.target as Element;
const tagNameLower = toLowerCase(target.tagName);
const sourceAttributeName = m.attributeName as string;
const attributeNamespace = m.attributeNamespace ?? null;
let attributeName = getSerializedAttributeName(
Expand Down Expand Up @@ -765,41 +766,41 @@ export default class MutationBuffer {

let item = this.attributeMap.get(m.target);
const isIframeSrc =
target.tagName === 'IFRAME' && attributeName === 'src';
tagNameLower === 'iframe' && attributeName === 'src';
if (
isIframeSrc &&
!this.keepIframeSrcFn(value as string) &&
(target as HTMLIFrameElement).contentDocument
) {
return;
}
if (!item) {
item = {
node: m.target,
attributes: {},
styleDiff: {},
_unchangedStyles: {},
};
this.attributes.push(item);
this.attributeMap.set(m.target, item);
}

// Keep this property on inputs that used to be password inputs
// This is used to ensure we do not unmask value when using e.g. a "Show password" type button
if (
attributeName === 'type' &&
target.tagName === 'INPUT' &&
tagNameLower === 'input' &&
(m.oldValue || '').toLowerCase() === 'password'
) {
target.setAttribute('data-rr-is-password', 'true');
}

if (!ignoreAttribute(target.tagName, attributeName, value)) {
if (!ignoreAttribute(tagNameLower, attributeName, value)) {
Comment thread
pauldambra marked this conversation as resolved.
if (!item) {
item = {
node: m.target,
attributes: {},
styleDiff: {},
_unchangedStyles: {},
};
this.attributes.push(item);
this.attributeMap.set(m.target, item);
}
// Transform with the source name before representing an inaccessible
// iframe's source under the final rr_src key.
const transformedValue = transformAttribute(
this.doc,
toLowerCase(target.tagName),
tagNameLower,
toLowerCase(attributeName),
value,
target,
Expand Down Expand Up @@ -850,7 +851,7 @@ export default class MutationBuffer {
item.styleDiff[pname] = false; // delete
}
}
} else if (attributeName === 'open' && target.tagName === 'DIALOG') {
} else if (attributeName === 'open' && tagNameLower === 'dialog') {
if (target.matches('dialog:modal')) {
item.attributes['rr_open_mode'] = 'modal';
} else {
Expand Down
74 changes: 74 additions & 0 deletions packages/rrweb/rrweb/test/record/mutation-attributes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { Mirror } from '@posthog/rrweb-snapshot';
import type { attributeCursor, mutationRecord } from '@posthog/rrweb-types';
import MutationBuffer from '../../src/record/mutation';

type AttributeProbe = {
attributes: attributeCursor[];
processMutation: (m: mutationRecord) => void;
};

function createProbe() {
// Exercise the attributes branch without JSDOM's recorder/observer setup.
// Built-SDK Playwright tests cover the actual buffered payloads.
const buffer = new MutationBuffer();
Object.assign(buffer, {
blockClass: 'ph-no-capture',
blockSelector: null,
doc: document,
mirror: new Mirror(),
slimDOMOptions: {},
maskInputOptions: {},
dataURLOptions: {},
keepIframeSrcFn: () => true,
});
return buffer as unknown as AttributeProbe;
}

function attributeMutation(
target: Element,
attributeName: string,
): mutationRecord {
return {
type: 'attributes',
target,
attributeName,
attributeNamespace: null,
oldValue: null,
} as unknown as mutationRecord;
}

function recordedNames(buffer: AttributeProbe): string[] {
return buffer.attributes.flatMap((item) => Object.keys(item.attributes));
}

function mutate(target: Element, name: string, value: string) {
const buffer = createProbe();
document.body.append(target);
target.setAttribute(name, value);
buffer.processMutation(attributeMutation(target, name));
return buffer;
}

describe('attribute mutations', () => {
afterEach(() => {
document.body.innerHTML = '';
});

// `tagName` is uppercase for HTML elements, so the media check has to
// normalise it before comparing (upstream rrweb #1921).
it.each(['video', 'audio'])('ignores autoplay mutations on <%s>', (tag) => {
const buffer = mutate(document.createElement(tag), 'autoplay', '');
expect(recordedNames(buffer)).toEqual([]);
});

it('records other attributes on media elements', () => {
const buffer = mutate(document.createElement('video'), 'width', '320');
expect(buffer.attributes[0].attributes).toEqual({ width: '320' });
});

it('records autoplay on elements that are not media elements', () => {
const buffer = mutate(document.createElement('div'), 'autoplay', '');
expect(buffer.attributes[0].attributes).toEqual({ autoplay: '' });
});
});