Skip to content

Commit

Permalink
Merge pull request #25 from amplitude/AMP-101866-textarea-serializati…
Browse files Browse the repository at this point in the history
…on-fix

AMP-101866 textarea serialization fix
  • Loading branch information
lewgordon-amplitude authored Aug 1, 2024
2 parents 487de10 + fb39603 commit e9e6a53
Show file tree
Hide file tree
Showing 18 changed files with 706 additions and 58 deletions.
5 changes: 5 additions & 0 deletions .changeset/moody-dots-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@amplitude/rrweb': patch
---

use WeakMap for faster attributeCursor lookup while processing attribute mutations
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 @@
---
'@amplitude/rrweb-snapshot': patch
'@amplitude/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 @@ -216,14 +215,9 @@ function buildNode(
value = adaptCssForReplay(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 @@ -676,10 +677,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 @@ -1069,10 +1069,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
52 changes: 44 additions & 8 deletions packages/rrweb/src/record/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export default class MutationBuffer {

private texts: textCursor[] = [];
private attributes: attributeCursor[] = [];
private attributeMap = new WeakMap<Node, attributeCursor>();
private removes: removedNodeMutation[] = [];
private mapRemoves: Node[] = [];

Expand Down Expand Up @@ -283,7 +284,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 @@ -433,10 +438,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 @@ -485,6 +497,7 @@ export default class MutationBuffer {
// reset
this.texts = [];
this.attributes = [];
this.attributeMap = new WeakMap<Node, attributeCursor>();
this.removes = [];
this.addedSet = new Set<Node>();
this.movedSet = new Set<Node>();
Expand All @@ -494,6 +507,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 @@ -546,9 +577,7 @@ export default class MutationBuffer {
return;
}

let item: attributeCursor | undefined = this.attributes.find(
(a) => a.node === m.target,
);
let item = this.attributeMap.get(m.target);
if (
target.tagName === 'IFRAME' &&
attributeName === 'src' &&
Expand All @@ -570,6 +599,7 @@ export default class MutationBuffer {
_unchangedStyles: {},
};
this.attributes.push(item);
this.attributeMap.set(m.target, item);
}

// Keep this property on inputs that used to be password inputs
Expand Down Expand Up @@ -637,6 +667,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 @@ -1554,6 +1554,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 @@ -1751,10 +1753,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 e9e6a53

Please sign in to comment.