Why does the icon in this Astro/Svelte component flicker on refresh?

<script lang="ts">
  import { onMount } from "svelte";  
  let theme = localStorage.getItem('theme') ?? 'light';
  let flag = false;
  onMount(()=>{    
    flag = true
  })
  $: if (flag) {
    if ( theme === 'dark') {
      document.documentElement.classList.add("dark");
    } else {
      document.documentElement.classList.remove("dark");
    }     
    localStorage.setItem("theme", theme);
  }
  
  const handleClick = () => {    
    theme = (theme === "light" ? "dark" : "light");    
  }; 
</script>

<button on:click={handleClick}>{theme === "dark" ? "🌕" : "🌑"}</button>

the icon flickers when dark mode is enabled, in light mode this doesnt happen, I'm assuming this happens because its defaulting to lightmode when it initially renders, how can i fix this?

Upvotes: 3

Views: 1671

Answers (2)

HynekS
HynekS

Reputation: 3317

I faced the same problem: a brief flash of the "default" theme icon after each page load.

The theme itself was loaded properly (no flash of light background in dark theme and vice versa) using this short render-blocking script in the <head> element that was setting the "dark" class on the <html> element if found in localStorage:

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- etc. -->
    <script is:inline>
      const theme = (() => {
        if (
          typeof localStorage !== "undefined" &&
          localStorage.getItem("theme")
        ) {
          return localStorage.getItem("theme");
        }
        if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
          return "dark";
        }
        return "light";
      })();
  
      if (theme === "light") {
        document.documentElement.classList.remove("dark");
      } else {
        document.documentElement.classList.add("dark");
      }
    </script>
  </head>
  <body>
   <!-- etc. -->
  </body>
</html>

As a toggler, I was using a simple Preact component, used like this:

<header>
    <ThemeToggle client:load/>
</header>

It was this element that was flickering. I would not put the whole code here, just the important parts:

// ThemeToggle.tsx
export default function ThemeToggle() {
  const theme = signal(localStorage.getItem("theme") ?? "light");  

  // event handler, etc. omitted…

  return (
    <button>
      {theme.value === "light" ? <MoonIcon /> : <SunIcon />}
    </button>
  );
}

I suspected there was an issue in my code logic, but there wasn't—it was flickering even when I threw everything but the return block away.

It was the @wassfila's answer that helped me figured out what was happening. I was using SSG, so what I got from the server was a static HTML string. Since Astro's client:load directive implements hydration, it was lazily loaded only after the page was initially rendered. That's why it was flickering! I don't know in detail how Astro does the static render, but since it is done on the server, I only guess that localStorage.getItem("theme") ?? "light" was resulting in the theme being set to "light", because there's no localStorage on server runtime.

The solution?

I ditched the Preact component because, although Preact is a tiny lib, 3kb of JS is still too much for a simple little toggle. And since I already have the value of the theme on the page when it's being rendered (as a class on the <html> element), I just use CSS and set different visibility (default/hidden) on the icons according to the current theme.

---
// themeToggle.astro
---
<button class="relative w-8 h-8" id="theme-toggle">
  <svg class="absolute inset-0 dark:invisible">
    <!-- etc -->
  </svg>
  <svg class="absolute inset-0 invisible dark:visible">
    <!-- etc -->
  </svg>
</button>

<script>
    document.getElementById("theme-toggle")?.addEventListener(
      "click",
      () => { // theme switch logic, omitted for brevity …}
    );
  }
</script>

This is using Tailwind and the "dark" class, but it would work the same with, e.g., vanilla CSS and a data attribute instead of class.

Upvotes: 1

wassfila
wassfila

Reputation: 1901

Problem specification

The flicker is caused by an unexpected side effect of MPA (Multi Page Application). In SPA (Single Page Application), routing happens on client side and only a single page is fetched on startup, so the state of things such as menu or theme stay consistent on client side.

Astro being an MPA would require a server page fetch when jumping from page to page. If the server does not know what the client set as theme (dark or light), it has to send a default one, then only when the <script> tag executes on the client, that's when the persisted state on client will kick in.

It is possible to refer to this problem as server/client state synchronization

To make the problem more spicy, we have to keep in mind that multiple clients could be using the website and not just one.

Solutions

For the solution purpose, if we think of the theme dark/light as being a counter 0/1

SSG using client side routing

I still want to mention this again in case of restriction for Static Site, then the two next solutions do not work, then fall back on client side routing is required => SPA

next solutions apply for Server Side Rendering SSR

SSR using cookies

set by the client and read by the server upon request to ensure correct state is sent back singe page load

here is the main code snippet on server side

let counter = 0
const cookie = Astro.cookies.get("counter")
if(cookie?.value){
    counter = cookie.value
}

here the functions how to set and get the cookie on client side with vanialla js

function get_counter(){
        const entry = document.cookie.split(';').find(entry=>entry.replace(' ','').startsWith('counter='))
        if(entry){
            return parseInt(entry.split('=')[1])
        }else{
            return 0
        }
    }
    function set_counter(counter){
        document.cookie = `counter=${counter}`
        console.log(`new counter value = ${counter}`)
    }

SSR avoiding cookies with storage and url params

Cookies are a much simpler solution, but in case this does not work for some reason (blocked, do not want notification,...) it is possible with storage and url parameters

setting the url parameter on client side and managing storage

...
window.history.replaceState(null, null, `?session_id=${session_id}`);
...
let session_id = sessionStorage.getItem("session_id")
...
sessionStorage.setItem("counter",counter)

and this is how the server manages the session id

let session_id = suid()
if(Astro.url.searchParams.has('session_id')){
    session_id = Astro.url.searchParams.get('session_id')
    console.log(`index.astro> retrieved session_id from url param '${session_id}'`)
}else{
    console.log(`index.astro> assigned new session_id '${session_id}'`)
}

References to full examples

with cookies

github : https://github.com/MicroWebStacks/astro-examples#13_client-cookie-counter

Cookies did not got though some vm service provides, here a working one on Gitpod

Gtipod : https://gitpod.io/?on=gitpod#https://github.com/MicroWebStacks/astro-examples/tree/main/13_client-cookie-counter

without cookies with storage and url params

github : https://github.com/MicroWebStacks/astro-examples#14_client-storage-counter

This runs anywhere so here example on Stackblitz : https://stackblitz.com/github/MicroWebStacks/astro-examples/tree/main/14_client-storage-counter

Upvotes: 4

Related Questions