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:
6
.changeset/rare-adults-sneeze.md
Normal file
6
.changeset/rare-adults-sneeze.md
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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\\">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
(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.',
|
||||
|
||||
@@ -2268,8 +2268,22 @@ exports[`record integration tests can record form interactions 1`] = `
|
||||
},
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"\\\\n \\",
|
||||
\\"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`] = `
|
||||
"[
|
||||
{
|
||||
@@ -4238,8 +4516,22 @@ exports[`record integration tests can use maskInputOptions to configure which ty
|
||||
},
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"\\\\n \\",
|
||||
\\"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
|
||||
@@ -6230,8 +6522,22 @@ exports[`record integration tests should mask inputs via function call 1`] = `
|
||||
},
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"\\\\n \\",
|
||||
\\"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
|
||||
@@ -9917,8 +10223,22 @@ exports[`record integration tests should not record input values if maskAllInput
|
||||
},
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"\\\\n \\",
|
||||
\\"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
|
||||
@@ -13626,8 +13946,22 @@ exports[`record integration tests should record input userTriggered values if us
|
||||
},
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"\\\\n \\",
|
||||
\\"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
|
||||
|
||||
81
packages/rrweb/test/events/bad-textarea.ts
Normal file
81
packages/rrweb/test/events/bad-textarea.ts
Normal 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;
|
||||
@@ -33,6 +33,7 @@
|
||||
<label for="password">
|
||||
<input type="password" />
|
||||
</label>
|
||||
<textarea>pre value</textarea>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1586,9 +1586,25 @@ exports[`cross origin iframes form.html should map input events correctly 1`] =
|
||||
},
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"\\\\n \\",
|
||||
\\"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,
|
||||
@@ -2583,9 +2599,25 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] =
|
||||
},
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"\\\\n \\",
|
||||
\\"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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user