From e19a1ea8f8eb0c19d921e74e3b766c2756f62f1e Mon Sep 17 00:00:00 2001 From: yu859 <15715093608@163.com> Date: Fri, 17 Jul 2026 21:16:31 +0800 Subject: [PATCH] fix(ui): prevent theme flash and delayed dark mode application --- packages/oxc/src/app/plugins/color-mode.ts | 6 ++++ packages/oxc/src/nuxt.config.ts | 5 +++ .../rolldown/src/app/plugins/color-mode.ts | 6 ++++ packages/rolldown/src/nuxt.config.ts | 5 +++ packages/ui/src/composables/dark.ts | 5 +-- packages/ui/src/plugins/color-mode.ts | 36 +++++++++++++++++++ packages/ui/src/utils/color-scheme.ts | 35 ++++++++++++++++++ packages/vite/src/app/plugins/color-mode.ts | 6 ++++ packages/vite/src/nuxt.config.ts | 5 +++ 9 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 packages/oxc/src/app/plugins/color-mode.ts create mode 100644 packages/rolldown/src/app/plugins/color-mode.ts create mode 100644 packages/ui/src/plugins/color-mode.ts create mode 100644 packages/ui/src/utils/color-scheme.ts create mode 100644 packages/vite/src/app/plugins/color-mode.ts diff --git a/packages/oxc/src/app/plugins/color-mode.ts b/packages/oxc/src/app/plugins/color-mode.ts new file mode 100644 index 000000000..d3a7d399e --- /dev/null +++ b/packages/oxc/src/app/plugins/color-mode.ts @@ -0,0 +1,6 @@ +import { installColorMode } from '@vitejs/devtools-ui/plugins/color-mode' +import { defineNuxtPlugin } from '#app/nuxt' + +export default defineNuxtPlugin(nuxtApp => { + installColorMode(nuxtApp) +}) diff --git a/packages/oxc/src/nuxt.config.ts b/packages/oxc/src/nuxt.config.ts index e5f85d62d..6041b4c09 100644 --- a/packages/oxc/src/nuxt.config.ts +++ b/packages/oxc/src/nuxt.config.ts @@ -1,4 +1,5 @@ import { fileURLToPath } from 'node:url' +import { getColorSchemeHeadScript } from '@vitejs/devtools-ui/utils/color-scheme' import { defineNuxtConfig } from 'nuxt/config' import { alias } from '../../../alias' @@ -57,6 +58,10 @@ export default defineNuxtConfig({ href: '/favicon.svg', }, ], + script: [ + // Anti-FOUC: apply the resolved color scheme before first paint. + { innerHTML: getColorSchemeHeadScript(), tagPosition: 'head' }, + ], htmlAttrs: { lang: 'en', class: 'bg-dots', diff --git a/packages/rolldown/src/app/plugins/color-mode.ts b/packages/rolldown/src/app/plugins/color-mode.ts new file mode 100644 index 000000000..90287570c --- /dev/null +++ b/packages/rolldown/src/app/plugins/color-mode.ts @@ -0,0 +1,6 @@ +import { installColorMode } from '@vitejs/devtools-ui/plugins/color-mode' +import { defineNuxtPlugin } from '#app/nuxt' + +export default defineNuxtPlugin((nuxtApp) => { + installColorMode(nuxtApp) +}) diff --git a/packages/rolldown/src/nuxt.config.ts b/packages/rolldown/src/nuxt.config.ts index d67c6a2b6..bfe993230 100644 --- a/packages/rolldown/src/nuxt.config.ts +++ b/packages/rolldown/src/nuxt.config.ts @@ -1,5 +1,6 @@ import process from 'node:process' import { fileURLToPath } from 'node:url' +import { getColorSchemeHeadScript } from '@vitejs/devtools-ui/utils/color-scheme' import { defineNuxtConfig } from 'nuxt/config' import Inspect from 'vite-plugin-inspect' import { alias } from '../../../alias' @@ -82,6 +83,10 @@ export default defineNuxtConfig({ link: [ { rel: 'icon', type: 'image/svg+xml', href: `/favicon.svg` }, ], + script: [ + // Anti-FOUC: apply the resolved color scheme before first paint. + { innerHTML: getColorSchemeHeadScript(), tagPosition: 'head' }, + ], htmlAttrs: { lang: 'en', class: 'bg-dots', diff --git a/packages/ui/src/composables/dark.ts b/packages/ui/src/composables/dark.ts index 84b31fe9b..6b334b32a 100644 --- a/packages/ui/src/composables/dark.ts +++ b/packages/ui/src/composables/dark.ts @@ -1,8 +1,9 @@ import { useDark } from '@vueuse/core' +import { COLOR_SCHEME_LIGHT, COLOR_SCHEME_STORAGE_KEY } from '../utils/color-scheme' export const isDark = useDark({ - storageKey: 'vite-devtools-color-scheme', - valueLight: 'light', + storageKey: COLOR_SCHEME_STORAGE_KEY, + valueLight: COLOR_SCHEME_LIGHT, }) export function toggleDark() { diff --git a/packages/ui/src/plugins/color-mode.ts b/packages/ui/src/plugins/color-mode.ts new file mode 100644 index 000000000..a84dcf268 --- /dev/null +++ b/packages/ui/src/plugins/color-mode.ts @@ -0,0 +1,36 @@ +import { watch } from 'vue' +import { isDark } from '../composables/dark' +import { COLOR_SCHEME_DARK, COLOR_SCHEME_LIGHT } from '../utils/color-scheme' + +/** + * Minimal structural type for the Nuxt app, avoiding a hard dependency on + * Nuxt's types from within the shared UI package. + */ +interface NuxtAppLike { + hook: (name: 'app:mounted', cb: () => void) => void +} + +function applyColorScheme(dark: boolean): void { + const el = document.documentElement + el.classList.add(dark ? COLOR_SCHEME_DARK : COLOR_SCHEME_LIGHT) + el.classList.remove(dark ? COLOR_SCHEME_LIGHT : COLOR_SCHEME_DARK) + el.style.colorScheme = dark ? COLOR_SCHEME_DARK : COLOR_SCHEME_LIGHT +} + +/** + * Ensures the resolved color scheme is applied within the Nuxt app lifecycle. + * + * The `isDark` singleton is created via `useDark()` at module scope, so its own + * `tryOnMounted` re-sync never fires (no component instance). Nuxt/unhead writes + * `htmlAttrs.class` during mount, which can drop the theme class applied at + * import time and leave the UI stuck in light mode until an unrelated reactive + * flush (cursor move / scroll). Re-applying on `app:mounted` — after unhead has + * set the attributes — fixes that. The `watch` keeps it in sync for runtime + * toggles and OS theme changes. + */ +export function installColorMode(nuxtApp: NuxtAppLike): void { + nuxtApp.hook('app:mounted', () => { + applyColorScheme(isDark.value) + watch(isDark, applyColorScheme, { flush: 'post' }) + }) +} diff --git a/packages/ui/src/utils/color-scheme.ts b/packages/ui/src/utils/color-scheme.ts new file mode 100644 index 000000000..21752ba8f --- /dev/null +++ b/packages/ui/src/utils/color-scheme.ts @@ -0,0 +1,35 @@ +/** + * Shared color-scheme constants and the anti-FOUC inline script. + * + * This module must stay side-effect free (no VueUse / no `dark.ts` import) so + * it can be imported from `nuxt.config.ts` at build time in Node, where + * `window` is unavailable. + * + * The storage key and class names are the single source of truth shared by the + * `useDark()` singleton in `../composables/dark` and the inline head script, + * so the two can never drift apart. + */ + +export const COLOR_SCHEME_STORAGE_KEY = 'vite-devtools-color-scheme' +export const COLOR_SCHEME_DARK = 'dark' +export const COLOR_SCHEME_LIGHT = 'light' + +/** + * Returns a minified IIFE to be inlined in `` so the resolved theme is + * applied to `` before first paint, eliminating the flash of light theme. + * + * It mirrors `useDark`'s `auto` resolution exactly: honor a stored + * `dark`/`light` value, otherwise fall back to the OS `prefers-color-scheme`. + */ +export function getColorSchemeHeadScript(): string { + const key = JSON.stringify(COLOR_SCHEME_STORAGE_KEY) + const dark = JSON.stringify(COLOR_SCHEME_DARK) + const light = JSON.stringify(COLOR_SCHEME_LIGHT) + return `;(function(){try{` + + `var v=localStorage.getItem(${key});` + + `var d=v===${dark}||((v===null||v==='auto')&&matchMedia('(prefers-color-scheme: dark)').matches);` + + `var el=document.documentElement;` + + `el.classList.add(d?${dark}:${light});el.classList.remove(d?${light}:${dark});` + + `el.style.colorScheme=d?${dark}:${light};` + + `}catch(e){}})();` +} diff --git a/packages/vite/src/app/plugins/color-mode.ts b/packages/vite/src/app/plugins/color-mode.ts new file mode 100644 index 000000000..90287570c --- /dev/null +++ b/packages/vite/src/app/plugins/color-mode.ts @@ -0,0 +1,6 @@ +import { installColorMode } from '@vitejs/devtools-ui/plugins/color-mode' +import { defineNuxtPlugin } from '#app/nuxt' + +export default defineNuxtPlugin((nuxtApp) => { + installColorMode(nuxtApp) +}) diff --git a/packages/vite/src/nuxt.config.ts b/packages/vite/src/nuxt.config.ts index 63bb99a3c..d13283b6e 100644 --- a/packages/vite/src/nuxt.config.ts +++ b/packages/vite/src/nuxt.config.ts @@ -1,4 +1,5 @@ import process from 'node:process' +import { getColorSchemeHeadScript } from '@vitejs/devtools-ui/utils/color-scheme' import { defineNuxtConfig } from 'nuxt/config' import { alias } from '../../../alias' import '@nuxt/eslint' @@ -75,6 +76,10 @@ export default defineNuxtConfig({ link: [ { rel: 'icon', type: 'image/svg+xml', href: `/favicon.svg` }, ], + script: [ + // Anti-FOUC: apply the resolved color scheme before first paint. + { innerHTML: getColorSchemeHeadScript(), tagPosition: 'head' }, + ], htmlAttrs: { lang: 'en', class: 'bg-dots',