fix: ensure empty string replace/replaceSync clears stylesheets (#1774)

* fix: ensure empty string replace/replaceSync clears stylesheets

---------

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Alailson
2026-04-01 12:00:00 +08:00
committed by GitHub
parent d5e0443926
commit 360ed54d2d
3 changed files with 281 additions and 4 deletions

View File

@@ -2026,14 +2026,14 @@ export class Replayer {
} }
}); });
if (data.replace) if (typeof data.replace === 'string')
try { try {
void styleSheet.replace?.(data.replace); void styleSheet.replace?.(data.replace);
} catch (e) { } catch (e) {
// for safety // for safety
} }
if (data.replaceSync) if (typeof data.replaceSync === 'string')
try { try {
styleSheet.replaceSync?.(data.replaceSync); styleSheet.replaceSync?.(data.replaceSync);
} catch (e) { } catch (e) {

View File

@@ -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,
},
];

View File

@@ -25,6 +25,10 @@ import StyleSheetTextMutation from './events/style-sheet-text-mutation';
import canvasInIframe from './events/canvas-in-iframe'; import canvasInIframe from './events/canvas-in-iframe';
import adoptedStyleSheet from './events/adopted-style-sheet'; import adoptedStyleSheet from './events/adopted-style-sheet';
import adoptedStyleSheetModification from './events/adopted-style-sheet-modification'; 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 nestedStyleDeclarationEvents from './events/nested-style-declaration';
import styleDeclarationMissingRuleEvents from './events/style-declaration-missing-rule'; import styleDeclarationMissingRuleEvents from './events/style-declaration-missing-rule';
import documentReplacementEvents from './events/document-replacement'; import documentReplacementEvents from './events/document-replacement';
@@ -1066,14 +1070,76 @@ describe('replayer', function () {
await check600ms(); 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(` 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; const { Replayer } = rrweb;
var replayer = new Replayer(events, { showDebug: true }); var replayer = new Replayer(events, { showDebug: true });
replayer.pause(0); 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 // At 250ms, setProperty on [0, 0] should change background-color to red
const bgColorAfterSet = await page.evaluate(` const bgColorAfterSet = await page.evaluate(`
replayer.pause(250); replayer.pause(250);