Skip to content

Commit

Permalink
Fix Safari broken rules (#86)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Lodin committed Oct 1, 2021
1 parent 34090df commit 7e7f05b
Show file tree
Hide file tree
Showing 8 changed files with 218 additions and 110 deletions.
2 changes: 1 addition & 1 deletion .size-limit.js
@@ -1,6 +1,6 @@
module.exports = [
{
path: 'dist/adoptedStyleSheets.js',
limit: '2 kB',
limit: '2.5 kB',
},
];
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -23,7 +23,7 @@
"pretest": "rimraf .coverage",
"pretest:watch": "npm run pretest",
"pretest:coverage": "npm run pretest",
"size": "size-limit",
"size": "npm run build && size-limit",
"typecheck": "tsc --noEmit"
},
"repository": {
Expand Down
38 changes: 21 additions & 17 deletions src/ConstructedStyleSheet.ts
@@ -1,19 +1,12 @@
import type Location from './Location';
import {_DOMException, bootstrapper} from './shared';
import {
clearRules,
defineProperty,
insertAllRules,
rejectImports,
} from './utils';
import {fixBrokenRules, hasBrokenRules} from './safari';
import {_DOMException, bootstrapper, defineProperty} from './shared';
import {clearRules, insertAllRules, rejectImports} from './utils';

const cssStyleSheetMethods = [
'addImport',
'addPageRule',
'addRule',
'deleteRule',
'insertRule',
'removeImport',
'removeRule',
];

Expand Down Expand Up @@ -146,6 +139,7 @@ function checkInvocationCorrectness(self: ConstructedStyleSheet) {
*/
declare class ConstructedStyleSheet extends CSSStyleSheet {
replace(text: string): Promise<ConstructedStyleSheet>;

replaceSync(text: string): void;
}

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

const style = $basicStyleSheet.get(self)!.ownerNode as HTMLStyleElement;
style.textContent = rejectImports(contents);
style.textContent = hasBrokenRules
? fixBrokenRules(rejectImports(contents))
: rejectImports(contents);
$basicStyleSheet.set(self, style.sheet!);

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

const args = arguments;
const basic = $basicStyleSheet.get(self)!;
const locations = $locations.get(self)!;

const result = basic[method].apply(basic, args);

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

return result;
if (hasBrokenRules) {
if (method === 'insertRule') {
args[0] = fixBrokenRules(args[0]);
}

if (method === 'addRule') {
args[1] = fixBrokenRules(args[1]);
}
}

const basic = $basicStyleSheet.get(self)!;

return basic[method].apply(basic, args);
};
});

Expand Down
150 changes: 73 additions & 77 deletions src/Location.ts
Expand Up @@ -7,11 +7,9 @@ import {
removeAdopterLocation,
restyleAdopter,
} from './ConstructedStyleSheet';
import {hasShadyCss} from './shared';
import {defineProperty, forEach, hasShadyCss} from './shared';
import {
defineProperty,
diff,
forEach,
getShadowRoot,
isElementConnected,
removeNode,
Expand Down Expand Up @@ -264,80 +262,78 @@ function Location(this: Location, element: Document | ShadowRoot) {
);
}

const proto = Location.prototype;

proto.isConnected = function isConnected() {
const element = $element.get(this)!;

return element instanceof Document
? element.readyState !== 'loading'
: isElementConnected(element.host);
};

proto.connect = function connect() {
const container = getAdopterContainer(this);

$observer.get(this)!.observe(container, defaultObserverOptions);

if ($uniqueSheets.get(this)!.length > 0) {
adopt(this);
}

traverseWebComponents(container, (root) => {
getAssociatedLocation(root).connect();
});
};

proto.disconnect = function disconnect() {
$observer.get(this)!.disconnect();
};

proto.update = function update(sheets: readonly ConstructedStyleSheet[]) {
const self = this;
const locationType =
$element.get(self) === document ? 'Document' : 'ShadowRoot';

if (!Array.isArray(sheets)) {
// document.adoptedStyleSheets = new CSSStyleSheet();
throw new TypeError(
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Iterator getter is not callable.`,
);
}

if (!sheets.every(isCSSStyleSheetInstance)) {
// document.adoptedStyleSheets = ['non-CSSStyleSheet value'];
throw new TypeError(
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Failed to convert value to 'CSSStyleSheet'`,
);
}

if (sheets.some(isNonConstructedStyleSheetInstance)) {
// document.adoptedStyleSheets = [document.styleSheets[0]];
throw new TypeError(
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Can't adopt non-constructed stylesheets`,
);
}

self.sheets = sheets;
const oldUniqueSheets = $uniqueSheets.get(self)!;
const uniqueSheets = unique(sheets);

// Style sheets that existed in the old sheet list but was excluded in the
// new one.
const removedSheets = diff(oldUniqueSheets, uniqueSheets);

removedSheets.forEach((sheet) => {
// Type Note: any removed sheet is already initialized, so there cannot be
// missing adopter for this location.
removeNode(getAdopterByLocation(sheet, self)!);
removeAdopterLocation(sheet, self);
});

$uniqueSheets.set(self, uniqueSheets);

if (self.isConnected() && uniqueSheets.length > 0) {
adopt(self);
}
// @ts-expect-error: too generic for TypeScript
Location.prototype = {
isConnected() {
const element = $element.get(this)!;

return element instanceof Document
? element.readyState !== 'loading'
: isElementConnected(element.host);
},
connect() {
const container = getAdopterContainer(this);

$observer.get(this)!.observe(container, defaultObserverOptions);

if ($uniqueSheets.get(this)!.length > 0) {
adopt(this);
}

traverseWebComponents(container, (root) => {
getAssociatedLocation(root).connect();
});
},
disconnect() {
$observer.get(this)!.disconnect();
},
update(sheets: readonly ConstructedStyleSheet[]) {
const self = this;
const locationType =
$element.get(self) === document ? 'Document' : 'ShadowRoot';

if (!Array.isArray(sheets)) {
// document.adoptedStyleSheets = new CSSStyleSheet();
throw new TypeError(
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Iterator getter is not callable.`,
);
}

if (!sheets.every(isCSSStyleSheetInstance)) {
// document.adoptedStyleSheets = ['non-CSSStyleSheet value'];
throw new TypeError(
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Failed to convert value to 'CSSStyleSheet'`,
);
}

if (sheets.some(isNonConstructedStyleSheetInstance)) {
// document.adoptedStyleSheets = [document.styleSheets[0]];
throw new TypeError(
`Failed to set the 'adoptedStyleSheets' property on ${locationType}: Can't adopt non-constructed stylesheets`,
);
}

self.sheets = sheets;
const oldUniqueSheets = $uniqueSheets.get(self)!;
const uniqueSheets = unique(sheets);

// Style sheets that existed in the old sheet list but was excluded in the
// new one.
const removedSheets = diff(oldUniqueSheets, uniqueSheets);

removedSheets.forEach((sheet) => {
// Type Note: any removed sheet is already initialized, so there cannot be
// missing adopter for this location.
removeNode(getAdopterByLocation(sheet, self)!);
removeAdopterLocation(sheet, self);
});

$uniqueSheets.set(self, uniqueSheets);

if (self.isConnected() && uniqueSheets.length > 0) {
adopt(self);
}
},
};

export default Location;
42 changes: 42 additions & 0 deletions src/safari.ts
@@ -0,0 +1,42 @@
import {bootstrapper} from './shared';

export const hasBrokenRules = (function () {
const style = bootstrapper.createElement('style');
style.textContent = '.x{content:"y"}';
bootstrapper.body.appendChild(style);

return (style.sheet!.cssRules[0] as CSSStyleRule).style.content !== '"y"';
})();

const brokenRulePatterns = [/content:\s*["']/gm];

/**
* Adds a special symbol "%" to the broken rule that forces the internal Safari
* CSS property string converter to add quotes around the value. This function
* should be only used for the internal basic stylesheet hidden in the
* bootstrapper because it pollutes the user content with the placeholder
* symbols. Use the `getCssText` function to remove the placeholder from the
* CSS string.
*
* @param content
*/
export function fixBrokenRules(content: string): string {
return brokenRulePatterns.reduce(
(acc, pattern) => acc.replace(pattern, '$&%%%'),
content,
);
}

const placeholderPatterns = [/(content:\s*["'])%%%/gm];

/**
* Removes the placeholder added by `fixBrokenRules` function from the received
* rule string.
*/
export const getCssText = hasBrokenRules
? (rule: CSSRule) =>
placeholderPatterns.reduce(
(acc, pattern) => acc.replace(pattern, '$1'),
rule.cssText,
)
: (rule: CSSRule) => rule.cssText;
5 changes: 4 additions & 1 deletion src/shared.ts
Expand Up @@ -9,7 +9,7 @@ export const hasShadyCss = 'ShadyCSS' in window && !ShadyCSS.nativeShadow;
* The in-memory HTMLDocument that is necessary to get the internal
* CSSStyleSheet of a basic `<style>` element.
*/
export const bootstrapper = document.implementation.createHTMLDocument('boot');
export const bootstrapper = document.implementation.createHTMLDocument('');

/**
* Since ShadowRoots with the closed mode are not available via
Expand All @@ -21,3 +21,6 @@ export const closedShadowRootRegistry = new WeakMap<Element, ShadowRoot>();
// Workaround for IE that does not support the DOMException constructor
export const _DOMException =
typeof DOMException === 'object' ? Error : DOMException;

export const defineProperty = Object.defineProperty;
export const forEach = Array.prototype.forEach;
8 changes: 3 additions & 5 deletions src/utils.ts
@@ -1,7 +1,5 @@
import {closedShadowRootRegistry} from './shared';

export const defineProperty = Object.defineProperty;
export const forEach = Array.prototype.forEach;
import {getCssText} from './safari';
import {closedShadowRootRegistry, forEach} from './shared';

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

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

export function insertAllRules(from: CSSStyleSheet, to: CSSStyleSheet): void {
forEach.call(from.cssRules, (rule, i) => {
to.insertRule(rule.cssText, i);
to.insertRule(getCssText(rule), i);
});
}

Expand Down

0 comments on commit 7e7f05b

Please sign in to comment.