Skip to content

Commit

Permalink
Fix serialization and mutation of <textarea> elements (#1351)
Browse files Browse the repository at this point in the history
* 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 0efe23f 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 <[email protected]>
  • Loading branch information
eoghanmurray authored Dec 1, 2023
1 parent 07ac5c9 commit a2be77b
Show file tree
Hide file tree
Showing 16 changed files with 686 additions and 55 deletions.
6 changes: 6 additions & 0 deletions .changeset/rare-adults-sneeze.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion packages/rrweb-snapshot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 2 additions & 8 deletions packages/rrweb-snapshot/src/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
tagMap,
elementNode,
BuildCache,
attributes,
legacyAttributes,
} from './types';
import { isElement, Mirror, isNodeMetaEqual } from './utils';
Expand Down Expand Up @@ -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;
}

Expand Down
21 changes: 15 additions & 6 deletions packages/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
MaskInputFn,
KeepIframeSrcFn,
ICanvas,
elementNode,
serializedElementNodeWithId,
} from './types';
import {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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\\">
Expand Down
4 changes: 3 additions & 1 deletion packages/rrweb-snapshot/test/html/form-fields.html
Original file line number Diff line number Diff line change
Expand Up @@ -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="">
Expand All @@ -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>
41 changes: 40 additions & 1 deletion packages/rrweb-snapshot/test/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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
});
});
1 change: 1 addition & 0 deletions packages/rrweb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 40 additions & 5 deletions packages/rrweb/src/record/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -435,10 +439,17 @@ export default class MutationBuffer {

const payload = {
texts: this.texts
.map((text) => ({
id: this.mirror.getId(text.node),
value: text.value,
}))
.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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 20 additions & 4 deletions packages/rrweb/src/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1761,10 +1763,24 @@ export class Replayer {
// for safe
}
}
(target as Element | RRElement).setAttribute(
attributeName,
value,
);
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.',
Expand Down
Loading

0 comments on commit a2be77b

Please sign in to comment.