diff --git a/.changeset/replay-mutate-stylesheets.md b/.changeset/replay-mutate-stylesheets.md new file mode 100644 index 0000000000..55bc5d4448 --- /dev/null +++ b/.changeset/replay-mutate-stylesheets.md @@ -0,0 +1,7 @@ +--- +"rrweb-snapshot": patch +"rrweb": patch +"rrdom": patch +--- + +Change to adding hover classes on replay directly to the rendered stylesheets rather than parsing and modifying the css text diff --git a/packages/rrweb-snapshot/src/css.ts b/packages/rrweb-snapshot/src/css.ts deleted file mode 100644 index d2755a03e5..0000000000 --- a/packages/rrweb-snapshot/src/css.ts +++ /dev/null @@ -1,987 +0,0 @@ -/** - * This file is a fork of https://github.com/reworkcss/css/blob/master/lib/parse/index.js - * I fork it because: - * 1. The css library was built for node.js which does not have tree-shaking supports. - * 2. Rewrites into typescript give us a better type interface. - */ -/* eslint-disable tsdoc/syntax */ - -export interface ParserOptions { - /** Silently fail on parse errors */ - silent?: boolean; - /** - * The path to the file containing css. - * Makes errors and source maps more helpful, by letting them know where code comes from. - */ - source?: string; -} - -/** - * Error thrown during parsing. - */ -export interface ParserError { - /** The full error message with the source position. */ - message?: string; - /** The error message without position. */ - reason?: string; - /** The value of options.source if passed to css.parse. Otherwise undefined. */ - filename?: string; - line?: number; - column?: number; - /** The portion of code that couldn't be parsed. */ - source?: string; -} - -export interface Loc { - line?: number; - column?: number; -} - -/** - * Base AST Tree Node. - */ -export interface Node { - /** The possible values are the ones listed in the Types section on https://github.com/reworkcss/css page. */ - type?: string; - /** A reference to the parent node, or null if the node has no parent. */ - parent?: Node; - /** Information about the position in the source string that corresponds to the node. */ - position?: { - start?: Loc; - end?: Loc; - /** The value of options.source if passed to css.parse. Otherwise undefined. */ - source?: string; - /** The full source string passed to css.parse. */ - content?: string; - }; -} - -export interface NodeWithRules extends Node { - /** Array of nodes with the types rule, comment and any of the at-rule types. */ - rules: Array; -} - -export interface Rule extends Node { - /** The list of selectors of the rule, split on commas. Each selector is trimmed from whitespace and comments. */ - selectors?: string[]; - /** Array of nodes with the types declaration and comment. */ - declarations?: Array; -} - -export interface Declaration extends Node { - /** The property name, trimmed from whitespace and comments. May not be empty. */ - property?: string; - /** The value of the property, trimmed from whitespace and comments. Empty values are allowed. */ - value?: string; -} - -/** - * A rule-level or declaration-level comment. Comments inside selectors, properties and values etc. are lost. - */ -export interface Comment extends Node { - comment?: string; -} - -/** - * The @charset at-rule. - */ -export interface Charset extends Node { - /** The part following @charset. */ - charset?: string; -} - -/** - * The @custom-media at-rule - */ -export interface CustomMedia extends Node { - /** The ---prefixed name. */ - name?: string; - /** The part following the name. */ - media?: string; -} - -/** - * The @document at-rule. - */ -export interface Document extends NodeWithRules { - /** The part following @document. */ - document?: string; - /** The vendor prefix in @document, or undefined if there is none. */ - vendor?: string; -} - -/** - * The @font-face at-rule. - */ -export interface FontFace extends Node { - /** Array of nodes with the types declaration and comment. */ - declarations?: Array; -} - -/** - * The @host at-rule. - */ -export type Host = NodeWithRules; - -/** - * The @import at-rule. - */ -export interface Import extends Node { - /** The part following @import. */ - import?: string; -} - -/** - * The @keyframes at-rule. - */ -export interface KeyFrames extends Node { - /** The name of the keyframes rule. */ - name?: string; - /** The vendor prefix in @keyframes, or undefined if there is none. */ - vendor?: string; - /** Array of nodes with the types keyframe and comment. */ - keyframes?: Array; -} - -export interface KeyFrame extends Node { - /** The list of "selectors" of the keyframe rule, split on commas. Each “selector” is trimmed from whitespace. */ - values?: string[]; - /** Array of nodes with the types declaration and comment. */ - declarations?: Array; -} - -/** - * The @media at-rule. - */ -export interface Media extends NodeWithRules { - /** The part following @media. */ - media?: string; -} - -/** - * The @namespace at-rule. - */ -export interface Namespace extends Node { - /** The part following @namespace. */ - namespace?: string; -} - -/** - * The @page at-rule. - */ -export interface Page extends Node { - /** The list of selectors of the rule, split on commas. Each selector is trimmed from whitespace and comments. */ - selectors?: string[]; - /** Array of nodes with the types declaration and comment. */ - declarations?: Array; -} - -/** - * The @supports at-rule. - */ -export interface Supports extends NodeWithRules { - /** The part following @supports. */ - supports?: string; -} - -/** All at-rules. */ -export type AtRule = - | Charset - | CustomMedia - | Document - | FontFace - | Host - | Import - | KeyFrames - | Media - | Namespace - | Page - | Supports; - -/** - * A collection of rules - */ -export interface StyleRules extends NodeWithRules { - source?: string; - /** Array of Errors. Errors collected during parsing when option silent is true. */ - parsingErrors?: ParserError[]; -} - -/** - * The root node returned by css.parse. - */ -export interface Stylesheet extends Node { - stylesheet?: StyleRules; -} - -// http://www.w3.org/TR/CSS21/grammar.html -// https://github.com/visionmedia/css-parse/pull/49#issuecomment-30088027 -const commentre = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g; - -export function parse(css: string, options: ParserOptions = {}): Stylesheet { - /** - * Positional. - */ - - let lineno = 1; - let column = 1; - - /** - * Update lineno and column based on `str`. - */ - - function updatePosition(str: string) { - const lines = str.match(/\n/g); - if (lines) { - lineno += lines.length; - } - const i = str.lastIndexOf('\n'); - column = i === -1 ? column + str.length : str.length - i; - } - - /** - * Mark position and patch `node.position`. - */ - - function position() { - const start = { line: lineno, column }; - return ( - node: Rule | Declaration | Comment | AtRule | Stylesheet | KeyFrame, - ) => { - node.position = new Position(start); - whitespace(); - return node; - }; - } - - /** - * Store position information for a node - */ - - class Position { - public static content: string; - public content!: string; - public start!: Loc; - public end!: Loc; - public source?: string; - - constructor(start: Loc) { - this.start = start; - this.end = { line: lineno, column }; - this.source = options.source; - this.content = Position.content; - } - } - - /** - * Non-enumerable source string - */ - - Position.content = css; - - const errorsList: ParserError[] = []; - - function error(msg: string) { - const err = new Error( - `${options.source || ''}:${lineno}:${column}: ${msg}`, - ) as ParserError; - err.reason = msg; - err.filename = options.source; - err.line = lineno; - err.column = column; - err.source = css; - - if (options.silent) { - errorsList.push(err); - } else { - throw err; - } - } - - /** - * Parse stylesheet. - */ - - function stylesheet(): Stylesheet { - const rulesList = rules(); - - return { - type: 'stylesheet', - stylesheet: { - source: options.source, - rules: rulesList, - parsingErrors: errorsList, - }, - }; - } - - /** - * Opening brace. - */ - - function open() { - return match(/^{\s*/); - } - - /** - * Closing brace. - */ - - function close() { - return match(/^}/); - } - - /** - * Parse ruleset. - */ - - function rules() { - let node: Rule | void; - const rules: Rule[] = []; - whitespace(); - comments(rules); - while (css.length && css.charAt(0) !== '}' && (node = atrule() || rule())) { - if (node) { - rules.push(node); - comments(rules); - } - } - return rules; - } - - /** - * Match `re` and return captures. - */ - - function match(re: RegExp) { - const m = re.exec(css); - if (!m) { - return; - } - const str = m[0]; - updatePosition(str); - css = css.slice(str.length); - return m; - } - - /** - * Parse whitespace. - */ - - function whitespace() { - match(/^\s*/); - } - - /** - * Parse comments; - */ - - function comments(rules: Rule[] = []) { - let c: Comment | void; - while ((c = comment())) { - if (c) { - rules.push(c); - } - c = comment(); - } - return rules; - } - - /** - * Parse comment. - */ - - function comment() { - const pos = position(); - if ('/' !== css.charAt(0) || '*' !== css.charAt(1)) { - return; - } - - let i = 2; - while ( - '' !== css.charAt(i) && - ('*' !== css.charAt(i) || '/' !== css.charAt(i + 1)) - ) { - ++i; - } - i += 2; - - if ('' === css.charAt(i - 1)) { - return error('End of comment missing'); - } - - const str = css.slice(2, i - 2); - column += 2; - updatePosition(str); - css = css.slice(i); - column += 2; - - return pos({ - type: 'comment', - comment: str, - }); - } - - /** - * Parse selector. - */ - - // originally from https://github.com/NxtChg/pieces/blob/3eb39c8287a97632e9347a24f333d52d916bc816/js/css_parser/css_parse.js#L46C1-L47C1 - const selectorMatcher = new RegExp( - '^((' + - [ - /[^\\]"(?:\\"|[^"])*"/.source, // consume any quoted parts (checking that the double quote isn't itself escaped) - /[^\\]'(?:\\'|[^'])*'/.source, // same but for single quotes - '[^{]', - ].join('|') + - ')+)', - ); - - function selector() { - whitespace(); - while (css[0] == '}') { - error('extra closing bracket'); - css = css.slice(1); - whitespace(); - } - - const m = match(selectorMatcher); - if (!m) { - return; - } - - /* @fix Remove all comments from selectors - * http://ostermiller.org/findcomment.html */ - const cleanedInput = m[0] - .trim() - .replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '') - - // Handle strings by replacing commas inside them - .replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, (m) => { - return m.replace(/,/g, '\u200C'); - }); - - // Split using a custom function and restore commas in strings - return customSplit(cleanedInput).map((s) => - s.replace(/\u200C/g, ',').trim(), - ); - } - - /** - * Split selector correctly, ensuring not to split on comma if inside (). - */ - - function customSplit(input: string) { - const result = []; - let currentSegment = ''; - let depthParentheses = 0; // Track depth of parentheses - let depthBrackets = 0; // Track depth of square brackets - let currentStringChar = null; - - for (const char of input) { - const hasStringEscape = currentSegment.endsWith('\\'); - - if (currentStringChar) { - if (currentStringChar === char && !hasStringEscape) { - currentStringChar = null; - } - } else if (char === '(') { - depthParentheses++; - } else if (char === ')') { - depthParentheses--; - } else if (char === '[') { - depthBrackets++; - } else if (char === ']') { - depthBrackets--; - } else if ('\'"'.includes(char)) { - currentStringChar = char; - } - - // Split point is a comma that is not inside parentheses or square brackets - if (char === ',' && depthParentheses === 0 && depthBrackets === 0) { - result.push(currentSegment); - currentSegment = ''; - } else { - currentSegment += char; - } - } - - // Add the last segment - if (currentSegment) { - result.push(currentSegment); - } - - return result; - } - - /** - * Parse declaration. - */ - - function declaration(): Declaration | void | never { - const pos = position(); - - // prop - // eslint-disable-next-line no-useless-escape - const propMatch = match(/^(\*?[-#\/\*\\\w]+(\[[0-9a-z_-]+\])?)\s*/); - if (!propMatch) { - return; - } - const prop = trim(propMatch[0]); - - // : - if (!match(/^:\s*/)) { - return error(`property missing ':'`); - } - - // val - // eslint-disable-next-line no-useless-escape - const val = match(/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^\)]*?\)|[^};])+)/); - - const ret = pos({ - type: 'declaration', - property: prop.replace(commentre, ''), - value: val ? trim(val[0]).replace(commentre, '') : '', - }); - - // ; - match(/^[;\s]*/); - - return ret; - } - - /** - * Parse declarations. - */ - - function declarations() { - const decls: Array = []; - - if (!open()) { - return error(`missing '{'`); - } - comments(decls); - - // declarations - let decl; - while ((decl = declaration())) { - if ((decl as unknown) !== false) { - decls.push(decl); - comments(decls); - } - decl = declaration(); - } - - if (!close()) { - return error(`missing '}'`); - } - return decls; - } - - /** - * Parse keyframe. - */ - - function keyframe() { - let m; - const vals = []; - const pos = position(); - - while ((m = match(/^((\d+\.\d+|\.\d+|\d+)%?|[a-z]+)\s*/))) { - vals.push(m[1]); - match(/^,\s*/); - } - - if (!vals.length) { - return; - } - - return pos({ - type: 'keyframe', - values: vals, - declarations: declarations() as Declaration[], - }); - } - - /** - * Parse keyframes. - */ - - function atkeyframes() { - const pos = position(); - let m = match(/^@([-\w]+)?keyframes\s*/); - - if (!m) { - return; - } - const vendor = m[1]; - - // identifier - m = match(/^([-\w]+)\s*/); - if (!m) { - return error('@keyframes missing name'); - } - const name = m[1]; - - if (!open()) { - return error(`@keyframes missing '{'`); - } - - let frame; - let frames = comments(); - while ((frame = keyframe())) { - frames.push(frame); - frames = frames.concat(comments()); - } - - if (!close()) { - return error(`@keyframes missing '}'`); - } - - return pos({ - type: 'keyframes', - name, - vendor, - keyframes: frames, - }); - } - - /** - * Parse supports. - */ - - function atsupports() { - const pos = position(); - const m = match(/^@supports *([^{]+)/); - - if (!m) { - return; - } - const supports = trim(m[1]); - - if (!open()) { - return error(`@supports missing '{'`); - } - - const style = comments().concat(rules()); - - if (!close()) { - return error(`@supports missing '}'`); - } - - return pos({ - type: 'supports', - supports, - rules: style, - }); - } - - /** - * Parse host. - */ - - function athost() { - const pos = position(); - const m = match(/^@host\s*/); - - if (!m) { - return; - } - - if (!open()) { - return error(`@host missing '{'`); - } - - const style = comments().concat(rules()); - - if (!close()) { - return error(`@host missing '}'`); - } - - return pos({ - type: 'host', - rules: style, - }); - } - - /** - * Parse media. - */ - - function atmedia() { - const pos = position(); - const m = match(/^@media *([^{]+)/); - - if (!m) { - return; - } - const media = trim(m[1]); - - if (!open()) { - return error(`@media missing '{'`); - } - - const style = comments().concat(rules()); - - if (!close()) { - return error(`@media missing '}'`); - } - - return pos({ - type: 'media', - media, - rules: style, - }); - } - - /** - * Parse custom-media. - */ - - function atcustommedia() { - const pos = position(); - const m = match(/^@custom-media\s+(--[^\s]+)\s*([^{;]+);/); - if (!m) { - return; - } - - return pos({ - type: 'custom-media', - name: trim(m[1]), - media: trim(m[2]), - }); - } - - /** - * Parse paged media. - */ - - function atpage() { - const pos = position(); - const m = match(/^@page */); - if (!m) { - return; - } - - const sel = selector() || []; - - if (!open()) { - return error(`@page missing '{'`); - } - let decls = comments(); - - // declarations - let decl; - while ((decl = declaration())) { - decls.push(decl); - decls = decls.concat(comments()); - } - - if (!close()) { - return error(`@page missing '}'`); - } - - return pos({ - type: 'page', - selectors: sel, - declarations: decls, - }); - } - - /** - * Parse document. - */ - - function atdocument() { - const pos = position(); - const m = match(/^@([-\w]+)?document *([^{]+)/); - if (!m) { - return; - } - - const vendor = trim(m[1]); - const doc = trim(m[2]); - - if (!open()) { - return error(`@document missing '{'`); - } - - const style = comments().concat(rules()); - - if (!close()) { - return error(`@document missing '}'`); - } - - return pos({ - type: 'document', - document: doc, - vendor, - rules: style, - }); - } - - /** - * Parse font-face. - */ - - function atfontface() { - const pos = position(); - const m = match(/^@font-face\s*/); - if (!m) { - return; - } - - if (!open()) { - return error(`@font-face missing '{'`); - } - let decls = comments(); - - // declarations - let decl; - while ((decl = declaration())) { - decls.push(decl); - decls = decls.concat(comments()); - } - - if (!close()) { - return error(`@font-face missing '}'`); - } - - return pos({ - type: 'font-face', - declarations: decls, - }); - } - - /** - * Parse import - */ - - const atimport = _compileAtrule('import'); - - /** - * Parse charset - */ - - const atcharset = _compileAtrule('charset'); - - /** - * Parse namespace - */ - - const atnamespace = _compileAtrule('namespace'); - - /** - * Parse non-block at-rules - */ - - function _compileAtrule(name: string) { - const re = new RegExp( - '^@' + - name + - '\\s*((?:' + - [ - /[^\\]"(?:\\"|[^"])*"/.source, // consume any quoted parts (checking that the double quote isn't itself escaped) - /[^\\]'(?:\\'|[^'])*'/.source, // same but for single quotes - '[^;]', - ].join('|') + - ')+);', - ); - return () => { - const pos = position(); - const m = match(re); - if (!m) { - return; - } - const ret: Record = { type: name }; - ret[name] = m[1].trim(); - return pos(ret); - }; - } - - /** - * Parse at rule. - */ - - function atrule() { - if (css[0] !== '@') { - return; - } - - return ( - atkeyframes() || - atmedia() || - atcustommedia() || - atsupports() || - atimport() || - atcharset() || - atnamespace() || - atdocument() || - atpage() || - athost() || - atfontface() - ); - } - - /** - * Parse rule. - */ - - function rule() { - const pos = position(); - const sel = selector(); - - if (!sel) { - return error('selector missing'); - } - comments(); - - return pos({ - type: 'rule', - selectors: sel, - declarations: declarations() as Declaration[], - }); - } - - return addParent(stylesheet()); -} - -/** - * Trim `str`. - */ - -function trim(str: string) { - return str ? str.replace(/^\s+|\s+$/g, '') : ''; -} - -/** - * Adds non-enumerable parent node reference to each node. - */ - -function addParent(obj: Stylesheet, parent?: Stylesheet): Stylesheet { - const isNode = obj && typeof obj.type === 'string'; - const childParent = isNode ? obj : parent; - - for (const k of Object.keys(obj)) { - const value = obj[k as keyof Stylesheet]; - if (Array.isArray(value)) { - value.forEach((v) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - addParent(v, childParent); - }); - } else if (value && typeof value === 'object') { - addParent(value as Stylesheet, childParent); - } - } - - if (isNode) { - Object.defineProperty(obj, 'parent', { - configurable: true, - writable: true, - enumerable: false, - value: parent || null, - }); - } - - return obj; -} diff --git a/packages/rrweb-snapshot/src/index.ts b/packages/rrweb-snapshot/src/index.ts index c9f91a9100..6b244e6799 100644 --- a/packages/rrweb-snapshot/src/index.ts +++ b/packages/rrweb-snapshot/src/index.ts @@ -9,11 +9,7 @@ import snapshot, { IGNORED_NODE, genId, } from './snapshot'; -import rebuild, { - buildNodeWithSN, - adaptCssForReplay, - createCache, -} from './rebuild'; +import rebuild, { buildNodeWithSN, createCache } from './rebuild'; export * from './types'; export * from './utils'; @@ -22,7 +18,6 @@ export { serializeNodeWithId, rebuild, buildNodeWithSN, - adaptCssForReplay, createCache, transformAttribute, ignoreAttribute, diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index c3300a59f1..cea6e9c525 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -1,6 +1,6 @@ -import { Rule, Media, NodeWithRules, parse } from './css'; import { serializedNodeWithId, + serializedElementNodeWithId, NodeType, tagMap, elementNode, @@ -57,85 +57,89 @@ function getTagName(n: elementNode): string { return tagName; } -// based on https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping -function escapeRegExp(str: string) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +function splitSelector(selectorText: string): string[] { + const selectorList = []; + let currentSegment = ''; + let depthParentheses = 0; // Track depth of parentheses + let depthBrackets = 0; // Track depth of square brackets + let currentStringChar = null; + for (const char of selectorText) { + const hasStringEscape = currentSegment.endsWith('\\'); + if (currentStringChar) { + if (currentStringChar === char && !hasStringEscape) { + currentStringChar = null; + } + } else if (char === '(') { + depthParentheses++; + } else if (char === ')') { + depthParentheses--; + } else if (char === '[') { + depthBrackets++; + } else if (char === ']') { + depthBrackets--; + } else if ('\'"'.includes(char)) { + currentStringChar = char; + } + // Split point is a comma that is not inside parentheses or square brackets + if (char === ',' && depthParentheses === 0 && depthBrackets === 0) { + selectorList.push(currentSegment.trim()); + currentSegment = ''; + } else { + currentSegment += char; + } + } + // Add the last segment + if (currentSegment) { + selectorList.push(currentSegment.trim()); + } + return selectorList; } const MEDIA_SELECTOR = /(max|min)-device-(width|height)/; const MEDIA_SELECTOR_GLOBAL = new RegExp(MEDIA_SELECTOR.source, 'g'); const HOVER_SELECTOR = /([^\\]):hover/; const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR.source, 'g'); -export function adaptCssForReplay(cssText: string, cache: BuildCache): string { - const cachedStyle = cache?.stylesWithHoverClass.get(cssText); - if (cachedStyle) return cachedStyle; - - const ast = parse(cssText, { - silent: true, - }); - - if (!ast.stylesheet) { - return cssText; - } - - const selectors: string[] = []; - const medias: string[] = []; - function getSelectors(rule: Rule | Media | NodeWithRules) { - if ('selectors' in rule && rule.selectors) { - rule.selectors.forEach((selector: string) => { - if (HOVER_SELECTOR.test(selector)) { - selectors.push(selector); - } - }); - } - if ('media' in rule && rule.media && MEDIA_SELECTOR.test(rule.media)) { - medias.push(rule.media); +export function adaptStylesheetForReplay(cssRules: CSSRuleList) { + // @ts-ignore ignore TS2488: Type 'CSSRuleList' must have a '[Symbol.iterator]()' method that returns an iterator ... it should be fixed in https://github.com/Microsoft/TypeScript/issues/23406 + for (const cssRule of cssRules) { + if ('media' in cssRule) { + const cssMediaRule = cssRule as CSSMediaRule; + if ( + cssMediaRule.media.mediaText && + MEDIA_SELECTOR.test(cssMediaRule.media.mediaText) + ) { + // not attempting to maintain min-device-width along with min-width + // (it's non standard) + cssMediaRule.media.mediaText = cssMediaRule.media.mediaText.replace( + MEDIA_SELECTOR_GLOBAL, + '$1-$2', + ); + } + } else if ('selectorText' in cssRule) { + const cssStyleRule = cssRule as CSSStyleRule; + if ( + cssStyleRule.selectorText && + HOVER_SELECTOR.test(cssStyleRule.selectorText) + ) { + cssStyleRule.selectorText = splitSelector(cssStyleRule.selectorText) + .map((selector) => { + if (!HOVER_SELECTOR.test(selector)) { + return selector; + } + const newSelector = selector.replace( + HOVER_SELECTOR_GLOBAL, + '$1.\\:hover', + ); + return `${selector}, ${newSelector}`; + }) + .join(', '); + } } - if ('rules' in rule && rule.rules) { - rule.rules.forEach(getSelectors); + if ('cssRules' in cssRule) { + // could be a CSSKeyframesRule or CSSContainerRule either + adaptStylesheetForReplay((cssRule as CSSGroupingRule).cssRules); } } - getSelectors(ast.stylesheet); - - let result = cssText; - if (selectors.length > 0) { - const selectorMatcher = new RegExp( - selectors - .filter((selector, index) => selectors.indexOf(selector) === index) - .sort((a, b) => b.length - a.length) - .map((selector) => { - return escapeRegExp(selector); - }) - .join('|'), - 'g', - ); - result = result.replace(selectorMatcher, (selector) => { - const newSelector = selector.replace( - HOVER_SELECTOR_GLOBAL, - '$1.\\:hover', - ); - return `${selector}, ${newSelector}`; - }); - } - if (medias.length > 0) { - const mediaMatcher = new RegExp( - medias - .filter((media, index) => medias.indexOf(media) === index) - .sort((a, b) => b.length - a.length) - .map((media) => { - return escapeRegExp(media); - }) - .join('|'), - 'g', - ); - result = result.replace(mediaMatcher, (media) => { - // not attempting to maintain min-device-width along with min-width - // (it's non standard) - return media.replace(MEDIA_SELECTOR_GLOBAL, '$1-$2'); - }); - } - cache?.stylesWithHoverClass.set(cssText, result); - return result; } export function createCache(): BuildCache { @@ -149,11 +153,10 @@ function buildNode( n: serializedNodeWithId, options: { doc: Document; - hackCss: boolean; cache: BuildCache; }, ): Node | null { - const { doc, hackCss, cache } = options; + const { doc, cache } = options; switch (n.type) { case NodeType.Document: return doc.implementation.createDocument(null, '', null); @@ -223,9 +226,6 @@ function buildNode( const isTextarea = tagName === 'textarea' && name === 'value'; const isRemoteOrDynamicCss = tagName === 'style' && name === '_cssText'; - if (isRemoteOrDynamicCss && hackCss && typeof value === 'string') { - value = adaptCssForReplay(value, cache); - } if ((isTextarea || isRemoteOrDynamicCss) && typeof value === 'string') { node.appendChild(doc.createTextNode(value)); // https://github.com/rrweb-io/rrweb/issues/112 @@ -378,11 +378,7 @@ function buildNode( return node; } case NodeType.Text: - return doc.createTextNode( - n.isStyle && hackCss - ? adaptCssForReplay(n.textContent, cache) - : n.textContent, - ); + return doc.createTextNode(n.textContent); case NodeType.CDATA: return doc.createCDATASection(n.textContent); case NodeType.Comment: @@ -398,7 +394,6 @@ export function buildNodeWithSN( doc: Document; mirror: Mirror; skipChild?: boolean; - hackCss: boolean; /** * This callback will be called for each of this nodes' `.childNodes` after they are appended to _this_ node. * Caveat: This callback _doesn't_ get called when this node is appended to the DOM. @@ -407,14 +402,7 @@ export function buildNodeWithSN( cache: BuildCache; }, ): Node | null { - const { - doc, - mirror, - skipChild = false, - hackCss = true, - afterAppend, - cache, - } = options; + const { doc, mirror, skipChild = false, afterAppend, cache } = options; /** * Add a check to see if the node is already in the mirror. If it is, we can skip the whole process. * This situation (duplicated nodes) can happen when recorder has some unfixed bugs and the same node is recorded twice. Or something goes wrong when saving or transferring event data. @@ -428,7 +416,7 @@ export function buildNodeWithSN( // For safety concern, check if the node in mirror is the same as the node we are trying to build if (isNodeMetaEqual(meta, n)) return mirror.getNode(n.id); } - let node = buildNode(n, { doc, hackCss, cache }); + let node = buildNode(n, { doc, cache }); if (!node) { return null; } @@ -477,7 +465,6 @@ export function buildNodeWithSN( doc, mirror, skipChild: false, - hackCss, afterAppend, cache, }); @@ -535,12 +522,7 @@ function visit(mirror: Mirror, onVisit: (node: Node) => void) { } } -function handleScroll(node: Node, mirror: Mirror) { - const n = mirror.getMeta(node); - if (n?.type !== NodeType.Element) { - return; - } - const el = node as HTMLElement; +function handleScroll(el: HTMLElement, n: serializedElementNodeWithId) { for (const name in n.attributes) { if ( !( @@ -583,7 +565,6 @@ function rebuild( doc, mirror, skipChild: false, - hackCss, afterAppend, cache, }); @@ -591,7 +572,18 @@ function rebuild( if (onVisit) { onVisit(visitedNode); } - handleScroll(visitedNode, mirror); + const n = mirror.getMeta(visitedNode); + if (n?.type === NodeType.Element) { + const el = visitedNode as HTMLElement; + handleScroll(el, n); + if (hackCss && el.tagName === 'STYLE') { + const styleEl = el as HTMLStyleElement; + if (styleEl.sheet) { + // cssRules are always available on inline style elements + adaptStylesheetForReplay(styleEl.sheet.cssRules); + } + } + } }); return node; } diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index c71e1e8510..70557b2f78 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -5,12 +5,62 @@ import * as fs from 'fs'; import * as path from 'path'; import { describe, it, beforeEach, expect } from 'vitest'; import { - adaptCssForReplay, + adaptStylesheetForReplay, buildNodeWithSN, createCache, } from '../src/rebuild'; import { NodeType } from '../src/types'; -import { createMirror, Mirror } from '../src/utils'; +import type { BuildCache } from '../src/types'; +import { createMirror, Mirror, stringifyStylesheet } from '../src/utils'; + +import { expect as _expect } from '@jest/globals'; + +export function adaptCssForReplay(cssText: string, cache: BuildCache): string { + let tempStyleSheet = new CSSStyleSheet(); + if (tempStyleSheet.replaceSync) { + tempStyleSheet.replaceSync(cssText); + } else { + const style = document.createElement('style'); + style.innerHTML = cssText; + document.head.appendChild(style); + if (style.sheet) { + tempStyleSheet = style.sheet; + } else { + return "error: can't find sheet"; + } + } + adaptStylesheetForReplay(tempStyleSheet.cssRules); + let ss = stringifyStylesheet(tempStyleSheet); + if (!ss) { + return 'error' + tempStyleSheet.cssRules.length; + } else { + return ss; + } +} + +const expect = _expect as unknown as { + (actual: T): { + toMatchCss(expected: string): void; + } & ReturnType; +} & typeof _expect; + +expect.extend({ + toMatchCss: function (received: string, expected: string) { + const pass = normCss(received) === normCss(expected); + const message: () => string = () => + pass + ? '' + : `Received (${received}) is not the same as expected (${expected})`; + return { + message, + pass, + }; + }, +}); + +function normCss(cssText: string): string { + return cssText.replace(/[\s;]/g, ''); +} function getDuration(hrtime: [number, number]) { const [seconds, nanoseconds] = hrtime; @@ -44,7 +94,6 @@ describe('rebuild', function () { { doc: document, mirror, - hackCss: false, cache, }, ) as HTMLImageElement; @@ -75,7 +124,6 @@ describe('rebuild', function () { { doc: document, mirror, - hackCss: false, cache, }, ) as HTMLDivElement; @@ -86,19 +134,19 @@ describe('rebuild', function () { 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(adaptCssForReplay(cssText, cache)).toEqual(cssText); + expect(adaptCssForReplay(cssText, cache)).toMatchCss(cssText); }); it('can add hover class to css text', () => { const cssText = '.a:hover { color: white }'; - expect(adaptCssForReplay(cssText, cache)).toEqual( + expect(adaptCssForReplay(cssText, cache)).toMatchCss( '.a:hover, .a.\\:hover { color: white }', ); }); it('can correctly add hover when in middle of selector', () => { const cssText = 'ul li a:hover img { color: white }'; - expect(adaptCssForReplay(cssText, cache)).toEqual( + expect(adaptCssForReplay(cssText, cache)).toMatchCss( 'ul li a:hover img, ul li a.\\:hover img { color: white }', ); }); @@ -111,63 +159,63 @@ img, ul li.specified c:hover img { color: white }`; - expect(adaptCssForReplay(cssText, cache)).toEqual( + expect(adaptCssForReplay(cssText, cache)).toMatchCss( `ul li.specified a:hover img, ul li.specified a.\\:hover img, ul li.multiline b:hover -img, ul li.multiline +img, +ul li.multiline b.\\:hover img, -ul li.specified c:hover img, ul li.specified c.\\:hover img { - color: white -}`, +ul li.specified c:hover img, +ul li.specified c.\\:hover img { color: white }`, ); }); it('can add hover class within media query', () => { const cssText = '@media screen { .m:hover { color: white } }'; - expect(adaptCssForReplay(cssText, cache)).toEqual( + expect(adaptCssForReplay(cssText, cache)).toMatchCss( '@media screen { .m:hover, .m.\\:hover { color: white } }', ); }); it('can add hover class when there is multi selector', () => { const cssText = '.a, .b:hover, .c { color: white }'; - expect(adaptCssForReplay(cssText, cache)).toEqual( + expect(adaptCssForReplay(cssText, cache)).toMatchCss( '.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(adaptCssForReplay(cssText, cache)).toEqual( + expect(adaptCssForReplay(cssText, cache)).toMatchCss( '.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(adaptCssForReplay(cssText, cache)).toEqual( + expect(adaptCssForReplay(cssText, cache)).toMatchCss( '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(adaptCssForReplay(cssText, cache)).toEqual( + expect(adaptCssForReplay(cssText, cache)).toMatchCss( 'a:hover b:hover, a.\\:hover b.\\:hover { color: white }', ); }); it('will ignore :hover in css value', () => { const cssText = '.a::after { content: ":hover" }'; - expect(adaptCssForReplay(cssText, cache)).toEqual(cssText); + expect(adaptCssForReplay(cssText, cache)).toMatchCss(cssText); }); it('can adapt media rules to replay context', () => { const cssText = '@media only screen and (min-device-width : 1200px) { .a { width: 10px; }}'; - expect(adaptCssForReplay(cssText, cache)).toEqual( + expect(adaptCssForReplay(cssText, cache)).toMatchCss( '@media only screen and (min-width : 1200px) { .a { width: 10px; }}', ); });