Contributing? (+ DOM style tag management)
When you have time, can you setup a contributing guide? If you're amenable, I'd like to send in a PR for discrete style tags for components. But I don't want to sacrifice the performance in terms of payload size and speed you're shooting for.
I ask because I like this method. I like it a lot. The downside I can see, though, when trying to utilize this with any sort of long-lived client session, is that styles are smashed together into a single style head. Later updates add a new style head. With long-lived clients and frequent CSS mutations, you can find yourself with hundreds, sometimes thousands of DOM nodes whose CSS contents are later overridden (and overridden n-1 times, where n is the number of unique props you send to x-style.
(Another issue is that there is the potential for a flash for unstyled content. But that doesn't seem to happen for me on WebKit, but YMMV.)
I have a patch that adds a new configuration property (and should leave the rest alone) that enables discrete style elements in the head for each DOM element. Is this a good idea? I don't know, TBH. But it makes it easier for this library to manage the lifecycle of individual DOM nodes and which styles we need to keep in the DOM.
In lieu of contributing directly, I mess with a lot of side projects that are UI-only SPAs (all data is stored in the browser). So I took x-style and made it do what I needed in long-lived browser client world. Take these ideas/this code directly if you want! It's gratis. Or throw it away 😄
Caveat emptor: I wrote this for me, and without robust testing, I'm hesitant to add it to this straight to the repo. In addition, I'm not sure if my cleanup method or use of browser Set objects is a good idea for performance. I'm also not super sure if my test is an accurate reflection of real-world performance issues. I might have just spent an hour on this for no real reason 🤷 (It was fun, tho!)
I have not done any golfing with this version. A run through a minifier gets it to about 1.67kb ungzipped, which is 896 bytes gzipped. (Compared to before: 895/564, so about a 332 byte increase (~60%). This is fine for me, but might be a bit too much for this library (unless the minifier I used ain't so hot).
Updated version with DOM style tag management
(() => {
// Aliases to aid in minification:
var doc = document;
var querySelectorAll = (e, s) => e.querySelectorAll(s);
// Plugins, list of functions that take the css and return a new css
var pluginsPre = [];
var pluginsPost = [];
/**
* x-style
* @param {string} attr - HTML attribute that contains the css, usually "css"
* @param {boolean} [noMutate] - Don't mutate the DOM by adding attributes
* @param {boolean} [discreteStyles] - Saves css in a discrete style element per DOM element
*/
var xstyle = (attr, noMutate, discreteStyles) => {
var styleEl;
var style = [];
var selectorCount = 0;
var attributeForSelector = `${attr}-match`;
var elementIdAttribute = `${attr}-id`;
var elementIdSeed = 0;
var processedCss = new Map(); // Map<cssString, renderSeed>
var allProcessedCss = new Map(); // Map<cssString, renderSeed>
var elementProcessedCss = new Map(); // Map<elementId, cssString>
var elementIdNodes = new Map(); // Map<elementId, node>
var elementIdStyleNode = new Map(); // Map<elementId, styleNode>
var idsToUpdate = new Set(); // DOM elements that have new CSS to apply
var styleElementsToDelete = new Set(); // We store these in a separate buffer to minimize DOM operations
var removingDomElements = false;
if (discreteStyles) {
// Every 10 seconds, clean up our processed CSS map to free up memory
setInterval(() => processedCss.clear(), 10000);
}
var removeStaleStyleElements = () => {
if (removingDomElements) return;
removingDomElements = true;
requestAnimationFrame(() => {
const elements = Array.from(styleElementsToDelete.values());
styleElementsToDelete.clear();
removingDomElements = false;
for (const el of elements) {
el.remove();
}
});
};
var observer = new MutationObserver((mutations) => {
for (var mutation of mutations) {
if (mutation.type === "attributes") {
processEl(mutation.target);
} else if (mutation.type === "childList") {
for (var el of mutation.addedNodes) {
if (!(el instanceof HTMLElement)) continue;
if (el.hasAttribute(attr)) {
processEl(el);
}
[...querySelectorAll(el, `[${attr}]`)].forEach(processEl);
}
if (discreteStyles) {
for (var el of mutation.removedNodes) {
if (!(el instanceof HTMLElement)) continue;
var id = el[elementIdAttribute];
if (!id) continue;
elementProcessedCss.delete(id);
elementIdNodes.delete(id);
idsToUpdate.delete(id);
const styleNode = elementIdStyleNode.get(id);
if (styleNode) {
elementIdStyleNode.delete(id);
styleElementsToDelete.add(styleNode);
}
}
}
}
}
emitStyle();
});
var emitStyle = () => {
if (discreteStyles) {
const values = Array.from(idsToUpdate.values());
idsToUpdate.clear();
for (const id of values) {
var css = elementProcessedCss.get(id);
if (!css) {
continue;
}
var styleNode = elementIdStyleNode.get(id);
if (!styleNode) {
var styleEl = doc.createElement("style");
styleEl.innerHTML = elementProcessedCss.get(id);
doc.head.appendChild(styleEl);
styleNode = elementIdStyleNode.set(id, styleEl);
continue;
}
styleNode.innerHTML = css;
removeStaleStyleElements();
}
} else if (style.length) {
styleEl = doc.createElement("style");
styleEl.innerHTML = style;
doc.head.appendChild(styleEl);
styleEl = null;
style = "";
}
};
var setAttribute = (el, rawCss) => {
var selectorAttr = `${attributeForSelector}-${processedCss.get(rawCss)}`;
var prop = "__" + attributeForSelector;
if (el[prop]) {
el.removeAttribute(el[prop]);
}
el.setAttribute(selectorAttr, "");
el[prop] = selectorAttr;
return selectorAttr;
};
/**
* Process an element.
* Extract the css from the attribute and add it to the style element.
* If the css has already been processed, either add the attributeForSelector
* or do nothing.
* The style element is added to the head on the next microtask.
* @param {HTMLElement} el
*/
var processEl = (el) => {
var rawCss = el.getAttribute(attr);
el[elementIdAttribute] = el[elementIdAttribute] || ++elementIdSeed;
if (discreteStyles) {
el.setAttribute(elementIdAttribute, el[elementIdAttribute]);
}
var css;
if (!rawCss || processedCss.has(rawCss)) {
if (!noMutate) {
setAttribute(el, rawCss);
}
return;
}
processedCss.set(rawCss, ++selectorCount);
allProcessedCss.set(rawCss, selectorCount);
if (noMutate) {
css = `[${attr}="${CSS.escape(rawCss)}"]`;
} else {
css = `[${setAttribute(el, rawCss)}]`;
}
pluginsPre.forEach((plugin) => (rawCss = plugin(rawCss)));
css += ` { ${rawCss} }`;
pluginsPost.forEach((plugin) => (css = plugin(css)));
if (!discreteStyles) {
style += css;
} else {
elementProcessedCss.set(el[elementIdAttribute], css);
elementIdNodes.set(el[elementIdAttribute], el);
idsToUpdate.add(el[elementIdAttribute]);
}
};
querySelectorAll(doc, `[${attr}]`).forEach(processEl);
emitStyle();
observer.observe(doc.documentElement, {
attributes: true,
attributeFilter: [attr],
childList: true,
subtree: true,
});
};
xstyle.pre = pluginsPre;
xstyle.post = pluginsPost;
xstyle.version = "0.0.3";
window.xstyle = xstyle;
})();
gzipped version:
(()=>{var e=document,t=(e,t)=>e.querySelectorAll(t),r=[],a=[],s=(s,n,o)=>{var i=[],l=0,c=`${s}-match`,d=`${s}-id`,f=0,v=new Map,u=new Map,m=new Map,p=new Map,h=new Map,b=new Set,M=new Set,g=!1;o&&setInterval((()=>v.clear()),1e4);var w=new MutationObserver((e=>{for(var r of e)if("attributes"===r.type)E(r.target);else if("childList"===r.type){for(var a of r.addedNodes)a instanceof HTMLElement&&(a.hasAttribute(s)&&E(a),[...t(a,`[${s}]`)].forEach(E));if(o)for(var a of r.removedNodes){if(!(a instanceof HTMLElement))continue;var n=a[d];if(!n)continue;m.delete(n),p.delete(n),b.delete(n);const e=h.get(n);e&&(h.delete(n),M.add(e))}}$()})),$=()=>{if(o){const s=Array.from(b.values());b.clear();for(const n of s){var t=m.get(n);if(t){var r=h.get(n);if(r)r.innerHTML=t,g||(console.log({processedCssSize:v.size}),console.log({allProcessedCssSize:u.size}),g=!0,requestAnimationFrame((()=>{const e=Array.from(M.values());M.clear(),g=!1;for(const t of e)t.remove()})));else{var a=e.createElement("style");a.innerHTML=m.get(n),e.head.appendChild(a),r=h.set(n,a)}}}}else i.length&&((a=e.createElement("style")).innerHTML=i,e.head.appendChild(a),a=null,i="")},A=(e,t)=>{var r=`${c}-${v.get(t)}`,a="__"+c;return e[a]&&e.removeAttribute(e[a]),e.setAttribute(r,""),e[a]=r,r},E=e=>{var t,c=e.getAttribute(s);e[d]=e[d]||++f,o&&e.setAttribute(d,e[d]),c&&!v.has(c)?(v.set(c,++l),u.set(c,l),t=n?`[${s}="${CSS.escape(c)}"]`:`[${A(e,c)}]`,r.forEach((e=>c=e(c))),t+=` { ${c} }`,a.forEach((e=>t=e(t))),o?(m.set(e[d],t),p.set(e[d],e),b.add(e[d])):i+=t):n||A(e,c)};t(e,`[${s}]`).forEach(E),$(),w.observe(e.documentElement,{attributes:!0,attributeFilter:[s],childList:!0,subtree:!0})};s.pre=r,s.post=a,s.version="0.0.3",window.xstyle=s})();
Here's the (React) test bed I used to play around with performance when adding, mutating, and removing lots of dom nodes
Note: the nature of this test means that you will almost certainly crash your browser eventually. My goal was to have x-style not be the cause.
import React from "react";
const getRandomColor = () => Math.floor(Math.random() * 16777215).toString(16);
const randomNumberBetween = (min: number, max: number) =>
Math.floor(Math.random() * (max - min + 1) + min);
export function Chaos() {
const [elCount, setElCount] = React.useState(() =>
randomNumberBetween(10, 50)
);
React.useEffect(() => {
const interval = setInterval(() => {
setElCount(randomNumberBetween(10, 50));
}, 500);
return () => clearInterval(interval);
}, []);
return (
<div>
{Array.from({ length: elCount })
.fill(0)
.map((_, i) => (
<ChaosParticle key={i} />
))}
</div>
);
}
function ChaosParticle() {
const [color, setColor] = React.useState(getRandomColor);
React.useEffect(() => {
const interval = setInterval(() => {
setColor(getRandomColor);
}, randomNumberBetween(500, 2000));
return () => clearInterval(interval);
}, []);
return (
<div
x-style={`
background-color: #${color};
padding: 5px
`}
>
I am a random color (#{color})
</div>
);
}
I quite like this approach as well.
But yes, I'm also concerned about the idea of appending all rules to a single stylesheet on-the-fly - and I'm not really convinced this change solves the problem at the right level?
Because of code like this:
<div
x-style={`
background-color: #${color};
padding: 5px
`}
>
This is a small example, but it means you're rebuilding unrelated styles like padding for no reason.
My thinking was more along the lines of exposing the internal function that get the selector? Like many libraries do it, give us a function that generates a class-name:
function ChaosParticle() {
const [color, setColor] = React.useState(getRandomColor);
React.useEffect(() => {
const interval = setInterval(() => {
setColor(getRandomColor);
}, randomNumberBetween(500, 2000));
return () => clearInterval(interval);
}, []);
const color = xstyle.css`
background-color: #${color};
`;
return (
<div
className={color}
x-style={`
padding: 5px
`}
>
I am a random color (#{color})
</div>
);
}
So you would separate out dynamic styles and turn those into classes instead - this gives you control of the scope of updates.
The down side is you get some fragmentation here - dynamic styles living separate lives from the main style definition.
Another approach that might be better in that regard, is to leverage custom properties:
function ChaosParticle() {
const [color, setColor] = React.useState(getRandomColor);
React.useEffect(() => {
const interval = setInterval(() => {
setColor(getRandomColor);
}, randomNumberBetween(500, 2000));
return () => clearInterval(interval);
}, []);
return (
<div
className={color}
x-color={`#${color}`}
x-style={`
padding: 5px
background-color: var(--x-color)
`}
>
I am a random color (#{color})
</div>
);
}
That is, an attribute like x-color would get picked up and defined as an --x-color custom property by convention.
The framework would apply individual custom properties directly to the style property of the element, e.g. element.style.setProperty('--x-color', value) - this makes the individual property updates fully atomic, e.g. only updating precisely one custom property for a single, dynamic style change.
You would just use standard var(--x-color) in your CSS, which means the x-style itself would never need to be updated.
In other words, this builds on the idea of not updating any CSS dynamically, which is quite attractive from both a performance and simplicity perspective, I think?
Where you approach tries to improve support for updating CSS, this approach might instead reinforce the idea of keeping the CSS itself static, and handling updates with custom properties - which might arguable be the way CSS intended it. 😉
Thoughts?
@samwillis is this library abandoned or are you still interested? 🙂
Hey @mindplay-dk
is this library abandoned or are you still interested? 🙂
Not abandoned, would describe as a slow burn. It's an experiment to look at these ideas, with no rush to make anything wider of it (I'm quite busy as it is 😬)
I'd like to think this repo could inspire new ideas through forks, and conversations.
x-style isn't really intended for dynamic styles, as you identify it would be bad for performance with a lot of duplication.
If you are living in the world of react I think there are other design options, such as a makeClass function that takes a style def generates a class name, and appends the style if it doesn't exist. It would be quite possible to preprocess that in a build step. You can then just inline the function in a className prop.
If you are living in the world of react I think there are other design options, such as a
makeClassfunction that takes a style def generates a class name, and appends the style if it doesn't exist.
I think this is what I was trying to explain with my first suggestion? You basically have this function in the guts of x-style - it uses an attribute-based selector, so the only difference would be (exposing it and) using a class-based selector instead, I think?
It (my first suggestion) looks and feels a bit disconnected to me though. I mean, I wouldn't want to make classes for everything, just the parts that can change. One of the main value propositions of CSS-in-JS from my perspective is "locality of behavior" - defining the styles where they're needed, avoiding the burden of having to make up a selector name of every element like BEM. I would still want inline CSS for the stuff that doesn't change.
With my second suggestion, you get the names and coupling only for the dynamic parts, where it's needed - the inline CSS is complete and readable on it's own, and anything dynamic happens via custom properties.
I think I like that direction much better than the "names for everything" approach that the makeClass-style libs take. 🙂