Skip to content

Commit ad5ac17

Browse files
alailsonkoJuice10Copilot
authored
fix: ensure empty string replace/replaceSync clears stylesheets (rrweb-io#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>
1 parent b149cf3 commit ad5ac17

File tree

3 files changed

+281
-4
lines changed

3 files changed

+281
-4
lines changed

packages/rrweb/src/replay/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,14 +2026,14 @@ export class Replayer {
20262026
}
20272027
});
20282028

2029-
if (data.replace)
2029+
if (typeof data.replace === 'string')
20302030
try {
20312031
void styleSheet.replace?.(data.replace);
20322032
} catch (e) {
20332033
// for safety
20342034
}
20352035

2036-
if (data.replaceSync)
2036+
if (typeof data.replaceSync === 'string')
20372037
try {
20382038
styleSheet.replaceSync?.(data.replaceSync);
20392039
} catch (e) {
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { EventType, IncrementalSource } from '@rrweb/types';
2+
import type { eventWithTime } from '@rrweb/types';
3+
4+
/**
5+
* Test events for validating that empty string replace/replaceSync clears stylesheets.
6+
* This tests the fix for the bug where `if (data.replace)` would skip empty strings.
7+
*/
8+
const now = Date.now();
9+
export const emptyReplaceSyncEvents: eventWithTime[] = [
10+
{
11+
type: EventType.Meta,
12+
data: {
13+
href: 'about:blank',
14+
width: 1920,
15+
height: 1080,
16+
},
17+
timestamp: now,
18+
},
19+
{
20+
type: EventType.FullSnapshot,
21+
data: {
22+
node: {
23+
type: 0,
24+
childNodes: [
25+
{
26+
type: 1,
27+
name: 'html',
28+
publicId: '',
29+
systemId: '',
30+
id: 2,
31+
},
32+
{
33+
type: 2,
34+
tagName: 'html',
35+
attributes: {},
36+
childNodes: [
37+
{
38+
type: 2,
39+
tagName: 'head',
40+
attributes: {},
41+
childNodes: [],
42+
id: 4,
43+
},
44+
{
45+
type: 2,
46+
tagName: 'body',
47+
attributes: {},
48+
childNodes: [
49+
{
50+
type: 2,
51+
tagName: 'div',
52+
attributes: {},
53+
childNodes: [
54+
{
55+
type: 3,
56+
textContent: 'test element',
57+
id: 6,
58+
},
59+
],
60+
id: 5,
61+
},
62+
],
63+
id: 3,
64+
},
65+
],
66+
id: 7,
67+
},
68+
],
69+
id: 1,
70+
},
71+
initialOffset: {
72+
left: 0,
73+
top: 0,
74+
},
75+
},
76+
timestamp: now + 100,
77+
},
78+
// Adopt a stylesheet with initial styles
79+
{
80+
type: EventType.IncrementalSnapshot,
81+
data: {
82+
source: IncrementalSource.AdoptedStyleSheet,
83+
id: 1,
84+
styles: [
85+
{
86+
styleId: 1,
87+
rules: [
88+
{
89+
rule: 'div { background: red; color: white; }',
90+
index: 0,
91+
},
92+
],
93+
},
94+
],
95+
styleIds: [1],
96+
},
97+
timestamp: now + 200,
98+
},
99+
// Clear stylesheet using replaceSync('') - this was the bug!
100+
{
101+
type: EventType.IncrementalSnapshot,
102+
data: {
103+
source: IncrementalSource.StyleSheetRule,
104+
styleId: 1,
105+
replaceSync: '',
106+
},
107+
timestamp: now + 300,
108+
},
109+
];
110+
111+
export const emptyReplaceEvents: eventWithTime[] = [
112+
{
113+
type: EventType.Meta,
114+
data: {
115+
href: 'about:blank',
116+
width: 1920,
117+
height: 1080,
118+
},
119+
timestamp: now,
120+
},
121+
{
122+
type: EventType.FullSnapshot,
123+
data: {
124+
node: {
125+
type: 0,
126+
childNodes: [
127+
{
128+
type: 1,
129+
name: 'html',
130+
publicId: '',
131+
systemId: '',
132+
id: 2,
133+
},
134+
{
135+
type: 2,
136+
tagName: 'html',
137+
attributes: {},
138+
childNodes: [
139+
{
140+
type: 2,
141+
tagName: 'head',
142+
attributes: {},
143+
childNodes: [],
144+
id: 4,
145+
},
146+
{
147+
type: 2,
148+
tagName: 'body',
149+
attributes: {},
150+
childNodes: [
151+
{
152+
type: 2,
153+
tagName: 'div',
154+
attributes: {},
155+
childNodes: [
156+
{
157+
type: 3,
158+
textContent: 'test element',
159+
id: 6,
160+
},
161+
],
162+
id: 5,
163+
},
164+
],
165+
id: 3,
166+
},
167+
],
168+
id: 7,
169+
},
170+
],
171+
id: 1,
172+
},
173+
initialOffset: {
174+
left: 0,
175+
top: 0,
176+
},
177+
},
178+
timestamp: now + 100,
179+
},
180+
// Adopt a stylesheet with initial styles
181+
{
182+
type: EventType.IncrementalSnapshot,
183+
data: {
184+
source: IncrementalSource.AdoptedStyleSheet,
185+
id: 1,
186+
styles: [
187+
{
188+
styleId: 1,
189+
rules: [
190+
{
191+
rule: 'div { background: blue; color: yellow; }',
192+
index: 0,
193+
},
194+
],
195+
},
196+
],
197+
styleIds: [1],
198+
},
199+
timestamp: now + 200,
200+
},
201+
// Clear stylesheet using replace('') - this was the bug!
202+
{
203+
type: EventType.IncrementalSnapshot,
204+
data: {
205+
source: IncrementalSource.StyleSheetRule,
206+
styleId: 1,
207+
replace: '',
208+
},
209+
timestamp: now + 300,
210+
},
211+
];

packages/rrweb/test/replayer.test.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import StyleSheetTextMutation from './events/style-sheet-text-mutation';
2525
import canvasInIframe from './events/canvas-in-iframe';
2626
import adoptedStyleSheet from './events/adopted-style-sheet';
2727
import adoptedStyleSheetModification from './events/adopted-style-sheet-modification';
28+
import {
29+
emptyReplaceSyncEvents,
30+
emptyReplaceEvents,
31+
} from './events/adopted-style-sheet-empty-replace';
2832
import nestedStyleDeclarationEvents from './events/nested-style-declaration';
2933
import styleDeclarationMissingRuleEvents from './events/style-declaration-missing-rule';
3034
import documentReplacementEvents from './events/document-replacement';
@@ -1066,14 +1070,76 @@ describe('replayer', function () {
10661070
await check600ms();
10671071
});
10681072

1069-
it('can replay StyleDeclaration events on nested CSS rules inside @media', async () => {
1073+
it('can clear adopted stylesheets with empty replaceSync', async () => {
10701074
await page.evaluate(`
1071-
events = ${JSON.stringify(nestedStyleDeclarationEvents)};
1075+
events = ${JSON.stringify(emptyReplaceSyncEvents)};
1076+
1077+
const { Replayer } = rrweb;
1078+
var replayer = new Replayer(events, { showDebug: true });
1079+
replayer.pause(0);
1080+
`);
1081+
1082+
const iframe = await page.$('iframe');
1083+
const contentDocument = await iframe!.contentFrame()!;
1084+
1085+
// At 250ms, stylesheet should have rules
1086+
await page.evaluate('replayer.pause(250);');
1087+
expect(
1088+
await contentDocument!.evaluate(
1089+
() =>
1090+
document.adoptedStyleSheets.length === 1 &&
1091+
document.adoptedStyleSheets[0].cssRules.length === 1,
1092+
),
1093+
).toBeTruthy();
1094+
1095+
// At 350ms, stylesheet should be empty after replaceSync('')
1096+
await page.evaluate('replayer.pause(350);');
1097+
expect(
1098+
await contentDocument!.evaluate(
1099+
() =>
1100+
document.adoptedStyleSheets.length === 1 &&
1101+
document.adoptedStyleSheets[0].cssRules.length === 0,
1102+
),
1103+
).toBeTruthy();
1104+
});
1105+
1106+
it('can clear adopted stylesheets with empty replace', async () => {
1107+
await page.evaluate(`
1108+
events = ${JSON.stringify(emptyReplaceEvents)};
10721109
const { Replayer } = rrweb;
10731110
var replayer = new Replayer(events, { showDebug: true });
10741111
replayer.pause(0);
10751112
`);
10761113

1114+
const iframe = await page.$('iframe');
1115+
const contentDocument = await iframe!.contentFrame()!;
1116+
1117+
// At 250ms, stylesheet should have rules
1118+
await page.evaluate('replayer.pause(250);');
1119+
expect(
1120+
await contentDocument!.evaluate(
1121+
() =>
1122+
document.adoptedStyleSheets.length === 1 &&
1123+
document.adoptedStyleSheets[0].cssRules.length === 1,
1124+
),
1125+
).toBeTruthy();
1126+
1127+
// At 350ms, stylesheet should be empty after replace('')
1128+
await page.evaluate('replayer.pause(350);');
1129+
await contentDocument!.waitForFunction(
1130+
() =>
1131+
document.adoptedStyleSheets.length === 1 &&
1132+
document.adoptedStyleSheets[0].cssRules.length === 0,
1133+
);
1134+
});
1135+
1136+
it('can replay StyleDeclaration events on nested CSS rules inside @media', async () => {
1137+
await page.evaluate(`
1138+
events = ${JSON.stringify(nestedStyleDeclarationEvents)};
1139+
const { Replayer } = rrweb;
1140+
var replayer = new Replayer(events, { showDebug: true });
1141+
replayer.pause(0);
1142+
`);
10771143
// At 250ms, setProperty on [0, 0] should change background-color to red
10781144
const bgColorAfterSet = await page.evaluate(`
10791145
replayer.pause(250);

0 commit comments

Comments
 (0)