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

@@ -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,10 +1090,19 @@ export function serializeNodeWithId(
stylesheetLoadTimeout,
keepIframeSrcFn,
};
for (const childN of Array.from(n.childNodes)) {
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
if (serializedChildNode) {
serializedNode.childNodes.push(serializedChildNode);
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);
}
}
}

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
});
});