Skip to content

Commit 7e7f05b

Browse files
authored
Fix Safari broken rules (#86)
* fix: repair Safari broken rules * refactor: remove CSSStyleSheet methods that are unsupported in Chrome * test: add tests for Safari broken rules * feat: add support for Safari broken rules for addRule * chore: increase size-limit to 2.5 kB * test: fix tests for IE11 * refactor: use another approach for fixing Safari bug * refactor: simplify approach to fix Safari bug * refactor: use arrow functions * refactor: fix replacement * chore: run build before size-limit * fix: apply broken rule fix only to basic stylesheet * test: use regexp to match content property value with broken rules bug * refactor: simplify pattern building approach & add some docs * refactor: use more correct placeholder matching * refactor: remove unnecessary code * refactor: apply bundle size optimizations
1 parent 34090df commit 7e7f05b

File tree

8 files changed

+218
-110
lines changed

8 files changed

+218
-110
lines changed

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module.exports = [
22
{
33
path: 'dist/adoptedStyleSheets.js',
4-
limit: '2 kB',
4+
limit: '2.5 kB',
55
},
66
];

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"pretest": "rimraf .coverage",
2424
"pretest:watch": "npm run pretest",
2525
"pretest:coverage": "npm run pretest",
26-
"size": "size-limit",
26+
"size": "npm run build && size-limit",
2727
"typecheck": "tsc --noEmit"
2828
},
2929
"repository": {

src/ConstructedStyleSheet.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,12 @@
11
import type Location from './Location';
2-
import {_DOMException, bootstrapper} from './shared';
3-
import {
4-
clearRules,
5-
defineProperty,
6-
insertAllRules,
7-
rejectImports,
8-
} from './utils';
2+
import {fixBrokenRules, hasBrokenRules} from './safari';
3+
import {_DOMException, bootstrapper, defineProperty} from './shared';
4+
import {clearRules, insertAllRules, rejectImports} from './utils';
95

106
const cssStyleSheetMethods = [
11-
'addImport',
12-
'addPageRule',
137
'addRule',
148
'deleteRule',
159
'insertRule',
16-
'removeImport',
1710
'removeRule',
1811
];
1912

@@ -146,6 +139,7 @@ function checkInvocationCorrectness(self: ConstructedStyleSheet) {
146139
*/
147140
declare class ConstructedStyleSheet extends CSSStyleSheet {
148141
replace(text: string): Promise<ConstructedStyleSheet>;
142+
149143
replaceSync(text: string): void;
150144
}
151145

@@ -182,7 +176,9 @@ proto.replaceSync = function replaceSync(contents) {
182176
const self = this;
183177

184178
const style = $basicStyleSheet.get(self)!.ownerNode as HTMLStyleElement;
185-
style.textContent = rejectImports(contents);
179+
style.textContent = hasBrokenRules
180+
? fixBrokenRules(rejectImports(contents))
181+
: rejectImports(contents);
186182
$basicStyleSheet.set(self, style.sheet!);
187183

188184
$locations.get(self)!.forEach((location) => {
@@ -211,12 +207,8 @@ cssStyleSheetMethods.forEach((method) => {
211207
checkInvocationCorrectness(self);
212208

213209
const args = arguments;
214-
const basic = $basicStyleSheet.get(self)!;
215-
const locations = $locations.get(self)!;
216-
217-
const result = basic[method].apply(basic, args);
218210

219-
locations.forEach((location) => {
211+
$locations.get(self)!.forEach((location) => {
220212
if (location.isConnected()) {
221213
// Type Note: If location is connected, adopter is already created; and
222214
// since it is connected to DOM, the sheet cannot be null.
@@ -225,7 +217,19 @@ cssStyleSheetMethods.forEach((method) => {
225217
}
226218
});
227219

228-
return result;
220+
if (hasBrokenRules) {
221+
if (method === 'insertRule') {
222+
args[0] = fixBrokenRules(args[0]);
223+
}
224+
225+
if (method === 'addRule') {
226+
args[1] = fixBrokenRules(args[1]);
227+
}
228+
}
229+
230+
const basic = $basicStyleSheet.get(self)!;
231+
232+
return basic[method].apply(basic, args);
229233
};
230234
});
231235

src/Location.ts

Lines changed: 73 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@ import {
77
removeAdopterLocation,
88
restyleAdopter,
99
} from './ConstructedStyleSheet';
10-
import {hasShadyCss} from './shared';
10+
import {defineProperty, forEach, hasShadyCss} from './shared';
1111
import {
12-
defineProperty,
1312
diff,
14-
forEach,
1513
getShadowRoot,
1614
isElementConnected,
1715
removeNode,
@@ -264,80 +262,78 @@ function Location(this: Location, element: Document | ShadowRoot) {
264262
);
265263
}
266264

267-
const proto = Location.prototype;
268-
269-
proto.isConnected = function isConnected() {
270-
const element = $element.get(this)!;
271-
272-
return element instanceof Document
273-
? element.readyState !== 'loading'
274-
: isElementConnected(element.host);
275-
};
276-
277-
proto.connect = function connect() {
278-
const container = getAdopterContainer(this);
279-
280-
$observer.get(this)!.observe(container, defaultObserverOptions);
281-
282-
if ($uniqueSheets.get(this)!.length > 0) {
283-
adopt(this);
284-
}
285-
286-
traverseWebComponents(container, (root) => {
287-
getAssociatedLocation(root).connect();
288-
});
289-
};
290-
291-
proto.disconnect = function disconnect() {
292-
$observer.get(this)!.disconnect();
293-
};
294-
295-
proto.update = function update(sheets: readonly ConstructedStyleSheet[]) {
296-
const self = this;
297-
const locationType =
298-
$element.get(self) === document ? 'Document' : 'ShadowRoot';
299-
300-
if (!Array.isArray(sheets)) {
301-
// document.adoptedStyleSheets = new CSSStyleSheet();
302-
throw new TypeError(
303-
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Iterator getter is not callable.`,
304-
);
305-
}
306-
307-
if (!sheets.every(isCSSStyleSheetInstance)) {
308-
// document.adoptedStyleSheets = ['non-CSSStyleSheet value'];
309-
throw new TypeError(
310-
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Failed to convert value to 'CSSStyleSheet'`,
311-
);
312-
}
313-
314-
if (sheets.some(isNonConstructedStyleSheetInstance)) {
315-
// document.adoptedStyleSheets = [document.styleSheets[0]];
316-
throw new TypeError(
317-
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Can't adopt non-constructed stylesheets`,
318-
);
319-
}
320-
321-
self.sheets = sheets;
322-
const oldUniqueSheets = $uniqueSheets.get(self)!;
323-
const uniqueSheets = unique(sheets);
324-
325-
// Style sheets that existed in the old sheet list but was excluded in the
326-
// new one.
327-
const removedSheets = diff(oldUniqueSheets, uniqueSheets);
328-
329-
removedSheets.forEach((sheet) => {
330-
// Type Note: any removed sheet is already initialized, so there cannot be
331-
// missing adopter for this location.
332-
removeNode(getAdopterByLocation(sheet, self)!);
333-
removeAdopterLocation(sheet, self);
334-
});
335-
336-
$uniqueSheets.set(self, uniqueSheets);
337-
338-
if (self.isConnected() && uniqueSheets.length > 0) {
339-
adopt(self);
340-
}
265+
// @ts-expect-error: too generic for TypeScript
266+
Location.prototype = {
267+
isConnected() {
268+
const element = $element.get(this)!;
269+
270+
return element instanceof Document
271+
? element.readyState !== 'loading'
272+
: isElementConnected(element.host);
273+
},
274+
connect() {
275+
const container = getAdopterContainer(this);
276+
277+
$observer.get(this)!.observe(container, defaultObserverOptions);
278+
279+
if ($uniqueSheets.get(this)!.length > 0) {
280+
adopt(this);
281+
}
282+
283+
traverseWebComponents(container, (root) => {
284+
getAssociatedLocation(root).connect();
285+
});
286+
},
287+
disconnect() {
288+
$observer.get(this)!.disconnect();
289+
},
290+
update(sheets: readonly ConstructedStyleSheet[]) {
291+
const self = this;
292+
const locationType =
293+
$element.get(self) === document ? 'Document' : 'ShadowRoot';
294+
295+
if (!Array.isArray(sheets)) {
296+
// document.adoptedStyleSheets = new CSSStyleSheet();
297+
throw new TypeError(
298+
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Iterator getter is not callable.`,
299+
);
300+
}
301+
302+
if (!sheets.every(isCSSStyleSheetInstance)) {
303+
// document.adoptedStyleSheets = ['non-CSSStyleSheet value'];
304+
throw new TypeError(
305+
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Failed to convert value to 'CSSStyleSheet'`,
306+
);
307+
}
308+
309+
if (sheets.some(isNonConstructedStyleSheetInstance)) {
310+
// document.adoptedStyleSheets = [document.styleSheets[0]];
311+
throw new TypeError(
312+
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Can't adopt non-constructed stylesheets`,
313+
);
314+
}
315+
316+
self.sheets = sheets;
317+
const oldUniqueSheets = $uniqueSheets.get(self)!;
318+
const uniqueSheets = unique(sheets);
319+
320+
// Style sheets that existed in the old sheet list but was excluded in the
321+
// new one.
322+
const removedSheets = diff(oldUniqueSheets, uniqueSheets);
323+
324+
removedSheets.forEach((sheet) => {
325+
// Type Note: any removed sheet is already initialized, so there cannot be
326+
// missing adopter for this location.
327+
removeNode(getAdopterByLocation(sheet, self)!);
328+
removeAdopterLocation(sheet, self);
329+
});
330+
331+
$uniqueSheets.set(self, uniqueSheets);
332+
333+
if (self.isConnected() && uniqueSheets.length > 0) {
334+
adopt(self);
335+
}
336+
},
341337
};
342338

343339
export default Location;

src/safari.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {bootstrapper} from './shared';
2+
3+
export const hasBrokenRules = (function () {
4+
const style = bootstrapper.createElement('style');
5+
style.textContent = '.x{content:"y"}';
6+
bootstrapper.body.appendChild(style);
7+
8+
return (style.sheet!.cssRules[0] as CSSStyleRule).style.content !== '"y"';
9+
})();
10+
11+
const brokenRulePatterns = [/content:\s*["']/gm];
12+
13+
/**
14+
* Adds a special symbol "%" to the broken rule that forces the internal Safari
15+
* CSS property string converter to add quotes around the value. This function
16+
* should be only used for the internal basic stylesheet hidden in the
17+
* bootstrapper because it pollutes the user content with the placeholder
18+
* symbols. Use the `getCssText` function to remove the placeholder from the
19+
* CSS string.
20+
*
21+
* @param content
22+
*/
23+
export function fixBrokenRules(content: string): string {
24+
return brokenRulePatterns.reduce(
25+
(acc, pattern) => acc.replace(pattern, '$&%%%'),
26+
content,
27+
);
28+
}
29+
30+
const placeholderPatterns = [/(content:\s*["'])%%%/gm];
31+
32+
/**
33+
* Removes the placeholder added by `fixBrokenRules` function from the received
34+
* rule string.
35+
*/
36+
export const getCssText = hasBrokenRules
37+
? (rule: CSSRule) =>
38+
placeholderPatterns.reduce(
39+
(acc, pattern) => acc.replace(pattern, '$1'),
40+
rule.cssText,
41+
)
42+
: (rule: CSSRule) => rule.cssText;

src/shared.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const hasShadyCss = 'ShadyCSS' in window && !ShadyCSS.nativeShadow;
99
* The in-memory HTMLDocument that is necessary to get the internal
1010
* CSSStyleSheet of a basic `<style>` element.
1111
*/
12-
export const bootstrapper = document.implementation.createHTMLDocument('boot');
12+
export const bootstrapper = document.implementation.createHTMLDocument('');
1313

1414
/**
1515
* Since ShadowRoots with the closed mode are not available via
@@ -21,3 +21,6 @@ export const closedShadowRootRegistry = new WeakMap<Element, ShadowRoot>();
2121
// Workaround for IE that does not support the DOMException constructor
2222
export const _DOMException =
2323
typeof DOMException === 'object' ? Error : DOMException;
24+
25+
export const defineProperty = Object.defineProperty;
26+
export const forEach = Array.prototype.forEach;

src/utils.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import {closedShadowRootRegistry} from './shared';
2-
3-
export const defineProperty = Object.defineProperty;
4-
export const forEach = Array.prototype.forEach;
1+
import {getCssText} from './safari';
2+
import {closedShadowRootRegistry, forEach} from './shared';
53

64
const importPattern = /@import.+?;?$/gm;
75

@@ -25,7 +23,7 @@ export function clearRules(sheet: CSSStyleSheet): void {
2523

2624
export function insertAllRules(from: CSSStyleSheet, to: CSSStyleSheet): void {
2725
forEach.call(from.cssRules, (rule, i) => {
28-
to.insertRule(rule.cssText, i);
26+
to.insertRule(getCssText(rule), i);
2927
});
3028
}
3129

0 commit comments

Comments
 (0)