From ec984d71f6476c4ab7b4e4421e0a8f6049819a45 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] Cache addHoverClass as it is quite expensive (#643) * Add cache and cache purging Needed for https://github.com/rrweb-io/rrweb-snapshot/pull/85 * Add cache and cache purging Needed for https://github.com/rrweb-io/rrweb-snapshot/pull/85 * Cache addHoverClass as it is quite expensive https://github.com/rrweb-io/rrweb-snapshot/pull/85 * Make cache non-optional * Make cache required on addHoverClass --- packages/rrweb-snapshot/src/index.ts | 7 ++- packages/rrweb-snapshot/src/rebuild.ts | 43 ++++++++++++--- packages/rrweb-snapshot/src/types.ts | 4 ++ packages/rrweb-snapshot/test/rebuild.test.ts | 57 +++++++++++++++----- packages/rrweb-snapshot/typings/index.d.ts | 4 +- packages/rrweb-snapshot/typings/rebuild.d.ts | 7 ++- packages/rrweb-snapshot/typings/types.d.ts | 3 ++ packages/rrweb/src/replay/index.ts | 23 +++++++- 8 files changed, 122 insertions(+), 26 deletions(-) diff --git a/packages/rrweb-snapshot/src/index.ts b/packages/rrweb-snapshot/src/index.ts index 8335768f..5bb5eea6 100644 --- a/packages/rrweb-snapshot/src/index.ts +++ b/packages/rrweb-snapshot/src/index.ts @@ -6,7 +6,11 @@ import snapshot, { needMaskingText, IGNORED_NODE, } from './snapshot'; -import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild'; +import rebuild, { + buildNodeWithSN, + addHoverClass, + createCache, +} from './rebuild'; export * from './types'; export * from './utils'; @@ -16,6 +20,7 @@ export { rebuild, buildNodeWithSN, addHoverClass, + createCache, transformAttribute, visitSnapshot, cleanupSnapshot, diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index a0778a0c..f1b7fbb8 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -6,6 +6,7 @@ import { elementNode, idNodeMap, INode, + BuildCache, } from './types'; import { isElement } from './utils'; @@ -64,7 +65,10 @@ function escapeRegExp(str: string) { const HOVER_SELECTOR = /([^\\]):hover/; const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR, 'g'); -export function addHoverClass(cssText: string): string { +export function addHoverClass(cssText: string, cache: BuildCache): string { + const cachedStyle = cache?.stylesWithHoverClass.get(cssText); + if (cachedStyle) return cachedStyle; + const ast = parse(cssText, { silent: true, }); @@ -99,10 +103,19 @@ export function addHoverClass(cssText: string): string { 'g', ); - return cssText.replace(selectorMatcher, (selector) => { + const result = cssText.replace(selectorMatcher, (selector) => { const newSelector = selector.replace(HOVER_SELECTOR_GLOBAL, '$1.\\:hover'); return `${selector}, ${newSelector}`; }); + cache?.stylesWithHoverClass.set(cssText, result); + return result; +} + +export function createCache(): BuildCache { + const stylesWithHoverClass: Map = new Map(); + return { + stylesWithHoverClass, + }; } function buildNode( @@ -110,9 +123,10 @@ function buildNode( options: { doc: Document; hackCss: boolean; + cache: BuildCache; }, ): Node | null { - const { doc, hackCss } = options; + const { doc, hackCss, cache } = options; switch (n.type) { case NodeType.Document: return doc.implementation.createDocument(null, '', null); @@ -143,7 +157,7 @@ function buildNode( const isRemoteOrDynamicCss = tagName === 'style' && name === '_cssText'; if (isRemoteOrDynamicCss && hackCss) { - value = addHoverClass(value); + value = addHoverClass(value, cache); } if (isTextarea || isRemoteOrDynamicCss) { const child = doc.createTextNode(value); @@ -256,7 +270,9 @@ function buildNode( return node; case NodeType.Text: return doc.createTextNode( - n.isStyle && hackCss ? addHoverClass(n.textContent) : n.textContent, + n.isStyle && hackCss + ? addHoverClass(n.textContent, cache) + : n.textContent, ); case NodeType.CDATA: return doc.createCDATASection(n.textContent); @@ -275,10 +291,18 @@ export function buildNodeWithSN( skipChild?: boolean; hackCss: boolean; afterAppend?: (n: INode) => unknown; + cache: BuildCache; }, ): INode | null { - const { doc, map, skipChild = false, hackCss = true, afterAppend } = options; - let node = buildNode(n, { doc, hackCss }); + const { + doc, + map, + skipChild = false, + hackCss = true, + afterAppend, + cache, + } = options; + let node = buildNode(n, { doc, hackCss, cache }); if (!node) { return null; } @@ -310,6 +334,7 @@ export function buildNodeWithSN( skipChild: false, hackCss, afterAppend, + cache, }); if (!childNode) { console.warn('Failed to rebuild', childN); @@ -369,9 +394,10 @@ function rebuild( onVisit?: (node: INode) => unknown; hackCss?: boolean; afterAppend?: (n: INode) => unknown; + cache: BuildCache; }, ): [Node | null, idNodeMap] { - const { doc, onVisit, hackCss = true, afterAppend } = options; + const { doc, onVisit, hackCss = true, afterAppend, cache } = options; const idNodeMap: idNodeMap = {}; const node = buildNodeWithSN(n, { doc, @@ -379,6 +405,7 @@ function rebuild( skipChild: false, hackCss, afterAppend, + cache, }); visit(idNodeMap, (visitedNode) => { if (onVisit) { diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index ffeef690..e482394b 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -111,3 +111,7 @@ export type MaskTextFn = (text: string) => string; export type MaskInputFn = (text: string) => string; export type KeepIframeSrcFn = (src: string) => boolean; + +export type BuildCache = { + stylesWithHoverClass: Map; +}; diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index a343e780..1ace39ad 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -1,53 +1,67 @@ import * as fs from 'fs'; import * as path from 'path'; -import 'mocha'; +import { Suite } from 'mocha'; import { expect } from 'chai'; -import { addHoverClass } from '../src/rebuild'; +import { addHoverClass, createCache } from '../src/rebuild'; +import { BuildCache } from '../src/types'; + +function getDuration(hrtime: [number, number]) { + const [seconds, nanoseconds] = hrtime; + return seconds * 1000 + nanoseconds / 1000000; +} + +interface ISuite extends Suite { + cache: BuildCache; +} + +describe('add hover class to hover selector related rules', function (this: ISuite) { + beforeEach(() => { + this.cache = createCache(); + }); -describe('add hover class to hover selector related rules', () => { it('will do nothing to css text without :hover', () => { const cssText = 'body { color: white }'; - expect(addHoverClass(cssText)).to.equal(cssText); + expect(addHoverClass(cssText, this.cache)).to.equal(cssText); }); it('can add hover class to css text', () => { const cssText = '.a:hover { color: white }'; - expect(addHoverClass(cssText)).to.equal( + expect(addHoverClass(cssText, this.cache)).to.equal( '.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)).to.equal( + expect(addHoverClass(cssText, this.cache)).to.equal( '.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)).to.equal( + expect(addHoverClass(cssText, this.cache)).to.equal( '.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)).to.equal( + expect(addHoverClass(cssText, this.cache)).to.equal( '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)).to.equal( + expect(addHoverClass(cssText, this.cache)).to.equal( '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)).to.equal(cssText); + expect(addHoverClass(cssText, this.cache)).to.equal(cssText); }); it('benchmark', () => { @@ -56,9 +70,28 @@ describe('add hover class to hover selector related rules', () => { 'utf8', ); const start = process.hrtime(); - addHoverClass(cssText); + addHoverClass(cssText, this.cache); const end = process.hrtime(start); - const duration = end[0] * 1_000 + end[1] / 1_000_000; + const duration = getDuration(end); expect(duration).to.below(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, this.cache); + const end = process.hrtime(start); + + const cachedStart = process.hrtime(); + addHoverClass(cssText, this.cache); + const cachedEnd = process.hrtime(cachedStart); + + expect(getDuration(cachedEnd) * factor).to.below(getDuration(end)); + }); }); diff --git a/packages/rrweb-snapshot/typings/index.d.ts b/packages/rrweb-snapshot/typings/index.d.ts index 000750d0..efd1fb34 100644 --- a/packages/rrweb-snapshot/typings/index.d.ts +++ b/packages/rrweb-snapshot/typings/index.d.ts @@ -1,5 +1,5 @@ import snapshot, { serializeNodeWithId, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE } from './snapshot'; -import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild'; +import rebuild, { buildNodeWithSN, addHoverClass, createCache } from './rebuild'; export * from './types'; export * from './utils'; -export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE, }; +export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, createCache, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE, }; diff --git a/packages/rrweb-snapshot/typings/rebuild.d.ts b/packages/rrweb-snapshot/typings/rebuild.d.ts index 6ab9f04b..64a564cf 100644 --- a/packages/rrweb-snapshot/typings/rebuild.d.ts +++ b/packages/rrweb-snapshot/typings/rebuild.d.ts @@ -1,16 +1,19 @@ -import { serializedNodeWithId, idNodeMap, INode } from './types'; -export declare function addHoverClass(cssText: string): string; +import { serializedNodeWithId, idNodeMap, INode, BuildCache } from './types'; +export declare function addHoverClass(cssText: string, cache: BuildCache): string; +export declare function createCache(): BuildCache; export declare function buildNodeWithSN(n: serializedNodeWithId, options: { doc: Document; map: idNodeMap; skipChild?: boolean; hackCss: boolean; afterAppend?: (n: INode) => unknown; + cache: BuildCache; }): INode | null; declare function rebuild(n: serializedNodeWithId, options: { doc: Document; onVisit?: (node: INode) => unknown; hackCss?: boolean; afterAppend?: (n: INode) => unknown; + cache: BuildCache; }): [Node | null, idNodeMap]; export default rebuild; diff --git a/packages/rrweb-snapshot/typings/types.d.ts b/packages/rrweb-snapshot/typings/types.d.ts index 8524801c..e83cd8b8 100644 --- a/packages/rrweb-snapshot/typings/types.d.ts +++ b/packages/rrweb-snapshot/typings/types.d.ts @@ -90,3 +90,6 @@ export declare type SlimDOMOptions = Partial<{ export declare type MaskTextFn = (text: string) => string; export declare type MaskInputFn = (text: string) => string; export declare type KeepIframeSrcFn = (src: string) => boolean; +export declare type BuildCache = { + stylesWithHoverClass: Map; +}; diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 5707feb2..9b49eba4 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -1,4 +1,11 @@ -import { rebuild, buildNodeWithSN, INode, NodeType } from 'rrweb-snapshot'; +import { + rebuild, + buildNodeWithSN, + INode, + NodeType, + BuildCache, + createCache, +} from 'rrweb-snapshot'; import * as mittProxy from 'mitt'; import { polyfill as smoothscrollPolyfill } from './smoothscroll'; import { Timer } from './timer'; @@ -97,6 +104,9 @@ export class Replayer { // Hold the list of CSSRules for in-memory state restoration private virtualStyleRulesMap!: VirtualStyleRulesMap; + // The replayer uses the cache to speed up replay and scrubbing. + private cache: BuildCache = createCache(); + private imageMap: Map = new Map(); private mirror: Mirror = createMirror(); @@ -376,6 +386,14 @@ export class Replayer { this.iframe.style.pointerEvents = 'none'; } + /** + * Empties the replayer's cache and reclaims memory. + * The replayer will use this cache to speed up the playback. + */ + public resetCache() { + this.cache = createCache(); + } + private setupDom() { this.wrapper = document.createElement('div'); this.wrapper.classList.add('replayer-wrapper'); @@ -566,6 +584,7 @@ export class Replayer { afterAppend: (builtNode) => { this.collectIframeAndAttachDocument(collected, builtNode); }, + cache: this.cache, })[1]; for (const { mutationInQueue, builtNode } of collected) { this.attachDocumentToIframe(mutationInQueue, builtNode); @@ -639,6 +658,7 @@ export class Replayer { afterAppend: (builtNode) => { this.collectIframeAndAttachDocument(collected, builtNode); }, + cache: this.cache, }); for (const { mutationInQueue, builtNode } of collected) { this.attachDocumentToIframe(mutationInQueue, builtNode); @@ -1217,6 +1237,7 @@ export class Replayer { map: this.mirror.map, skipChild: true, hackCss: true, + cache: this.cache, }) as INode; // legacy data, we should not have -1 siblings any more