DEV Community

Cover image for Chrome does some weird stuff if you toggle a stylesheet on and off
Matteo Mazzarolo
Matteo Mazzarolo

Posted on • Originally published at mmazzarolo.com on

Chrome does some weird stuff if you toggle a stylesheet on and off

Last week I spent more time than I expected debugging a Chrome-specific issue: when toggling the visibility of a view of one of our web apps, the view was flashing with unstyled content before becoming visible.

The flow that was causing the issue is the following:

  1. At some point, during the user session, we hide a view and disable the stylesheet (<link href="stylesheet">) associated with it (it doesn’t really matter why we’re disabling it).
  2. Later on, when needed, we re-enable the stylesheet.
  3. Immediately after re-enabling the stylesheet, we show the view (e.g., by changing its visibility from display: none to display: block).

To toggle the stylesheet on and off, we are using the disabled property of the StyleSheet interface.

At this stage, the stylesheet had already been loaded once (before step 1), so expected that re-enabling it (in step 2) would have applied its styles immediately.

Unfortunately, that’s not what happens in Chrome.

When you re-enable a stylesheet, Chrome (v94.0.4606.71) sometimes tries to fetch it:

devtools

The fetch results in loading the stylesheet from the cache. Still, the browser runs this flow asynchronously, causing a quick flash on unstyled content until the stylesheet is fully loaded:

You can reproduce this issue with the following HTML code:

<html>
  <body>
    <link
      rel="stylesheet"
      href="https://unpkg.com/purecss@2.0.6/build/pure-min.css"
      id="purecss"
    />
    <button id="btn">Toggle CSS</button>
    <h1 id="text">Hello world</h1>
  </body>
  <script>
    const purecss = document.querySelector("#purecss");
    const btn = document.querySelector("#btn");
    const text = document.querySelector("#text");
    btn.addEventListener("click", () => {
      if (purecss.disabled) {
        purecss.disabled = false;
        // At this point, the stylesheet on the page should reflect the state
        // set in the previous line — but in Chrome, sometimes it wont.
        // To reproduce the issue consistently, tick "Disable cache" in
        // the network panel.
        text.style.display = "block";
        debugger;
      } else {
        text.style.display = "none";
        purecss.disabled = true;
      }
    });
  </script>
</html>
Enter fullscreen mode Exit fullscreen mode

If you run your code on Chrome, sometimes you’ll notice that the breakpoint will stop in a state where the stylesheet is not fully loaded.

For example, in the screenshot below, Firefox and Chrome are paused on the same breakpoint. As you can see, in Chrome there’s a pending network request for the stylesheet, and the style has not been applied yet (see the text font style).

firefox vs chrome

I don’t know if this issue is a Chrome bug or not, but I have an idea on why it might be happening.

When you add a disabled stylesheet to the page (with <link href="stylesheet" disabled>) and then enable it a runtime dynamically, browsers load the stylesheet on-demand.

My guess is that sometimes Chrome tries to load the stylesheet on-demand even if it has already been loaded before.

To solve this issue, we must wait for the stylesheet to be fully loaded before showing the view.

Unfortunately, toggling a stylesheet on and off doesn’t trigger its onload event.

As a (less elegant) alternative, we can use the document.styleSheets API. This API is a read-only property that returns the list of stylesheets explicitly linked into or embedded in the document. In Chrome, we can expect to find our re-enabled stylesheet in this list only after it has been fully loaded.

So, we can update our flow this way:

  1. At some point, during the user session, we hide a view and disable the stylesheet (<link href="stylesheet">) associated with it (it doesn’t really matter why we’re disabling it).
  2. Later on, when needed, we re-enable the stylesheet.
  3. Wait for the stylesheet to be available in document.styleSheet (using a quick-looped setInterval).
  4. Immediately after re-enabling the stylesheet, we show the view (e.g., by changing its visibility from display: none to display: block).

Example of the solution:

<html>
  <body>
    <link
      rel="stylesheet"
      href="https://unpkg.com/purecss@2.0.6/build/pure-min.css"
      id="purecss"
    />
    <button id="btn">Toggle CSS</button>
    <h1 id="text">Hello world</h1>
  </body>
  <script>
    const purecss = document.querySelector("#purecss");
    const btn = document.querySelector("#btn");
    const text = document.querySelector("#text");

    function isStylesheetLoaded() {
      return [...document.styleSheets].find(
        (stylesheet) => stylesheet.href === purecss.href
      );
    }

    // Wait for the stylesheet to be loaded
    async function waitForStylesheet() {
      if (isStylesheetLoaded()) {
        // In non-Chromium browsers the stylesheet will immediately be loaded
        return;
      }
      const intervalMs = 20;
      return new Promise((resolve) => {
        const intervalId = setInterval(() => {
          if (isStylesheetLoaded()) {
            clearInterval(intervalId);
            return resolve();
          }
          // Handle max retries/timeout if needed.
        });
      });
    }

    btn.addEventListener("click", async () => {
      if (purecss.disabled) {
        purecss.disabled = false;
        await waitForStylesheet(); // 👈👈👈
        text.style.display = "block";
        debugger;
      } else {
        text.style.display = "none";
        purecss.disabled = true;
      }
    });
  </script>
</html>
Enter fullscreen mode Exit fullscreen mode

Discussion (0)