Inject per-component style tags dynamically with Rollup and scss
Asked Answered
F

2

13

I am building a React component library whose source code takes this general structure:

- src
  - common.scss (contains things like re-usable css variables)
  - components
    - button
      - index.js
      - button.scss
    - dialog
      - index.js
      - dialog.scss

My components are responsible for importing their own per-component styles (using scss), so for example, button/index.js has this line:

import "./button.scss";

So far, in my application I have been consuming my library directly from source like this:

// app.js
import "mylib/src/common.scss" // load global styles
import Button from 'mylib/src/components/button/index.js'
import Dialog from 'mylib/src/components/dialog/index.js'

// ...application code...

When my application uses webpack, along with style-loader, the per-component css is appended as style tags in head dynamically when the component is first used. This is a nice performance win since the per-component styling doesn't need to be parsed by the browser until it's actually needed.

Now though, I want to distribute my library using Rollup, so application consumers would do something like this:

import { Button, Dialog } from 'mylib'
import "mylib/common.css" // load global styles

// ...application code...

When I use rollup-plugin-scss it just bundles the per-component styles all together, not dynamically adding them as before.

Is there a technique I can incorporate into my Rollup build so that my per-component styles are dynamically added as style tags in the head tag as they are used?

Fissiparous answered 3/7, 2019 at 17:5 Comment(0)
H
4

One approach would be to load your SCSS as a CSS stylesheet string the output:false option in the plugin (see the Options section of the docs), then in your component use react-helmet to inject the stylesheet at runtime:

import componentCss from './myComponent.scss'; // plain CSS from rollup plugin
import Helmet from 'react-helmet';

function MyComponent(props) {
    return (
        <>
             <ActualComponentStuff {...props} />
             <Helmet>
                 <style>{ componentCss }</style>
             </Helmet>
        </>
    );
}

This basic idea should work, but I wouldn't use this implementation for 2 reasons:

  1. Rendering two instances of MyComponent will cause the stylesheet to be injected twice, causing lots of unnecessary DOM injection
  2. It's a lot of boilerplate to wrap around every component (even if we factor out our Helmet instance into a nice wrapper)

Therefore you're better off using a custom hook, and passing in a uniqueId that allows your hook to de-duplicate stylesheets. Something like this:

// -------------- myComponent.js -------------------
import componentCss from "./myComponent.scss"; // plain CSS from rollup plugin
import useCss from "./useCss";

function MyComponent(props) {
    useCss(componentCss, "my-component");
    return (
        <ActualComponentStuff {...props} />
    );
}

// ------------------ useCss.js ------------------
import { useEffect } from "react";

const cssInstances = {};

function addCssToDocument(css) {
    const cssElement = document.createElement("style");
    cssElement.setAttribute("type", "text/css");

    //normally this would be dangerous, but it's OK for
    // a style element because there's no execution!
    cssElement.innerHTML = css;
    document.head.appendChild(cssElement);
    return cssElement;
}

function registerInstance(uniqueId, instanceSymbol, css) {
    if (cssInstances[uniqueId]) {
        cssInstances[uniqueId].symbols.push(instanceSymbol);
    } else {
        const cssElement = addCssToDocument(css);
        cssInstances[uniqueId] = {
            symbols: [instanceSymbol],
            cssElement
        };
    }
}

function deregisterInstance(uniqueId, instanceSymbol) {
    const instances = cssInstances[uniqueId];
    if (instances) {
        //removes this instance by symbol
        instances.symbols = instances.symbols.filter(symbol => symbol !== instanceSymbol);

        if (instances.symbols.length === 0) {
            document.head.removeChild(instances.cssElement);
            instances.cssElement = undefined;
        }
    } else {
        console.error(`useCss() failure - tried to deregister and instance of ${uniqueId} but none existed!`);
    }
}

export default function useCss(css, uniqueId) {
    return useEffect(() => {
        // each instance of our component gets a unique symbol
        // to track its creation and removal
        const instanceSymbol = Symbol();

        registerInstance(uniqueId, instanceSymbol, css);

        return () => deregisterInstance(uniqueId, instanceSymbol);
    }, [css, uniqueId]);
}

This should work much better - the hook will use effectively a app-wide global to track instances of your component, add the CSS dynamically when it gets first rendered, and remove it when the last component dies. All you need to do is add that single hook as an extra line in each of your components (assuming you're using only function React components - if you're using classes you'll need to wrap them, maybe using a HOC or similar).

It should work fine, but it also has some drawbacks:

  1. We're effectively using global state (cssInstances, which is kind of unavoidable if we're trying to prevent clashes from different parts of the React tree. I was hoping there would be a way to do this by storing state in the DOM itself (this makes sense given that our de-duplication stage is the DOM), but I couldn't find one. Another way would be to use the React Context API instead of a module-level global. This would work fine too and be easier to test; shouldn't be hard to rewrite the hook with useContext() if that's what you want, but then the integrating app would need to set up a Context provider at the root level and that creates more work for integrators, more documentation, etc.

    1. The entire approach of dynamically adding/removing style tags means that stylesheet order is not only non-deterministic (which it already is when doing style loading with bundlers like Rollup), but also can change during runtime, so if you have stylesheets that conflict, the behaviour might change during runtime. Your stylesheets should ideally be too tightly scoped to conflict anyway, but I have seen this go wrong with a Material UI app where multiple instances of MUI were loaded - it's real hard to debug!

The dominant approach at the moment seems to be JSS - using something like nano-renderer to turn JS objects into CSS and then injecting them. There doesn't seem to be anything I can find that does this for textual CSS.

Hope this is a useful answer. I've tested the hook itself and it works fine, but I'm not totally confident with Rollup so I'm relying on the plugin documentation here. Either way, good luck with the project!

Hastate answered 23/1, 2020 at 15:58 Comment(1)
@o-t-w I've moved on and don't have time to try this answer out...can you please comment if this answer should be accepted?Fissiparous
I
0

Use rollup-plugin-styles instead. It will inject styles to head tag on a per component basis if you use mode: inject which is the default. See doc

Intolerant answered 10/5, 2023 at 4:40 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.