From ad5ac17422f4cbc450915c5dd6e0c0b0eb6c13a6 Mon Sep 17 00:00:00 2001 From: Alailson <35277996+alailsonko@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:43:30 +0100 Subject: [PATCH] fix: ensure empty string replace/replaceSync clears stylesheets (#1774) * fix: ensure empty string replace/replaceSync clears stylesheets --------- Co-authored-by: Justin Halsall Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/rrweb/src/replay/index.ts | 4 +- .../adopted-style-sheet-empty-replace.ts | 211 ++++++++++++++++++ packages/rrweb/test/replayer.test.ts | 70 +++++- 3 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 packages/rrweb/test/events/adopted-style-sheet-empty-replace.ts diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 5ee1175b..a2a7091b 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -2026,14 +2026,14 @@ export class Replayer { } }); - if (data.replace) + if (typeof data.replace === 'string') try { void styleSheet.replace?.(data.replace); } catch (e) { // for safety } - if (data.replaceSync) + if (typeof data.replaceSync === 'string') try { styleSheet.replaceSync?.(data.replaceSync); } catch (e) { diff --git a/packages/rrweb/test/events/adopted-style-sheet-empty-replace.ts b/packages/rrweb/test/events/adopted-style-sheet-empty-replace.ts new file mode 100644 index 00000000..008e087b --- /dev/null +++ b/packages/rrweb/test/events/adopted-style-sheet-empty-replace.ts @@ -0,0 +1,211 @@ +import { EventType, IncrementalSource } from '@rrweb/types'; +import type { eventWithTime } from '@rrweb/types'; + +/** + * Test events for validating that empty string replace/replaceSync clears stylesheets. + * This tests the fix for the bug where `if (data.replace)` would skip empty strings. + */ +const now = Date.now(); +export const emptyReplaceSyncEvents: eventWithTime[] = [ + { + type: EventType.Meta, + data: { + href: 'about:blank', + width: 1920, + height: 1080, + }, + timestamp: now, + }, + { + type: EventType.FullSnapshot, + data: { + node: { + type: 0, + childNodes: [ + { + type: 1, + name: 'html', + publicId: '', + systemId: '', + id: 2, + }, + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + id: 4, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'test element', + id: 6, + }, + ], + id: 5, + }, + ], + id: 3, + }, + ], + id: 7, + }, + ], + id: 1, + }, + initialOffset: { + left: 0, + top: 0, + }, + }, + timestamp: now + 100, + }, + // Adopt a stylesheet with initial styles + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.AdoptedStyleSheet, + id: 1, + styles: [ + { + styleId: 1, + rules: [ + { + rule: 'div { background: red; color: white; }', + index: 0, + }, + ], + }, + ], + styleIds: [1], + }, + timestamp: now + 200, + }, + // Clear stylesheet using replaceSync('') - this was the bug! + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + styleId: 1, + replaceSync: '', + }, + timestamp: now + 300, + }, +]; + +export const emptyReplaceEvents: eventWithTime[] = [ + { + type: EventType.Meta, + data: { + href: 'about:blank', + width: 1920, + height: 1080, + }, + timestamp: now, + }, + { + type: EventType.FullSnapshot, + data: { + node: { + type: 0, + childNodes: [ + { + type: 1, + name: 'html', + publicId: '', + systemId: '', + id: 2, + }, + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + id: 4, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'div', + attributes: {}, + childNodes: [ + { + type: 3, + textContent: 'test element', + id: 6, + }, + ], + id: 5, + }, + ], + id: 3, + }, + ], + id: 7, + }, + ], + id: 1, + }, + initialOffset: { + left: 0, + top: 0, + }, + }, + timestamp: now + 100, + }, + // Adopt a stylesheet with initial styles + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.AdoptedStyleSheet, + id: 1, + styles: [ + { + styleId: 1, + rules: [ + { + rule: 'div { background: blue; color: yellow; }', + index: 0, + }, + ], + }, + ], + styleIds: [1], + }, + timestamp: now + 200, + }, + // Clear stylesheet using replace('') - this was the bug! + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + styleId: 1, + replace: '', + }, + timestamp: now + 300, + }, +]; diff --git a/packages/rrweb/test/replayer.test.ts b/packages/rrweb/test/replayer.test.ts index b51079a0..878bc4aa 100644 --- a/packages/rrweb/test/replayer.test.ts +++ b/packages/rrweb/test/replayer.test.ts @@ -25,6 +25,10 @@ import StyleSheetTextMutation from './events/style-sheet-text-mutation'; import canvasInIframe from './events/canvas-in-iframe'; import adoptedStyleSheet from './events/adopted-style-sheet'; import adoptedStyleSheetModification from './events/adopted-style-sheet-modification'; +import { + emptyReplaceSyncEvents, + emptyReplaceEvents, +} from './events/adopted-style-sheet-empty-replace'; import nestedStyleDeclarationEvents from './events/nested-style-declaration'; import styleDeclarationMissingRuleEvents from './events/style-declaration-missing-rule'; import documentReplacementEvents from './events/document-replacement'; @@ -1066,14 +1070,76 @@ describe('replayer', function () { await check600ms(); }); - it('can replay StyleDeclaration events on nested CSS rules inside @media', async () => { + it('can clear adopted stylesheets with empty replaceSync', async () => { await page.evaluate(` - events = ${JSON.stringify(nestedStyleDeclarationEvents)}; + events = ${JSON.stringify(emptyReplaceSyncEvents)}; + + const { Replayer } = rrweb; + var replayer = new Replayer(events, { showDebug: true }); + replayer.pause(0); + `); + + const iframe = await page.$('iframe'); + const contentDocument = await iframe!.contentFrame()!; + + // At 250ms, stylesheet should have rules + await page.evaluate('replayer.pause(250);'); + expect( + await contentDocument!.evaluate( + () => + document.adoptedStyleSheets.length === 1 && + document.adoptedStyleSheets[0].cssRules.length === 1, + ), + ).toBeTruthy(); + + // At 350ms, stylesheet should be empty after replaceSync('') + await page.evaluate('replayer.pause(350);'); + expect( + await contentDocument!.evaluate( + () => + document.adoptedStyleSheets.length === 1 && + document.adoptedStyleSheets[0].cssRules.length === 0, + ), + ).toBeTruthy(); + }); + + it('can clear adopted stylesheets with empty replace', async () => { + await page.evaluate(` + events = ${JSON.stringify(emptyReplaceEvents)}; const { Replayer } = rrweb; var replayer = new Replayer(events, { showDebug: true }); replayer.pause(0); `); + const iframe = await page.$('iframe'); + const contentDocument = await iframe!.contentFrame()!; + + // At 250ms, stylesheet should have rules + await page.evaluate('replayer.pause(250);'); + expect( + await contentDocument!.evaluate( + () => + document.adoptedStyleSheets.length === 1 && + document.adoptedStyleSheets[0].cssRules.length === 1, + ), + ).toBeTruthy(); + + // At 350ms, stylesheet should be empty after replace('') + await page.evaluate('replayer.pause(350);'); + await contentDocument!.waitForFunction( + () => + document.adoptedStyleSheets.length === 1 && + document.adoptedStyleSheets[0].cssRules.length === 0, + ); + }); + + it('can replay StyleDeclaration events on nested CSS rules inside @media', async () => { + await page.evaluate(` + events = ${JSON.stringify(nestedStyleDeclarationEvents)}; + const { Replayer } = rrweb; + var replayer = new Replayer(events, { showDebug: true }); + replayer.pause(0); + `); // At 250ms, setProperty on [0, 0] should change background-color to red const bgColorAfterSet = await page.evaluate(` replayer.pause(250);