-
Notifications
You must be signed in to change notification settings - Fork 53
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"]', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Strings tend to not compress well. If we switch to |
||
filter: undefined, | ||
observeOptions: { childList: true, subtree: true }, | ||
}; | ||
|
||
this.styleObserver = beginInjectingGlobalStyles( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Super tiny nit: The prefix |
||
inst.shadowRoot, | ||
options.injectGlobalStyles === true | ||
? defaults | ||
: /* eslint-disable indent */ | ||
{ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
@@ -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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
(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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth checking: If we inline |
||
.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 | ||
|
@@ -99,6 +175,10 @@ function attributeChangedCallback(name, oldValue, newValue) { | |
|
||
function disconnectedCallback() { | ||
render((this._vdom = null), this._root); | ||
|
||
if (this.styleObserver) { | ||
this.styleObserver.disconnect(); | ||
} | ||
} | ||
|
||
/** | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
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 asinjectGlobalStyles.selector = '*'
injectGlobalStyles.target
by looping overdocument.styleSheets
directly. It includes both inline style tags and sheets added via alink
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.