Skip to content

Favicon does not update on theme toggle without page reload #4100

@hl9020

Description

@hl9020

Bug Description

The favicon does not switch between light and dark variants when toggling the theme in Dokploy's UI. It only updates after a full page reload.

Root Cause

_document.tsx sets a static favicon:

<link rel="icon" href="/icon.svg" />

The SVG uses @media (prefers-color-scheme: dark) to switch between black and white fills. This reacts to the OS-level color scheme, not to Dokploy's app-level theme state.

Dokploy uses next-themes with attribute="class" (in _app.tsx), meaning theme switching is class-based on the <html> element. Since the favicon is loaded as an external resource and not part of the DOM, it cannot observe class changes - it only responds to the OS media query.

This was partially addressed in PR #1049, which added the prefers-color-scheme media query to the SVG. The PR author documented the mismatch between OS theme and Dokploy theme but did not resolve it.

Steps to Reproduce

  1. Set OS to light mode
  2. Open Dokploy, toggle theme to dark
  3. Observe: favicon remains black (light-mode variant)
  4. Reload the page
  5. Favicon is still wrong because OS is light, but Dokploy is dark

Proposed Fix

1. Add two static SVG assets to /public:

  • icon-light.svg - paths with fill="black", no style block
  • icon-dark.svg - paths with fill="white", no style block

These are trivial derivations of the existing icon.svg with the <style> block removed and a fixed fill attribute on each path.

2. Extend WhitelabelingProvider to handle theme-aware favicon switching declaratively via next/head:

import { useTheme } from "next-themes";

export function WhitelabelingProvider() {
  const { resolvedTheme } = useTheme();
  const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
    staleTime: 5 * 60 * 1000,
    refetchOnWindowFocus: false,
  });

  const faviconHref = config?.faviconUrl
    ?? (resolvedTheme === "dark" ? "/icon-dark.svg"
      : resolvedTheme === "light" ? "/icon-light.svg"
      : "/icon.svg");

  return (
    <>
      <Head>
        {config?.metaTitle && <title>{config.metaTitle}</title>}
        <link rel="icon" href={faviconHref} key="app-favicon" />
      </Head>
      {config?.customCss && (
        <style
          id="whitelabeling-styles"
          dangerouslySetInnerHTML={{ __html: config.customCss }}
        />
      )}
    </>
  );
}

Why extend WhitelabelingProvider instead of adding a new component:

  • It already owns favicon and meta title injection via next/head
  • Single source of truth for all <head> meta - no race conditions between competing <link rel="icon"> tags
  • The key="app-favicon" ensures Next.js deduplicates the tag properly

Why declarative next/head instead of querySelector DOM manipulation:

  • WhitelabelingProvider already uses <Head> for favicons - mixing in imperative DOM mutation introduces timing conflicts
  • querySelector('link[rel="icon"]') is fragile when multiple icon links exist
  • Declarative approach lets React/Next.js manage exactly one favicon entry

3. Keep _document.tsx unchanged - the existing icon.svg with prefers-color-scheme remains as a pre-hydration fallback. Before JS loads, users at least get an OS-matching icon, which is better than a static single-color fallback.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions