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

* Sort attributes to make `rr_*` attributes handled last

`rr_dataURL` overwrites `src` attribute, because of this we need to evaluate `rr_dataURL` last so it doesn't accidentally get overwritten again.

* Update packages/rrweb-snapshot/src/rebuild.ts

* Refactor handling of rr_* attributes

Be a little more strict when it comes attribute types
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 81ed51c1e0
commit 65ff0efefd
4 changed files with 254 additions and 182 deletions

View File

@@ -5,6 +5,8 @@ import {
tagMap, tagMap,
elementNode, elementNode,
BuildCache, BuildCache,
attributes,
legacyAttributes,
} from './types'; } from './types';
import { isElement, Mirror } from './utils'; import { isElement, Mirror } from './utils';
@@ -142,137 +144,163 @@ function buildNode(
} else { } else {
node = doc.createElement(tagName); 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) { for (const name in n.attributes) {
if (!Object.prototype.hasOwnProperty.call(n.attributes, name)) { if (!Object.prototype.hasOwnProperty.call(n.attributes, name)) {
continue; continue;
} }
let value = n.attributes[name]; 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, // legacy fix (TODO: if `value === false` can be generated for other attrs,
// should we also omit those other attrs from build ?) // should we also omit those other attrs from build ?)
continue; continue;
} }
value =
typeof value === 'boolean' || typeof value === 'number' ? '' : value; /**
// attribute names start with rr_ are internal attributes added by rrweb * Boolean attributes are considered to be true if they're present on the element at all.
if (!name.startsWith('rr_')) { * We should set value to the empty string ("") or the attribute's name, with no leading or trailing whitespace.
const isTextarea = tagName === 'textarea' && name === 'value'; * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute#parameters
const isRemoteOrDynamicCss = */
tagName === 'style' && name === '_cssText'; if (value === true) value = '';
if (isRemoteOrDynamicCss && hackCss) {
value = addHoverClass(value, cache); if (name.startsWith('rr_')) {
} specialAttributes[name] = value;
if (isTextarea || isRemoteOrDynamicCss) { continue;
const child = doc.createTextNode(value); }
// https://github.com/rrweb-io/rrweb/issues/112
for (const c of Array.from(node.childNodes)) { const isTextarea = tagName === 'textarea' && name === 'value';
if (c.nodeType === node.TEXT_NODE) { const isRemoteOrDynamicCss = tagName === 'style' && name === '_cssText';
node.removeChild(c); 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); }
node.appendChild(child);
continue;
}
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; 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());
} }
} catch (error) {
// skip invalid attribute
}
}
try { for (const name in specialAttributes) {
if (n.isSVG && name === 'xlink:href') { const value = specialAttributes[name];
node.setAttributeNS('http://www.w3.org/1999/xlink', name, value); // handle internal attributes
} else if ( if (tagName === 'canvas' && name === 'rr_dataURL') {
name === 'onload' || const image = document.createElement('img');
name === 'onclick' || image.onload = () => {
name.substring(0, 7) === 'onmouse' const ctx = (node as HTMLCanvasElement).getContext('2d');
) { if (ctx) {
// Rename some of the more common atttributes from https://www.w3schools.com/tags/ref_eventattributes.asp ctx.drawImage(image, 0, 0, image.width, image.height);
// 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
}
} 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;
} }
};
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') { if (name === 'rr_width') {
(node as HTMLElement).style.width = value; (node as HTMLElement).style.width = value.toString();
} else if (name === 'rr_height') { } else if (name === 'rr_height') {
(node as HTMLElement).style.height = value; (node as HTMLElement).style.height = value.toString();
} else if (name === 'rr_mediaCurrentTime') { } else if (
(node as HTMLMediaElement).currentTime = n.attributes name === 'rr_mediaCurrentTime' &&
.rr_mediaCurrentTime as number; typeof value === 'number'
} else if (name === 'rr_mediaState') { ) {
switch (value) { (node as HTMLMediaElement).currentTime = value;
case 'played': } else if (name === 'rr_mediaState') {
(node as HTMLMediaElement) switch (value) {
.play() case 'played':
.catch((e) => console.warn('media playback error', e)); (node as HTMLMediaElement)
break; .play()
case 'paused': .catch((e) => console.warn('media playback error', e));
(node as HTMLMediaElement).pause(); break;
break; case 'paused':
default: (node as HTMLMediaElement).pause();
} break;
default:
} }
} }
} }

View File

@@ -676,6 +676,7 @@ function serializeElementNode(
// form fields // form fields
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') { if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
const value = (n as HTMLInputElement | HTMLTextAreaElement).value; const value = (n as HTMLInputElement | HTMLTextAreaElement).value;
const checked = (n as HTMLInputElement).checked;
if ( if (
attributes.type !== 'radio' && attributes.type !== 'radio' &&
attributes.type !== 'checkbox' && attributes.type !== 'checkbox' &&
@@ -690,8 +691,8 @@ function serializeElementNode(
maskInputOptions, maskInputOptions,
maskInputFn, maskInputFn,
}); });
} else if ((n as HTMLInputElement).checked) { } else if (checked) {
attributes.checked = (n as HTMLInputElement).checked; attributes.checked = checked;
} }
} }
if (tagName === 'option') { if (tagName === 'option') {

View File

@@ -21,8 +21,16 @@ export type documentTypeNode = {
}; };
export type attributes = { 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 = { export type elementNode = {
type: NodeType.Element; type: NodeType.Element;
tagName: string; tagName: string;

View File

@@ -1,93 +1,128 @@
/**
* @jest-environment jsdom
*/
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { addHoverClass, createCache } from '../src/rebuild'; import { addHoverClass, buildNodeWithSN, createCache } from '../src/rebuild';
import { NodeType } from '../src/types';
import { createMirror, Mirror } from '../src/utils';
function getDuration(hrtime: [number, number]) { function getDuration(hrtime: [number, number]) {
const [seconds, nanoseconds] = hrtime; const [seconds, nanoseconds] = hrtime;
return seconds * 1000 + nanoseconds / 1000000; return seconds * 1000 + nanoseconds / 1000000;
} }
describe('add hover class to hover selector related rules', function () { describe('rebuild', function () {
let cache: ReturnType<typeof createCache>; let cache: ReturnType<typeof createCache>;
let mirror: Mirror;
beforeEach(() => { beforeEach(() => {
mirror = createMirror();
cache = createCache(); cache = createCache();
}); });
it('will do nothing to css text without :hover', () => { describe('rr_dataURL', function () {
const cssText = 'body { color: white }'; it('should rebuild dataURL', function () {
expect(addHoverClass(cssText, cache)).toEqual(cssText); const dataURI =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const node = buildNodeWithSN(
{
id: 1,
tagName: 'img',
type: NodeType.Element,
attributes: {
rr_dataURL: dataURI,
src: 'http://example.com/image.png',
},
childNodes: [],
},
{
doc: document,
mirror,
hackCss: false,
cache,
},
) as HTMLImageElement;
expect(node?.src).toBe(dataURI);
});
}); });
it('can add hover class to css text', () => { describe('add hover class to hover selector related rules', function () {
const cssText = '.a:hover { color: white }'; it('will do nothing to css text without :hover', () => {
expect(addHoverClass(cssText, cache)).toEqual( const cssText = 'body { color: white }';
'.a:hover, .a.\\:hover { color: white }', expect(addHoverClass(cssText, cache)).toEqual(cssText);
); });
});
it('can add hover class when there is multi selector', () => { it('can add hover class to css text', () => {
const cssText = '.a, .b:hover, .c { color: white }'; const cssText = '.a:hover { color: white }';
expect(addHoverClass(cssText, cache)).toEqual( expect(addHoverClass(cssText, cache)).toEqual(
'.a, .b:hover, .b.\\:hover, .c { color: white }', '.a:hover, .a.\\:hover { color: white }',
); );
}); });
it('can add hover class when there is a multi selector with the same prefix', () => { it('can add hover class when there is multi selector', () => {
const cssText = '.a:hover, .a:hover::after { color: white }'; const cssText = '.a, .b:hover, .c { color: white }';
expect(addHoverClass(cssText, cache)).toEqual( expect(addHoverClass(cssText, cache)).toEqual(
'.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }', '.a, .b:hover, .b.\\:hover, .c { color: white }',
); );
}); });
it('can add hover class when :hover is not the end of selector', () => { it('can add hover class when there is a multi selector with the same prefix', () => {
const cssText = 'div:hover::after { color: white }'; const cssText = '.a:hover, .a:hover::after { color: white }';
expect(addHoverClass(cssText, cache)).toEqual( expect(addHoverClass(cssText, cache)).toEqual(
'div:hover::after, div.\\:hover::after { color: white }', '.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }',
); );
}); });
it('can add hover class when the selector has multi :hover', () => { it('can add hover class when :hover is not the end of selector', () => {
const cssText = 'a:hover b:hover { color: white }'; const cssText = 'div:hover::after { color: white }';
expect(addHoverClass(cssText, cache)).toEqual( expect(addHoverClass(cssText, cache)).toEqual(
'a:hover b:hover, a.\\:hover b.\\:hover { color: white }', 'div:hover::after, div.\\:hover::after { color: white }',
); );
}); });
it('will ignore :hover in css value', () => { it('can add hover class when the selector has multi :hover', () => {
const cssText = '.a::after { content: ":hover" }'; const cssText = 'a:hover b:hover { color: white }';
expect(addHoverClass(cssText, cache)).toEqual(cssText); expect(addHoverClass(cssText, cache)).toEqual(
}); 'a:hover b:hover, a.\\:hover b.\\:hover { color: white }',
);
});
// this benchmark is unreliable when run in parallel with other tests it('will ignore :hover in css value', () => {
it.skip('benchmark', () => { const cssText = '.a::after { content: ":hover" }';
const cssText = fs.readFileSync( expect(addHoverClass(cssText, cache)).toEqual(cssText);
path.resolve(__dirname, './css/benchmark.css'), });
'utf8',
);
const start = process.hrtime();
addHoverClass(cssText, cache);
const end = process.hrtime(start);
const duration = getDuration(end);
expect(duration).toBeLessThan(100);
});
it('should be a lot faster to add a hover class to a previously processed css string', () => { // this benchmark is unreliable when run in parallel with other tests
const factor = 100; it.skip('benchmark', () => {
const cssText = fs.readFileSync(
path.resolve(__dirname, './css/benchmark.css'),
'utf8',
);
const start = process.hrtime();
addHoverClass(cssText, cache);
const end = process.hrtime(start);
const duration = getDuration(end);
expect(duration).toBeLessThan(100);
});
let cssText = fs.readFileSync( it('should be a lot faster to add a hover class to a previously processed css string', () => {
path.resolve(__dirname, './css/benchmark.css'), const factor = 100;
'utf8',
);
const start = process.hrtime(); let cssText = fs.readFileSync(
addHoverClass(cssText, cache); path.resolve(__dirname, './css/benchmark.css'),
const end = process.hrtime(start); 'utf8',
);
const cachedStart = process.hrtime(); const start = process.hrtime();
addHoverClass(cssText, cache); addHoverClass(cssText, cache);
const cachedEnd = process.hrtime(cachedStart); const end = process.hrtime(start);
expect(getDuration(cachedEnd) * factor).toBeLessThan(getDuration(end)); const cachedStart = process.hrtime();
addHoverClass(cssText, cache);
const cachedEnd = process.hrtime(cachedStart);
expect(getDuration(cachedEnd) * factor).toBeLessThan(getDuration(end));
});
}); });
}); });