Skip to content

Commit e17e84c

Browse files
fix: nested stylesheets should have absolute URLs (rrweb-io#1533)
* Replace relative URLs with absolute URLs when stringifying stylesheets * Add test to show desired behavior for imported stylesheets from seperate directory * Rename `absoluteToStylesheet` to `absolutifyURLs` and call it once after stringifying imported stylesheet * Don't create the intermediary array of the spread operator * Formalize that `stringifyRule` should expect a sheet href * Ensure a <style> element can also import and gets it's url absolutized * Handle case where non imported stylesheet has relative urls that need to be absolutified * Clarify in test files where jpegs are expected to appear in absolutified urls * Move absolutifyURLs call for import rules out of trycatch * Add a benchmarking test for stringifyStylesheet * Avoid the duplication on how to fall back --------- Co-authored-by: Eoghan Murray <[email protected]> Co-authored-by: eoghanmurray <[email protected]>
1 parent 0b16a30 commit e17e84c

13 files changed

+177
-154
lines changed

.changeset/six-llamas-brush.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"rrweb-snapshot": patch
3+
---
4+
5+
Fix `url()` rewrite for nested stylesheets by rewriting during stringification instead of after

packages/rrweb-snapshot/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"test:watch": "vitest watch",
1111
"retest:update": "vitest run --update",
1212
"test:update": "yarn build && vitest run --update",
13+
"bench": "vite build && vitest bench",
1314
"dev": "vite build --watch",
1415
"build": "yarn turbo prepublish -F rrweb-snapshot",
1516
"check-types": "tsc --noEmit",

packages/rrweb-snapshot/src/snapshot.ts

+5-67
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
getInputType,
2626
toLowerCase,
2727
extractFileExtension,
28-
nativeSetTimeout,
28+
absolutifyURLs,
2929
} from './utils';
3030

3131
let _id = 1;
@@ -54,71 +54,9 @@ function getValidTagName(element: HTMLElement): Lowercase<string> {
5454
return processedTagName;
5555
}
5656

57-
function extractOrigin(url: string): string {
58-
let origin = '';
59-
if (url.indexOf('//') > -1) {
60-
origin = url.split('/').slice(0, 3).join('/');
61-
} else {
62-
origin = url.split('/')[0];
63-
}
64-
origin = origin.split('?')[0];
65-
return origin;
66-
}
67-
6857
let canvasService: HTMLCanvasElement | null;
6958
let canvasCtx: CanvasRenderingContext2D | null;
7059

71-
const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm;
72-
const URL_PROTOCOL_MATCH = /^(?:[a-z+]+:)?\/\//i;
73-
const URL_WWW_MATCH = /^www\..*/i;
74-
const DATA_URI = /^(data:)([^,]*),(.*)/i;
75-
export function absoluteToStylesheet(
76-
cssText: string | null,
77-
href: string,
78-
): string {
79-
return (cssText || '').replace(
80-
URL_IN_CSS_REF,
81-
(
82-
origin: string,
83-
quote1: string,
84-
path1: string,
85-
quote2: string,
86-
path2: string,
87-
path3: string,
88-
) => {
89-
const filePath = path1 || path2 || path3;
90-
const maybeQuote = quote1 || quote2 || '';
91-
if (!filePath) {
92-
return origin;
93-
}
94-
if (URL_PROTOCOL_MATCH.test(filePath) || URL_WWW_MATCH.test(filePath)) {
95-
return `url(${maybeQuote}${filePath}${maybeQuote})`;
96-
}
97-
if (DATA_URI.test(filePath)) {
98-
return `url(${maybeQuote}${filePath}${maybeQuote})`;
99-
}
100-
if (filePath[0] === '/') {
101-
return `url(${maybeQuote}${
102-
extractOrigin(href) + filePath
103-
}${maybeQuote})`;
104-
}
105-
const stack = href.split('/');
106-
const parts = filePath.split('/');
107-
stack.pop();
108-
for (const part of parts) {
109-
if (part === '.') {
110-
continue;
111-
} else if (part === '..') {
112-
stack.pop();
113-
} else {
114-
stack.push(part);
115-
}
116-
}
117-
return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`;
118-
},
119-
);
120-
}
121-
12260
// eslint-disable-next-line no-control-regex
12361
const SRCSET_NOT_SPACES = /^[^ \t\n\r\u000c]+/; // Don't use \s, to avoid matching non-breaking space
12462
// eslint-disable-next-line no-control-regex
@@ -255,7 +193,7 @@ export function transformAttribute(
255193
} else if (name === 'srcset') {
256194
return getAbsoluteSrcsetString(doc, value);
257195
} else if (name === 'style') {
258-
return absoluteToStylesheet(value, getHref(doc));
196+
return absolutifyURLs(value, getHref(doc));
259197
} else if (tagName === 'object' && name === 'data') {
260198
return absoluteToDoc(doc, value);
261199
}
@@ -599,7 +537,7 @@ function serializeTextNode(
599537
n,
600538
);
601539
}
602-
textContent = absoluteToStylesheet(textContent, getHref(options.doc));
540+
textContent = absolutifyURLs(textContent, getHref(options.doc));
603541
}
604542
if (isScript) {
605543
textContent = 'SCRIPT_PLACEHOLDER';
@@ -686,7 +624,7 @@ function serializeElementNode(
686624
if (cssText) {
687625
delete attributes.rel;
688626
delete attributes.href;
689-
attributes._cssText = absoluteToStylesheet(cssText, stylesheet!.href!);
627+
attributes._cssText = cssText;
690628
}
691629
}
692630
// dynamic stylesheet
@@ -700,7 +638,7 @@ function serializeElementNode(
700638
(n as HTMLStyleElement).sheet as CSSStyleSheet,
701639
);
702640
if (cssText) {
703-
attributes._cssText = absoluteToStylesheet(cssText, getHref(doc));
641+
attributes._cssText = cssText;
704642
}
705643
}
706644
// form fields

packages/rrweb-snapshot/src/utils.ts

+84-59
Original file line numberDiff line numberDiff line change
@@ -127,61 +127,21 @@ export function escapeImportStatement(rule: CSSImportRule): string {
127127
export function stringifyStylesheet(s: CSSStyleSheet): string | null {
128128
try {
129129
const rules = s.rules || s.cssRules;
130-
const stringifiedRules = [] as string[];
131-
for (let i = 0; i < rules.length; ++i) {
132-
stringifiedRules.push(stringifyRule(rules[i]));
130+
if (!rules) {
131+
return null;
133132
}
134-
return rules
135-
? fixBrowserCompatibilityIssuesInCSS(stringifiedRules.join(''))
136-
: null;
133+
const stringifiedRules = Array.from(rules, (rule: CSSRule) =>
134+
stringifyRule(rule, s.href),
135+
).join('');
136+
return fixBrowserCompatibilityIssuesInCSS(stringifiedRules);
137137
} catch (error) {
138138
return null;
139139
}
140140
}
141141

142-
function replaceChromeGridTemplateAreas(rule: CSSStyleRule): string {
143-
const hasGridTemplateInCSSText = rule.cssText.includes('grid-template:');
144-
const hasGridTemplateAreaInStyleRules =
145-
rule.style.getPropertyValue('grid-template-areas') !== '';
146-
const hasGridTemplateAreaInCSSText = rule.cssText.includes(
147-
'grid-template-areas:',
148-
);
149-
if (
150-
isCSSStyleRule(rule) &&
151-
hasGridTemplateInCSSText &&
152-
hasGridTemplateAreaInStyleRules &&
153-
!hasGridTemplateAreaInCSSText
154-
) {
155-
// chrome does not correctly provide the grid template areas in the rules cssText
156-
// e.g. https://bugs.chromium.org/p/chromium/issues/detail?id=1303968
157-
// we remove the grid-template rule from the text... so everything from grid-template: to the next semicolon
158-
// and then add each grid-template-x rule into the css text because Chrome isn't doing this correctly
159-
const parts = rule.cssText
160-
.split(';')
161-
.filter((s) => !s.includes('grid-template:'))
162-
.map((s) => s.trim());
163-
164-
const gridStyles: string[] = [];
165-
166-
for (let i = 0; i < rule.style.length; i++) {
167-
const styleName = rule.style[i];
168-
if (styleName.startsWith('grid-template')) {
169-
gridStyles.push(
170-
`${styleName}: ${rule.style.getPropertyValue(styleName)}`,
171-
);
172-
}
173-
}
174-
parts.splice(parts.length - 1, 0, gridStyles.join('; '));
175-
return parts.join('; ');
176-
}
177-
return rule.cssText;
178-
}
179-
180-
export function stringifyRule(rule: CSSRule): string {
181-
let importStringified;
182-
let gridTemplateFixed;
183-
142+
export function stringifyRule(rule: CSSRule, sheetHref: string | null): string {
184143
if (isCSSImportRule(rule)) {
144+
let importStringified;
185145
try {
186146
importStringified =
187147
// for same-origin stylesheets,
@@ -190,19 +150,25 @@ export function stringifyRule(rule: CSSRule): string {
190150
// work around browser issues with the raw string `@import url(...)` statement
191151
escapeImportStatement(rule);
192152
} catch (error) {
193-
// ignore
153+
importStringified = rule.cssText;
194154
}
195-
} else if (isCSSStyleRule(rule) && rule.selectorText.includes(':')) {
196-
// Safari does not escape selectors with : properly
197-
// see https://bugs.webkit.org/show_bug.cgi?id=184604
198-
return fixSafariColons(rule.cssText);
199-
}
200-
201-
if (isCSSStyleRule(rule)) {
202-
gridTemplateFixed = replaceChromeGridTemplateAreas(rule);
155+
if (rule.styleSheet.href) {
156+
// url()s within the imported stylesheet are relative to _that_ sheet's href
157+
return absolutifyURLs(importStringified, rule.styleSheet.href);
158+
}
159+
return importStringified;
160+
} else {
161+
let ruleStringified = rule.cssText;
162+
if (isCSSStyleRule(rule) && rule.selectorText.includes(':')) {
163+
// Safari does not escape selectors with : properly
164+
// see https://bugs.webkit.org/show_bug.cgi?id=184604
165+
ruleStringified = fixSafariColons(ruleStringified);
166+
}
167+
if (sheetHref) {
168+
return absolutifyURLs(ruleStringified, sheetHref);
169+
}
170+
return ruleStringified;
203171
}
204-
205-
return importStringified || gridTemplateFixed || rule.cssText;
206172
}
207173

208174
export function fixSafariColons(cssStringified: string): string {
@@ -431,3 +397,62 @@ export function extractFileExtension(
431397
const match = url.pathname.match(regex);
432398
return match?.[1] ?? null;
433399
}
400+
401+
function extractOrigin(url: string): string {
402+
let origin = '';
403+
if (url.indexOf('//') > -1) {
404+
origin = url.split('/').slice(0, 3).join('/');
405+
} else {
406+
origin = url.split('/')[0];
407+
}
408+
origin = origin.split('?')[0];
409+
return origin;
410+
}
411+
412+
const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm;
413+
const URL_PROTOCOL_MATCH = /^(?:[a-z+]+:)?\/\//i;
414+
const URL_WWW_MATCH = /^www\..*/i;
415+
const DATA_URI = /^(data:)([^,]*),(.*)/i;
416+
export function absolutifyURLs(cssText: string | null, href: string): string {
417+
return (cssText || '').replace(
418+
URL_IN_CSS_REF,
419+
(
420+
origin: string,
421+
quote1: string,
422+
path1: string,
423+
quote2: string,
424+
path2: string,
425+
path3: string,
426+
) => {
427+
const filePath = path1 || path2 || path3;
428+
const maybeQuote = quote1 || quote2 || '';
429+
if (!filePath) {
430+
return origin;
431+
}
432+
if (URL_PROTOCOL_MATCH.test(filePath) || URL_WWW_MATCH.test(filePath)) {
433+
return `url(${maybeQuote}${filePath}${maybeQuote})`;
434+
}
435+
if (DATA_URI.test(filePath)) {
436+
return `url(${maybeQuote}${filePath}${maybeQuote})`;
437+
}
438+
if (filePath[0] === '/') {
439+
return `url(${maybeQuote}${
440+
extractOrigin(href) + filePath
441+
}${maybeQuote})`;
442+
}
443+
const stack = href.split('/');
444+
const parts = filePath.split('/');
445+
stack.pop();
446+
for (const part of parts) {
447+
if (part === '.') {
448+
continue;
449+
} else if (part === '..') {
450+
stack.pop();
451+
} else {
452+
stack.push(part);
453+
}
454+
}
455+
return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`;
456+
},
457+
);
458+
}

packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap

+3-2
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ exports[`integration tests > [html file]: with-style-sheet.html 1`] = `
489489
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
490490
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
491491
<title>with style sheet</title>
492-
<style>body { margin: 0px; background: url(\\"http://localhost:3030/a.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/css/b.jpg\\"); }body &gt; p { color: yellow; }</style>
492+
<style>body { margin: 0px; background: url(\\"http://localhost:3030/should-be-in-root-folder.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/css/should-be-in-css-folder.jpg\\"); }body &gt; p { color: yellow; }</style>
493493
</head><body>
494494
</body></html>"
495495
`;
@@ -500,7 +500,8 @@ exports[`integration tests > [html file]: with-style-sheet-with-import.html 1`]
500500
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
501501
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
502502
<title>with style sheet with import</title>
503-
<style>@import url(\\"//fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&amp;family=Roboto:wght@100;300;400;500;700&amp;display=swap\\\\\\"\\");body { margin: 0px; background: url(\\"http://localhost:3030/a.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/css/b.jpg\\"); }body &gt; p { color: yellow; }</style>
503+
<style>@import url(\\"//fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&amp;family=Roboto:wght@100;300;400;500;700&amp;display=swap\\\\\\"\\");body { margin: 0px; background: url(\\"http://localhost:3030/should-be-in-root-folder.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/css/should-be-in-css-folder.jpg\\"); }body &gt; p { color: yellow; }body { margin: 0px; background: url(\\"http://localhost:3030/should-be-in-root-folder.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/alt-css/should-be-in-alt-css-folder.jpg\\"); }body &gt; p { color: yellow; }</style>
504+
<style>body { margin: 0px; background: url(\\"http://localhost:3030/should-be-in-root-folder.jpg\\"); border-image: url(\\"data:image/svg+xml;utf8,&lt;svg xmlns=\\\\\\"http://www.w3.org/2000/svg\\\\\\" x=\\\\\\"0px\\\\\\" y=\\\\\\"0px\\\\\\" viewBox=\\\\\\"0 0 256 256\\\\\\"&gt;&lt;g&gt;&lt;g&gt;&lt;polygon points=\\\\\\"79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128\\\\\\"/&gt;&lt;/g&gt;&lt;/g&gt;&lt;/svg&gt;\\") 100% / 1 / 0 stretch; }p { color: red; background: url(\\"http://localhost:3030/alt-css/should-be-in-alt-css-folder.jpg\\"); }body &gt; p { color: yellow; }section { background: url(\\"http://localhost:3030/should-be-in-root-folder.jpg\\"); }</style>
504505
</head><body>
505506
</body></html>"
506507
`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
body {
2+
margin: 0;
3+
background: url('../should-be-in-root-folder.jpg');
4+
border-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 256 256"><g><g><polygon points="79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128"/></g></g></svg>');
5+
}
6+
p {
7+
color: red;
8+
background: url('./should-be-in-alt-css-folder.jpg');
9+
}
10+
body > p {
11+
color: yellow;
12+
}
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
@import '//fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&family=Roboto:wght@100;300;400;500;700&display=swap"';
22
@import './style.css';
3+
@import '../alt-css/alt-style.css';

packages/rrweb-snapshot/test/css/style.css

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
body {
22
margin: 0;
3-
background: url('../a.jpg');
3+
background: url('../should-be-in-root-folder.jpg');
44
border-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 256 256"><g><g><polygon points="79.093,0 48.907,30.187 146.72,128 48.907,225.813 79.093,256 207.093,128"/></g></g></svg>');
55
}
66
p {
77
color: red;
8-
background: url('./b.jpg');
8+
background: url('./should-be-in-css-folder.jpg');
99
}
1010
body > p {
1111
color: yellow;

packages/rrweb-snapshot/test/html/with-style-sheet-with-import.html

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
<meta http-equiv="X-UA-Compatible" content="ie=edge">
88
<title>with style sheet with import</title>
99
<link rel="stylesheet" href="/css/style-with-import.css">
10+
<style>
11+
@import '../alt-css/alt-style.css';
12+
section { background: url('./should-be-in-root-folder.jpg'); }
13+
</style>
1014
</head>
1115

1216
<body>

0 commit comments

Comments
 (0)