diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 978aa87d27..c5c317bfc9 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -5,6 +5,8 @@ import { tagMap, elementNode, BuildCache, + attributes, + legacyAttributes, } from './types'; import { isElement, Mirror } from './utils'; @@ -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: } } } diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 144555e0a2..a947b073bb 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -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' && @@ -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') { diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index 488be6eaa1..dcbf04399b 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -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; diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index f7bbebb443..70e86a7142 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -1,93 +1,128 @@ +/** + * @jest-environment jsdom + */ import * as fs from 'fs'; 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]) { const [seconds, nanoseconds] = hrtime; return seconds * 1000 + nanoseconds / 1000000; } -describe('add hover class to hover selector related rules', function () { +describe('rebuild', function () { let cache: ReturnType; + let mirror: Mirror; beforeEach(() => { + mirror = createMirror(); cache = createCache(); }); - it('will do nothing to css text without :hover', () => { - const cssText = 'body { color: white }'; - expect(addHoverClass(cssText, cache)).toEqual(cssText); + describe('rr_dataURL', function () { + it('should rebuild dataURL', function () { + 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', () => { - const cssText = '.a:hover { color: white }'; - expect(addHoverClass(cssText, cache)).toEqual( - '.a:hover, .a.\\:hover { color: white }', - ); - }); - - it('can add hover class when there is multi selector', () => { - const cssText = '.a, .b:hover, .c { color: white }'; - expect(addHoverClass(cssText, cache)).toEqual( - '.a, .b:hover, .b.\\:hover, .c { color: white }', - ); - }); - - it('can add hover class when there is a multi selector with the same prefix', () => { - const cssText = '.a:hover, .a:hover::after { color: white }'; - expect(addHoverClass(cssText, cache)).toEqual( - '.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }', - ); - }); - - it('can add hover class when :hover is not the end of selector', () => { - const cssText = 'div:hover::after { color: white }'; - expect(addHoverClass(cssText, cache)).toEqual( - 'div:hover::after, div.\\:hover::after { color: white }', - ); - }); - - it('can add hover class when the selector has multi :hover', () => { - const cssText = 'a:hover b:hover { color: white }'; - expect(addHoverClass(cssText, cache)).toEqual( - 'a:hover b:hover, a.\\:hover b.\\:hover { color: white }', - ); - }); - - it('will ignore :hover in css value', () => { - const cssText = '.a::after { content: ":hover" }'; - expect(addHoverClass(cssText, cache)).toEqual(cssText); - }); - - // this benchmark is unreliable when run in parallel with other tests - 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); - }); - - it('should be a lot faster to add a hover class to a previously processed css string', () => { - const factor = 100; - - let cssText = fs.readFileSync( - path.resolve(__dirname, './css/benchmark.css'), - 'utf8', - ); - - const start = process.hrtime(); - addHoverClass(cssText, cache); - const end = process.hrtime(start); - - const cachedStart = process.hrtime(); - addHoverClass(cssText, cache); - const cachedEnd = process.hrtime(cachedStart); - - expect(getDuration(cachedEnd) * factor).toBeLessThan(getDuration(end)); + describe('add hover class to hover selector related rules', function () { + it('will do nothing to css text without :hover', () => { + const cssText = 'body { color: white }'; + expect(addHoverClass(cssText, cache)).toEqual(cssText); + }); + + it('can add hover class to css text', () => { + const cssText = '.a:hover { color: white }'; + expect(addHoverClass(cssText, cache)).toEqual( + '.a:hover, .a.\\:hover { color: white }', + ); + }); + + it('can add hover class when there is multi selector', () => { + const cssText = '.a, .b:hover, .c { color: white }'; + expect(addHoverClass(cssText, cache)).toEqual( + '.a, .b:hover, .b.\\:hover, .c { color: white }', + ); + }); + + it('can add hover class when there is a multi selector with the same prefix', () => { + const cssText = '.a:hover, .a:hover::after { color: white }'; + expect(addHoverClass(cssText, cache)).toEqual( + '.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }', + ); + }); + + it('can add hover class when :hover is not the end of selector', () => { + const cssText = 'div:hover::after { color: white }'; + expect(addHoverClass(cssText, cache)).toEqual( + 'div:hover::after, div.\\:hover::after { color: white }', + ); + }); + + it('can add hover class when the selector has multi :hover', () => { + const cssText = 'a:hover b:hover { color: white }'; + expect(addHoverClass(cssText, cache)).toEqual( + 'a:hover b:hover, a.\\:hover b.\\:hover { color: white }', + ); + }); + + it('will ignore :hover in css value', () => { + const cssText = '.a::after { content: ":hover" }'; + expect(addHoverClass(cssText, cache)).toEqual(cssText); + }); + + // this benchmark is unreliable when run in parallel with other tests + 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); + }); + + it('should be a lot faster to add a hover class to a previously processed css string', () => { + const factor = 100; + + let cssText = fs.readFileSync( + path.resolve(__dirname, './css/benchmark.css'), + 'utf8', + ); + + const start = process.hrtime(); + addHoverClass(cssText, cache); + const end = process.hrtime(start); + + const cachedStart = process.hrtime(); + addHoverClass(cssText, cache); + const cachedEnd = process.hrtime(cachedStart); + + expect(getDuration(cachedEnd) * factor).toBeLessThan(getDuration(end)); + }); }); });