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:
@@ -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