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

Contributing? (+ DOM style tag management) #2

Open
baublet opened this issue Jun 16, 2023 · 0 comments
Open

Contributing? (+ DOM style tag management) #2

baublet opened this issue Jun 16, 2023 · 0 comments

Comments

@baublet
Copy link

baublet commented Jun 16, 2023

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>
  );
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant