This 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:
- 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). - Later on, when needed, we re-enable the stylesheet.
- Immediately after re-enabling the stylesheet, we show the view (e.g., by changing its visibility from
display: none
todisplay: 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:
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>
<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>
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).
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:
- 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). - Later on, when needed, we re-enable the stylesheet.
- Wait for the stylesheet to be available in
document.styleSheet
(using a quick-loopedsetInterval
). - Immediately after re-enabling the stylesheet, we show the view (e.g., by changing its visibility from
display: none
todisplay: 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>
<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>