Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bugfix: Sort attributes to make rr_* attributes handled last #970

Merged
merged 4 commits into from
Sep 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 143 additions & 115 deletions packages/rrweb-snapshot/src/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
tagMap,
elementNode,
BuildCache,
attributes,
legacyAttributes,
} from './types';
import { isElement, Mirror } from './utils';

Expand Down Expand Up @@ -142,137 +144,163 @@ function buildNode(
} else {
node = doc.createElement(tagName);
}
/**
* Attribute names start with `rr_` are internal attributes added by rrweb.
* They often overwrite other attributes on the element.
* We need to parse them last so they can overwrite conflicting attributes.
*/
const specialAttributes: attributes = {};
for (const name in n.attributes) {
if (!Object.prototype.hasOwnProperty.call(n.attributes, name)) {
continue;
}
let value = n.attributes[name];
if (tagName === 'option' && name === 'selected' && value === false) {
if (
tagName === 'option' &&
name === 'selected' &&
(value as legacyAttributes[typeof name]) === false
) {
// legacy fix (TODO: if `value === false` can be generated for other attrs,
// should we also omit those other attrs from build ?)
continue;
}
value =
typeof value === 'boolean' || typeof value === 'number' ? '' : value;
// attribute names start with rr_ are internal attributes added by rrweb
if (!name.startsWith('rr_')) {
const isTextarea = tagName === 'textarea' && name === 'value';
const isRemoteOrDynamicCss =
tagName === 'style' && name === '_cssText';
if (isRemoteOrDynamicCss && hackCss) {
value = addHoverClass(value, cache);
}
if (isTextarea || isRemoteOrDynamicCss) {
const child = 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);
}

/**
* Boolean attributes are considered to be true if they're present on the element at all.
* We should set value to the empty string ("") or the attribute's name, with no leading or trailing whitespace.
* @see https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute#parameters
*/
if (value === true) value = '';

if (name.startsWith('rr_')) {
specialAttributes[name] = value;
continue;
}

const isTextarea = tagName === 'textarea' && name === 'value';
const isRemoteOrDynamicCss = tagName === 'style' && name === '_cssText';
if (isRemoteOrDynamicCss && hackCss && typeof value === 'string') {
value = addHoverClass(value, cache);
}
if ((isTextarea || isRemoteOrDynamicCss) && typeof value === 'string') {
const child = 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);
continue;
}
node.appendChild(child);
continue;
}

try {
if (n.isSVG && name === 'xlink:href') {
node.setAttributeNS('http://www.w3.org/1999/xlink', name, value);
} else if (
name === 'onload' ||
name === 'onclick' ||
name.substring(0, 7) === 'onmouse'
) {
// Rename some of the more common atttributes from https://www.w3schools.com/tags/ref_eventattributes.asp
// as setting them triggers a console.error (which shows up despite the try/catch)
// Assumption: these attributes are not used to css
node.setAttribute('_' + name, value);
} else if (
tagName === 'meta' &&
n.attributes['http-equiv'] === 'Content-Security-Policy' &&
name === 'content'
) {
// If CSP contains style-src and inline-style is disabled, there will be an error "Refused to apply inline style because it violates the following Content Security Policy directive: style-src '*'".
// And the function insertStyleRules in rrweb replayer will throw an error "Uncaught TypeError: Cannot read property 'insertRule' of null".
node.setAttribute('csp-content', value);
continue;
} else if (
tagName === 'link' &&
n.attributes.rel === 'preload' &&
n.attributes.as === 'script'
) {
// ignore
} else if (
tagName === 'link' &&
n.attributes.rel === 'prefetch' &&
typeof n.attributes.href === 'string' &&
n.attributes.href.endsWith('.js')
) {
// ignore
} else if (
tagName === 'img' &&
n.attributes.srcset &&
n.attributes.rr_dataURL
) {
// backup original img srcset
node.setAttribute(
'rrweb-original-srcset',
n.attributes.srcset as string,
);
} else {
node.setAttribute(name, value);
}
} catch (error) {
// skip invalid attribute
try {
if (n.isSVG && name === 'xlink:href') {
node.setAttributeNS(
'http://www.w3.org/1999/xlink',
name,
value.toString(),
);
} else if (
name === 'onload' ||
name === 'onclick' ||
name.substring(0, 7) === 'onmouse'
) {
// Rename some of the more common atttributes from https://www.w3schools.com/tags/ref_eventattributes.asp
// as setting them triggers a console.error (which shows up despite the try/catch)
// Assumption: these attributes are not used to css
node.setAttribute('_' + name, value.toString());
} else if (
tagName === 'meta' &&
n.attributes['http-equiv'] === 'Content-Security-Policy' &&
name === 'content'
) {
// If CSP contains style-src and inline-style is disabled, there will be an error "Refused to apply inline style because it violates the following Content Security Policy directive: style-src '*'".
// And the function insertStyleRules in rrweb replayer will throw an error "Uncaught TypeError: Cannot read property 'insertRule' of null".
node.setAttribute('csp-content', value.toString());
continue;
} else if (
tagName === 'link' &&
n.attributes.rel === 'preload' &&
n.attributes.as === 'script'
) {
// ignore
} else if (
tagName === 'link' &&
n.attributes.rel === 'prefetch' &&
typeof n.attributes.href === 'string' &&
n.attributes.href.endsWith('.js')
) {
// ignore
} else if (
tagName === 'img' &&
n.attributes.srcset &&
n.attributes.rr_dataURL
) {
// backup original img srcset
node.setAttribute(
'rrweb-original-srcset',
n.attributes.srcset as string,
);
} else {
node.setAttribute(name, value.toString());
}
} else {
// handle internal attributes
if (tagName === 'canvas' && name === 'rr_dataURL') {
const image = document.createElement('img');
image.onload = () => {
const ctx = (node as HTMLCanvasElement).getContext('2d');
if (ctx) {
ctx.drawImage(image, 0, 0, image.width, image.height);
}
};
image.src = value;
type RRCanvasElement = {
RRNodeType: NodeType;
rr_dataURL: string;
};
// If the canvas element is created in RRDom runtime (seeking to a time point), the canvas context isn't supported. So the data has to be stored and not handled until diff process. https://github.com/rrweb-io/rrweb/pull/944
if (((node as unknown) as RRCanvasElement).RRNodeType)
((node as unknown) as RRCanvasElement).rr_dataURL = value;
} else if (tagName === 'img' && name === 'rr_dataURL') {
const image = node as HTMLImageElement;
if (!image.currentSrc.startsWith('data:')) {
// Backup original img src. It may not have been set yet.
image.setAttribute(
'rrweb-original-src',
n.attributes.src as string,
);
image.src = value;
} catch (error) {
// skip invalid attribute
}
}

for (const name in specialAttributes) {
const value = specialAttributes[name];
// handle internal attributes
if (tagName === 'canvas' && name === 'rr_dataURL') {
const image = document.createElement('img');
image.onload = () => {
const ctx = (node as HTMLCanvasElement).getContext('2d');
if (ctx) {
ctx.drawImage(image, 0, 0, image.width, image.height);
}
};
image.src = value.toString();
type RRCanvasElement = {
RRNodeType: NodeType;
rr_dataURL: string;
};
// If the canvas element is created in RRDom runtime (seeking to a time point), the canvas context isn't supported. So the data has to be stored and not handled until diff process. https://github.com/rrweb-io/rrweb/pull/944
if (((node as unknown) as RRCanvasElement).RRNodeType)
((node as unknown) as RRCanvasElement).rr_dataURL = value.toString();
} else if (tagName === 'img' && name === 'rr_dataURL') {
const image = node as HTMLImageElement;
if (!image.currentSrc.startsWith('data:')) {
// Backup original img src. It may not have been set yet.
image.setAttribute(
'rrweb-original-src',
n.attributes.src as string,
);
image.src = value.toString();
}
}

if (name === 'rr_width') {
(node as HTMLElement).style.width = value;
} else if (name === 'rr_height') {
(node as HTMLElement).style.height = value;
} else if (name === 'rr_mediaCurrentTime') {
(node as HTMLMediaElement).currentTime = n.attributes
.rr_mediaCurrentTime as number;
} else if (name === 'rr_mediaState') {
switch (value) {
case 'played':
(node as HTMLMediaElement)
.play()
.catch((e) => console.warn('media playback error', e));
break;
case 'paused':
(node as HTMLMediaElement).pause();
break;
default:
}
if (name === 'rr_width') {
(node as HTMLElement).style.width = value.toString();
} else if (name === 'rr_height') {
(node as HTMLElement).style.height = value.toString();
} else if (
name === 'rr_mediaCurrentTime' &&
typeof value === 'number'
) {
(node as HTMLMediaElement).currentTime = value;
} else if (name === 'rr_mediaState') {
switch (value) {
case 'played':
(node as HTMLMediaElement)
.play()
.catch((e) => console.warn('media playback error', e));
break;
case 'paused':
(node as HTMLMediaElement).pause();
break;
default:
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions packages/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ function serializeElementNode(
// form fields
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
const value = (n as HTMLInputElement | HTMLTextAreaElement).value;
const checked = (n as HTMLInputElement).checked;
if (
attributes.type !== 'radio' &&
attributes.type !== 'checkbox' &&
Expand All @@ -690,8 +691,8 @@ function serializeElementNode(
maskInputOptions,
maskInputFn,
});
} else if ((n as HTMLInputElement).checked) {
attributes.checked = (n as HTMLInputElement).checked;
} else if (checked) {
attributes.checked = checked;
}
}
if (tagName === 'option') {
Expand Down
10 changes: 9 additions & 1 deletion packages/rrweb-snapshot/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ export type documentTypeNode = {
};

export type attributes = {
[key: string]: string | number | boolean;
[key: string]: string | number | true;
};
export type legacyAttributes = {
/**
* @deprecated old bug in rrweb was causing these to always be set
* @see https://github.com/rrweb-io/rrweb/pull/651
*/
selected: false;
};

export type elementNode = {
type: NodeType.Element;
tagName: string;
Expand Down
Loading