Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add automatic global style injection #48

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,63 @@ FullName.propTypes = {
register(FullName, 'full-name');
```

### API

### register(Component, tagName, props?, options?)

Returns a Custom Element.

#### Component

Type: `object`

#### tagName

Type: `string`

#### props?

Type: `Array<string>`

#### options

Type: `boolean | object`
Default: `false`

#### options.shadow

Type: `boolean`
Default: `false`

Attaches a Shadow DOM instance to your Custom Element. Remember that global styles won't affect the Custom Element unless you inject (see `options.injectGlobalStyles`) or somehow import them from the component itself.

#### options.injectGlobalStyles
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can slim down the user-facing API surface area. My gut tells me that we can combine the solutions:

  • options.injectGlobalStyles = true is the same as injectGlobalStyles.selector = '*'
  • We could eliminate injectGlobalStyles.target by looping over document.styleSheets directly. It includes both inline style tags and sheets added via a link element. Each sheet has a pointer to the node it was created by and we can match against that.

I'd love to start simple and remove filter too. Whilst there are cases in theory where a query selector may not suffice, I can't come up with realistic real world examples where the selector approach falls short. Checking popular CSS-in-JS libs, they all mark their own stylesheets with a special attribute. For CSS-modules the user usually has tight control over naming the assets and we can match on that.

Since there all sheets are inherently global, unless bound to a shadow root, we can drop the Global part of the variable name, imo.


Type: `boolean | object`
Default: `false`

Injects current and future style and link elements related to styling into your Custom Element. Only works if `shadow: true`.

##### options.injectGlobalStyles.target

Type: `DOMNode`
Default: `document.head`

Where to look for styles to get added. Most 3rd party tooling loads style tags into document.head.

##### options.injectGlobalStyles.selector

Type: `(querySelector) string`
Default: `'style, link[rel="stylesheet"], link[rel="preload"][as="style"]'`

What types of elements to inject to your Custom Element's Shadow DOM.

##### options.injectGlobalStyles.filter

Type: `Function<boolean>`
Default: `undefined`

Allows you to filter what elements get added to the Custom Element more granularly. Gets executed on `addedNodes` by the MutationObserver, as well as the initial styles present on the page.

## Related

Expand Down
80 changes: 80 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ export default function register(Component, tagName, propNames, options) {
inst._vdomComponent = Component;
inst._root =
options && options.shadow ? inst.attachShadow({ mode: 'open' }) : inst;

if (options && options.shadow && options.injectGlobalStyles) {
const defaults = {
target: document.head,
selector:
'style, link[rel="stylesheet"], link[rel="preload"][as="style"]',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strings tend to not compress well. If we switch to document.styleSheets and match our selectors on sheet.ownerNode, we should be able to get rid of it.

filter: undefined,
observeOptions: { childList: true, subtree: true },
};

this.styleObserver = beginInjectingGlobalStyles(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super tiny nit: The prefix begin* somewhat implies that there is an end* function somewhere. Seeing that this function is only used once we can inline it and save about 14B.

inst.shadowRoot,
options.injectGlobalStyles === true
? defaults
: /* eslint-disable indent */
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do follow through with the stylesheet approach mentioned earlier we can remove this block.

...defaults,
...options.injectGlobalStyles,
/* eslint-enable indent */
}
);
}

return inst;
}
PreactElement.prototype = Object.create(HTMLElement.prototype);
Expand Down Expand Up @@ -62,6 +85,59 @@ function ContextProvider(props) {
return cloneElement(children, rest);
}

function cloneElementsToShadowRoot(shadowRoot, elements) {
elements.forEach((el) => shadowRoot.appendChild(el.cloneNode(true)));
}

function getAllStyles(target, selector, filter) {
const elements = Array.prototype.slice.call(
target.querySelectorAll(selector)
);

return filter ? elements.filter(filter) : elements;
}

const beginInjectingGlobalStyles = (shadowRootRef, injectGlobalStyles) => {
cloneElementsToShadowRoot(
shadowRootRef,
getAllStyles(
injectGlobalStyles.target,
injectGlobalStyles.selector,
injectGlobalStyles.filter
)
);

return observeStyleChanges(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inlining this function here saves about 23B . It allows us to get rid of the callback function wrapping and additional function call as we can just call cloneElementsToShadowRoot.

(elements) => {
cloneElementsToShadowRoot(shadowRootRef, elements);
},
injectGlobalStyles.target,
injectGlobalStyles.selector,
injectGlobalStyles.filter,
injectGlobalStyles.observeOptions
);
};

function observeStyleChanges(
callback,
target,
selector,
filter,
observeOptions
) {
return new MutationObserver((mutations, observer) => {
mutations.forEach((mutation) => {
const matchedElements = Array.prototype.slice
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth checking: If we inline cloneElementsToShadowRoot we can loop directly over the NodeList, thereby skipping the additional iteration over elements and saving an array allocation in the process.

.call(mutation.addedNodes)
.filter((node) => node.matches && node.matches(selector));

if (matchedElements.length > 0) {
callback(filter ? matchedElements.filter(filter) : matchedElements);
}
});
}).observe(target, observeOptions);
}

function connectedCallback() {
// Obtain a reference to the previous context by pinging the nearest
// higher up node that was rendered with Preact. If one Preact component
Expand Down Expand Up @@ -99,6 +175,10 @@ function attributeChangedCallback(name, oldValue, newValue) {

function disconnectedCallback() {
render((this._vdom = null), this._root);

if (this.styleObserver) {
this.styleObserver.disconnect();
}
}

/**
Expand Down
155 changes: 155 additions & 0 deletions src/index.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,159 @@ describe('web components', () => {
});
assert.equal(getShadowHTML(), '<p>Active theme: sunny</p>');
});

function Thing() {
return <span>Hello world!</span>;
}

const styleElem = document.createElement('style');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love the tests 🙌

styleElem.innerHTML = 'span { color: red; }';
document.head.appendChild(styleElem);

registerElement(Thing, 'x-thing', undefined, {
shadow: true,
injectGlobalStyles: true,
});

describe('Global style injections from document.head', () => {
it('injects style-tags', () => {
const el = document.createElement('x-thing');

root.appendChild(el);

assert.equal(
document.querySelector('x-thing').shadowRoot.innerHTML,
'<span>Hello world!</span><style>span { color: red; }</style>'
);

const computedStyle = window
.getComputedStyle(
document.querySelector('x-thing').shadowRoot.querySelector('span'),
null
)
.getPropertyValue('color');

assert.equal(computedStyle, 'rgb(255, 0, 0)');

// assert.equal(
// root.innerHTML,
// '<x-thing><span>Hello world!</span></x-thing>'
// );
styleElem.parentElement.removeChild(styleElem);
});

it('injects link-tags of rel="stylesheet"', async () => {
const blob = new Blob([], { type: 'text/css' });

let linkElementLoaded;

let deferred;
let promise = new Promise((resolve) => {
deferred = resolve;
});

const linkElem = document.createElement('link');
linkElem.rel = 'stylesheet';
linkElem.href = window.URL.createObjectURL(blob);
linkElem.onload = () => {
linkElementLoaded = true;
deferred();
};

document.head.appendChild(linkElem);

const el = document.createElement('x-thing');

root.appendChild(el);

assert.equal(
document.querySelector('x-thing').shadowRoot.innerHTML,
`<span>Hello world!</span><link rel="stylesheet" href="${linkElem.href}">`
);

await promise;
assert.isTrue(linkElementLoaded);

linkElem.parentElement.removeChild(linkElem);
});

it('injects link-tags of rel="preload"', async () => {
const blob = new Blob([], { type: 'text/css' });

let linkElementLoaded;

let deferred;
let promise = new Promise((resolve) => {
deferred = resolve;
});

const linkElem = document.createElement('link');
linkElem.rel = 'preload';
linkElem.as = 'style';
linkElem.href = window.URL.createObjectURL(blob);
linkElem.onload = () => {
linkElementLoaded = true;
deferred();
};

document.head.appendChild(linkElem);

const el = document.createElement('x-thing');

root.appendChild(el);

assert.equal(
document.querySelector('x-thing').shadowRoot.innerHTML,
`<span>Hello world!</span><link rel="preload" as="style" href="${linkElem.href}">`
);

await promise;
assert.isTrue(linkElementLoaded);

linkElem.parentElement.removeChild(linkElem);
});

it('injects style-tags that is added after custom element is loaded', async () => {
const el = document.createElement('x-thing');

root.appendChild(el);

assert.equal(
document.querySelector('x-thing').shadowRoot.innerHTML,
'<span>Hello world!</span>'
);

const computedStyle = window
.getComputedStyle(
document.querySelector('x-thing').shadowRoot.querySelector('span'),
null
)
.getPropertyValue('color');

assert.equal(computedStyle, 'rgb(0, 0, 0)');

const styleElem = document.createElement('style');
styleElem.innerHTML = 'span { color: red; }';

// wait for the element to be added
await new Promise((resolve) => {
new MutationObserver((mutations, observer) => {
resolve();
observer.disconnect();
}).observe(document.querySelector('x-thing').shadowRoot, {
childList: true,
subtree: true,
});

document.head.appendChild(styleElem);
});

assert.equal(
document.querySelector('x-thing').shadowRoot.innerHTML,
'<span>Hello world!</span><style>span { color: red; }</style>'
);

styleElem.parentElement.removeChild(styleElem);
});
});
});