Fix serialization and mutation of <textarea> elements (#1351)

* Fix serialization and mutation of <textarea> elements taking account the duality that the value can be set in either the child node, or in the value _parameter_ (not attribute)

* Backwards compatibility: Bug fix and regression test for #112
 - this is to fix up 'historical' recordings, as duplicate textarea content should no longer be being created at record time
 - new test shows what the snapshot generated by previous versions of rrweb used to look like, hence 'bad'
 - original 0efe23f04a fix either didn't work or no longer works due to childNodes being appended subsequent to this part of the code
 - during review, we also verified that the `_cssText` case should still be handled okay, as there's currently no scenario where csstext is present with css child nodes of a <style>

* Masking: Fix that textarea values were being missed by the masking system if the value was recorded as a child node
 - I didn't notice that form.html was used in other tests, so lucky that I noticed that those tests also should have the 'pre value' masked out

* Simplify by always storing the textarea value in the `.value` attribute (from it's DOM property) and not as a childNode. It should still be rebuilt as a childNode rather than a property
---------

Authored-by: eoghanmurray <eoghan@getthere.ie>
This commit is contained in:
Eoghan Murray
2026-04-01 12:00:00 +08:00
committed by GitHub
parent d1d0c7f366
commit a8d93986f4
16 changed files with 686 additions and 55 deletions

View File

@@ -0,0 +1,6 @@
---
'rrweb-snapshot': patch
'rrweb': patch
---
Don't double-record the values of <textarea>s when they already have some content prefilled #1301

View File

@@ -5,7 +5,8 @@
"scripts": {
"prepare": "npm run prepack",
"prepack": "npm run bundle && npm run typings",
"test": "jest",
"retest": "jest",
"test": "yarn bundle && yarn retest",
"test:watch": "jest --watch",
"test:update": "jest --updateSnapshot",
"bundle": "rollup --config",

View File

@@ -5,7 +5,6 @@ import {
tagMap,
elementNode,
BuildCache,
attributes,
legacyAttributes,
} from './types';
import { isElement, Mirror, isNodeMetaEqual } from './utils';
@@ -200,14 +199,9 @@ function buildNode(
value = addHoverClass(value, cache);
}
if ((isTextarea || isRemoteOrDynamicCss) && typeof value === 'string') {
const child = doc.createTextNode(value);
node.appendChild(doc.createTextNode(value));
// https://github.com/rrweb-io/rrweb/issues/112
for (const c of Array.from(node.childNodes)) {
if (c.nodeType === node.TEXT_NODE) {
node.removeChild(c);
}
}
node.appendChild(child);
n.childNodes = []; // value overrides childNodes
continue;
}

View File

@@ -10,6 +10,7 @@ import {
MaskInputFn,
KeepIframeSrcFn,
ICanvas,
elementNode,
serializedElementNodeWithId,
} from './types';
import {
@@ -672,10 +673,9 @@ function serializeElementNode(
attributes.type !== 'button' &&
value
) {
const type = getInputType(n);
attributes.value = maskInputValue({
element: n,
type,
type: getInputType(n),
tagName,
value,
maskInputOptions,
@@ -1090,12 +1090,21 @@ export function serializeNodeWithId(
stylesheetLoadTimeout,
keepIframeSrcFn,
};
if (
serializedNode.type === NodeType.Element &&
serializedNode.tagName === 'textarea' &&
(serializedNode as elementNode).attributes.value !== undefined
) {
// value parameter in DOM reflects the correct value, so ignore childNode
} else {
for (const childN of Array.from(n.childNodes)) {
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
if (serializedChildNode) {
serializedNode.childNodes.push(serializedChildNode);
}
}
}
if (isElement(n) && n.shadowRoot) {
for (const childN of Array.from(n.shadowRoot.childNodes)) {

View File

@@ -247,6 +247,7 @@ exports[`integration tests [html file]: form-fields.html 1`] = `
</label>
<label for=\\"textarea\\">
<textarea name=\\"\\" id=\\"\\" cols=\\"30\\" rows=\\"10\\">1234</textarea>
<textarea name=\\"\\" id=\\"\\" cols=\\"30\\" rows=\\"10\\">1234</textarea>
</label>
<label for=\\"select\\">
<select name=\\"\\" id=\\"\\" value=\\"2\\">

View File

@@ -20,6 +20,7 @@
</label>
<label for="textarea">
<textarea name="" id="" cols="30" rows="10"></textarea>
<textarea name="" id="" cols="30" rows="10">-1</textarea>
</label>
<label for="select">
<select name="" id="">
@@ -36,7 +37,8 @@
document.querySelector('input[type="text"]').value = '1';
document.querySelector('input[type="radio"]').checked = true;
document.querySelector('input[type="checkbox"]').checked = true;
document.querySelector('textarea').value = '1234';
document.querySelector('textarea:empty').value = '1234';
document.querySelector('textarea:not(:empty)').value = '1234';
document.querySelector('select').value = '2';
</script>
</html>

View File

@@ -7,7 +7,7 @@ import {
serializeNodeWithId,
_isBlockedElement,
} from '../src/snapshot';
import { serializedNodeWithId } from '../src/types';
import { serializedNodeWithId, elementNode } from '../src/types';
import { Mirror } from '../src/utils';
describe('absolute url to stylesheet', () => {
@@ -218,3 +218,42 @@ describe('scrollTop/scrollLeft', () => {
});
});
});
describe('form', () => {
const serializeNode = (node: Node): serializedNodeWithId | null => {
return serializeNodeWithId(node, {
doc: document,
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
maskTextClass: 'maskmask',
maskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
maskInputFn: undefined,
slimDOMOptions: {},
newlyAddedElement: false,
});
};
const render = (html: string): HTMLTextAreaElement => {
document.write(html);
return document.querySelector('textarea')!;
};
it('should record textarea values once', () => {
const el = render(`<textarea>Lorem ipsum</textarea>`);
const sel = serializeNode(el) as elementNode;
// we serialize according to where the DOM stores the value, not how
// the HTML stores it (this is so that maskInputValue can work over
// inputs/textareas/selects in a uniform way)
expect(sel).toMatchObject({
attributes: {
value: 'Lorem ipsum',
},
});
expect(sel?.childNodes).toEqual([]); // shouldn't be stored in childNodes while in transit
});
});

View File

@@ -12,6 +12,7 @@
"test": "yarn test:headless",
"test:watch": "yarn test:headless -- --watch",
"test:update": "yarn test:headless -- --updateSnapshot",
"retest:update": "PUPPETEER_HEADLESS=true yarn retest -- --updateSnapshot",
"repl": "yarn bundle:browser && node scripts/repl.js",
"live-stream": "yarn bundle:browser && node scripts/stream.js",
"dev": "yarn bundle:browser --watch",

View File

@@ -285,7 +285,11 @@ export default class MutationBuffer {
return nextId;
};
const pushAdd = (n: Node) => {
if (!n.parentNode || !inDom(n)) {
if (
!n.parentNode ||
!inDom(n) ||
(n.parentNode as Element).tagName === 'TEXTAREA'
) {
return;
}
const parentId = isShadowRoot(n.parentNode)
@@ -435,10 +439,17 @@ export default class MutationBuffer {
const payload = {
texts: this.texts
.map((text) => ({
id: this.mirror.getId(text.node),
.map((text) => {
const n = text.node;
if ((n.parentNode as Element).tagName === 'TEXTAREA') {
// the node is being ignored as it isn't in the mirror, so shift mutation to attributes on parent textarea
this.genTextAreaValueMutation(n.parentNode as HTMLTextAreaElement);
}
return {
id: this.mirror.getId(n),
value: text.value,
}))
};
})
// no need to include them on added elements, as they have just been serialized with up to date attribubtes
.filter((text) => !addedIds.has(text.id))
// text mutation's id was not in the mirror map means the target node has been removed
@@ -497,6 +508,24 @@ export default class MutationBuffer {
this.mutationCb(payload);
};
private genTextAreaValueMutation = (textarea: HTMLTextAreaElement) => {
let item = this.attributeMap.get(textarea);
if (!item) {
item = {
node: textarea,
attributes: {},
styleDiff: {},
_unchangedStyles: {},
};
this.attributes.push(item);
this.attributeMap.set(textarea, item);
}
item.attributes.value = Array.from(
textarea.childNodes,
(cn) => cn.textContent || '',
).join('');
};
private processMutation = (m: mutationRecord) => {
if (isIgnored(m.target, this.mirror)) {
return;
@@ -642,6 +671,12 @@ export default class MutationBuffer {
if (isBlocked(m.target, this.blockClass, this.blockSelector, true))
return;
if ((m.target as Element).tagName === 'TEXTAREA') {
// children would be ignored in genAdds as they aren't in the mirror
this.genTextAreaValueMutation(m.target as HTMLTextAreaElement);
return; // any removedNodes won't have been in mirror either
}
m.addedNodes.forEach((n) => this.genAdds(n, m.target));
m.removedNodes.forEach((n) => {
const nodeId = this.mirror.getId(n);

View File

@@ -1564,6 +1564,8 @@ export class Replayer {
const childNodeArray = Array.isArray(parent.childNodes)
? parent.childNodes
: Array.from(parent.childNodes);
// This should be redundant now as we are either recording the value or the childNode, and not both
// keeping around for backwards compatibility with old bad double data, see
// https://github.com/rrweb-io/rrweb/issues/745
// parent is textarea, will only keep one child node as the value
@@ -1761,10 +1763,24 @@ export class Replayer {
// for safe
}
}
if (attributeName === 'value' && target.nodeName === 'TEXTAREA') {
// this may or may not have an effect on the value property (which is what is displayed)
// depending on whether the textarea has been modified by the user yet
// TODO: replaceChildNodes is not available in RRDom
const textarea = target as TNode;
textarea.childNodes.forEach((c) =>
textarea.removeChild(c as TNode),
);
const tn = target.ownerDocument?.createTextNode(value);
if (tn) {
textarea.appendChild(tn as TNode);
}
} else {
(target as Element | RRElement).setAttribute(
attributeName,
value,
);
}
} catch (error) {
this.warn(
'An error occurred may due to the checkout feature.',

View File

@@ -2270,6 +2270,20 @@ exports[`record integration tests can record form interactions 1`] = `
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 61
},
{
\\"type\\": 2,
\\"tagName\\": \\"textarea\\",
\\"attributes\\": {
\\"value\\": \\"pre value\\"
},
\\"childNodes\\": [],
\\"id\\": 62
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 63
}
],
\\"id\\": 18
@@ -2277,7 +2291,7 @@ exports[`record integration tests can record form interactions 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 62
\\"id\\": 64
},
{
\\"type\\": 2,
@@ -2287,15 +2301,15 @@ exports[`record integration tests can record form interactions 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 64
\\"id\\": 66
}
],
\\"id\\": 63
\\"id\\": 65
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 65
\\"id\\": 67
}
],
\\"id\\": 16
@@ -3811,6 +3825,270 @@ exports[`record integration tests can record style changes compactly and preserv
]"
`;
exports[`record integration tests can record textarea mutations correctly 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\\": {
\\"lang\\": \\"en\\"
},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"charset\\": \\"UTF-8\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"name\\": \\"viewport\\",
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
},
\\"childNodes\\": [],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 9
},
{
\\"type\\": 2,
\\"tagName\\": \\"title\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Empty\\",
\\"id\\": 11
}
],
\\"id\\": 10
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 12
}
],
\\"id\\": 4
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 13
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 15
},
{
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {
\\"id\\": \\"one\\"
},
\\"childNodes\\": [],
\\"id\\": 16
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 19
}
],
\\"id\\": 18
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 20
}
],
\\"id\\": 14
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 14,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"textarea\\",
\\"attributes\\": {
\\"value\\": \\"pre value\\"
},
\\"childNodes\\": [],
\\"id\\": 21
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 21,
\\"attributes\\": {
\\"value\\": \\"ok\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 21,
\\"attributes\\": {
\\"value\\": \\"ok3\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 5,
\\"id\\": 21
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"1ok3\\",
\\"isChecked\\": false,
\\"id\\": 21
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 21,
\\"attributes\\": {
\\"value\\": \\"ignore\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"12ok3\\",
\\"isChecked\\": false,
\\"id\\": 21
}
}
]"
`;
exports[`record integration tests can use maskInputOptions to configure which type of inputs should be masked 1`] = `
"[
{
@@ -4240,6 +4518,20 @@ exports[`record integration tests can use maskInputOptions to configure which ty
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 61
},
{
\\"type\\": 2,
\\"tagName\\": \\"textarea\\",
\\"attributes\\": {
\\"value\\": \\"pre value\\"
},
\\"childNodes\\": [],
\\"id\\": 62
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 63
}
],
\\"id\\": 18
@@ -4247,7 +4539,7 @@ exports[`record integration tests can use maskInputOptions to configure which ty
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 62
\\"id\\": 64
},
{
\\"type\\": 2,
@@ -4257,15 +4549,15 @@ exports[`record integration tests can use maskInputOptions to configure which ty
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 64
\\"id\\": 66
}
],
\\"id\\": 63
\\"id\\": 65
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 65
\\"id\\": 67
}
],
\\"id\\": 16
@@ -6232,6 +6524,20 @@ exports[`record integration tests should mask inputs via function call 1`] = `
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 61
},
{
\\"type\\": 2,
\\"tagName\\": \\"textarea\\",
\\"attributes\\": {
\\"value\\": \\"*********\\"
},
\\"childNodes\\": [],
\\"id\\": 62
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 63
}
],
\\"id\\": 18
@@ -6239,7 +6545,7 @@ exports[`record integration tests should mask inputs via function call 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 62
\\"id\\": 64
},
{
\\"type\\": 2,
@@ -6249,15 +6555,15 @@ exports[`record integration tests should mask inputs via function call 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 64
\\"id\\": 66
}
],
\\"id\\": 63
\\"id\\": 65
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 65
\\"id\\": 67
}
],
\\"id\\": 16
@@ -9919,6 +10225,20 @@ exports[`record integration tests should not record input values if maskAllInput
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 61
},
{
\\"type\\": 2,
\\"tagName\\": \\"textarea\\",
\\"attributes\\": {
\\"value\\": \\"*********\\"
},
\\"childNodes\\": [],
\\"id\\": 62
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 63
}
],
\\"id\\": 18
@@ -9926,7 +10246,7 @@ exports[`record integration tests should not record input values if maskAllInput
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 62
\\"id\\": 64
},
{
\\"type\\": 2,
@@ -9936,15 +10256,15 @@ exports[`record integration tests should not record input values if maskAllInput
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 64
\\"id\\": 66
}
],
\\"id\\": 63
\\"id\\": 65
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 65
\\"id\\": 67
}
],
\\"id\\": 16
@@ -13628,6 +13948,20 @@ exports[`record integration tests should record input userTriggered values if us
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 61
},
{
\\"type\\": 2,
\\"tagName\\": \\"textarea\\",
\\"attributes\\": {
\\"value\\": \\"pre value\\"
},
\\"childNodes\\": [],
\\"id\\": 62
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 63
}
],
\\"id\\": 18
@@ -13635,7 +13969,7 @@ exports[`record integration tests should record input userTriggered values if us
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 62
\\"id\\": 64
},
{
\\"type\\": 2,
@@ -13645,15 +13979,15 @@ exports[`record integration tests should record input userTriggered values if us
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 64
\\"id\\": 66
}
],
\\"id\\": 63
\\"id\\": 65
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 65
\\"id\\": 67
}
],
\\"id\\": 16

View File

@@ -0,0 +1,81 @@
import { EventType, IncrementalSource } from '@rrweb/types';
import type { eventWithTime } from '@rrweb/types';
const now = Date.now();
const events: eventWithTime[] = [
{
type: EventType.DomContentLoaded,
data: {},
timestamp: now,
},
{
type: EventType.Load,
data: {},
timestamp: now + 50,
},
{
type: EventType.Meta,
data: {
href: 'http://localhost',
width: 1000,
height: 800,
},
timestamp: now + 50,
},
// full snapshot:
{
data: {
node: {
id: 1,
type: 0,
childNodes: [
{ id: 2, name: 'html', type: 1, publicId: '', systemId: '' },
{
id: 3,
type: 2,
tagName: 'html',
attributes: { lang: 'en' },
childNodes: [
{
id: 4,
type: 2,
tagName: 'head',
attributes: {},
childNodes: [],
},
{
id: 5,
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{
id: 6,
type: 2,
tagName: 'textarea',
attributes: {
value: 'this value is used for replay',
},
childNodes: [
{
type: 3,
textContent:
'this value is IGNORED for replay (but was present as a duplicte in legacy recordings)',
id: 7,
},
],
},
],
},
],
},
],
},
initialOffset: { top: 0, left: 0 },
},
type: EventType.FullSnapshot,
timestamp: now + 50,
},
];
export default events;

View File

@@ -33,6 +33,7 @@
<label for="password">
<input type="password" />
</label>
<textarea>pre value</textarea>
</form>
</body>
</html>

View File

@@ -105,6 +105,69 @@ describe('record integration tests', function (this: ISuite) {
assertSnapshot(snapshots);
});
it('can record textarea mutations correctly', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'empty.html'));
await waitForRAF(page); // ensure mutations aren't included in fullsnapshot
await page.evaluate(() => {
const ta = document.createElement('textarea');
ta.innerText = 'pre value';
document.body.append(ta);
});
await page.evaluate(() => {
const t = document.querySelector('textarea') as HTMLTextAreaElement;
t.innerText = 'ok'; // this mutation should be recorded
});
await page.evaluate(() => {
const t = document.querySelector('textarea') as HTMLTextAreaElement;
(t.childNodes[0] as Text).appendData('3'); // this mutation is also valid
});
await page.type('textarea', '1'); // types (inserts) at index 0, in front of existing text
await page.evaluate(() => {
const t = document.querySelector('textarea') as HTMLTextAreaElement;
// user has typed so childNode content should now be ignored
(t.childNodes[0] as Text).data = 'igno';
(t.childNodes[0] as Text).appendData('re');
// this mutation is currently emitted, and shows up in snapshot
// but we will check that it doesn't have any effect on the value
// there is nothing explicit in rrweb which enforces this, but this test may protect against
// a future change where a mutation on a textarea incorrectly updates the .value
});
await page.type('textarea', '2'); // cursor is at index 1
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
// check after each mutation and text input
const replayTextareaValues = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(window.snapshots);
const vals = [];
window.snapshots.filter((e)=>e.data.attributes || e.data.source === 5).forEach((e)=>{
replayer.pause((e.timestamp - window.snapshots[0].timestamp)+1);
let ts = replayer.iframe.contentDocument.querySelector('textarea');
vals.push((e.data.source === 0 ? 'Mutation' : 'User') + ':' + ts.value);
});
vals;
`);
expect(replayTextareaValues).toEqual([
'Mutation:pre value',
'Mutation:ok',
'Mutation:ok3',
'User:1ok3',
'Mutation:1ok3', // if this gets set to 'ignore', it's an error, as the 'user' has modified the textarea
'User:12ok3',
]);
});
it('can record childList mutations', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');

View File

@@ -1589,6 +1589,22 @@ exports[`cross origin iframes form.html should map input events correctly 1`] =
\\"textContent\\": \\"\\\\n \\",
\\"rootId\\": 11,
\\"id\\": 73
},
{
\\"type\\": 2,
\\"tagName\\": \\"textarea\\",
\\"attributes\\": {
\\"value\\": \\"pre value\\"
},
\\"childNodes\\": [],
\\"rootId\\": 11,
\\"id\\": 74
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"rootId\\": 11,
\\"id\\": 75
}
],
\\"rootId\\": 11,
@@ -1598,7 +1614,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] =
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"rootId\\": 11,
\\"id\\": 74
\\"id\\": 76
}
],
\\"rootId\\": 11,
@@ -2586,6 +2602,22 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] =
\\"textContent\\": \\"\\\\n \\",
\\"rootId\\": 11,
\\"id\\": 73
},
{
\\"type\\": 2,
\\"tagName\\": \\"textarea\\",
\\"attributes\\": {
\\"value\\": \\"pre value\\"
},
\\"childNodes\\": [],
\\"rootId\\": 11,
\\"id\\": 74
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"rootId\\": 11,
\\"id\\": 75
}
],
\\"rootId\\": 11,
@@ -2595,7 +2627,7 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] =
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"rootId\\": 11,
\\"id\\": 74
\\"id\\": 76
}
],
\\"rootId\\": 11,

View File

@@ -16,6 +16,7 @@ import inputEvents from './events/input';
import iframeEvents from './events/iframe';
import selectionEvents from './events/selection';
import shadowDomEvents from './events/shadow-dom';
import textareaEvents from './events/bad-textarea';
import StyleSheetTextMutation from './events/style-sheet-text-mutation';
import canvasInIframe from './events/canvas-in-iframe';
import adoptedStyleSheet from './events/adopted-style-sheet';
@@ -1092,4 +1093,19 @@ describe('replayer', function () {
// If the custom element is defined, the display value will be 'block'.
expect(displayValue).toEqual('block');
});
it('can deal with legacy duplicate/conflicting values on textareas', async () => {
await page.evaluate(`events = ${JSON.stringify(textareaEvents)}`);
const displayValue = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(100);
const textarea = replayer.iframe.contentDocument.querySelector('textarea');
textarea.value;
`);
// If the custom element is not defined, the display value will be 'none'.
// If the custom element is defined, the display value will be 'block'.
expect(displayValue).toEqual('this value is used for replay');
});
});