From 2d06e4758aeaa05a22a3fb29da126e6da39d5a5c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 26 Oct 2026 21:09:18 +0000 Subject: [PATCH 1/3] perf: defer initial storage write until app is ready --- src/runtime/plugin.client.ts | 36 ++++++++++++++++++++++++- test/browser.ts | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/runtime/plugin.client.ts b/src/runtime/plugin.client.ts index 39ca9d6a..c24e1d36 100644 --- a/src/runtime/plugin.client.ts +++ b/src/runtime/plugin.client.ts @@ -1,6 +1,7 @@ import { computed, reactive, watch } from 'vue' import type { ColorModeInstance } from './types' +import type { NuxtApp } from '#app' import { defineNuxtPlugin, useRouter, useHead, useState, useCookie, useRuntimeConfig } from '#imports' import { globalName, storageKey, dataValue, disableTransition, storage } from '#build/color-mode-options.mjs' @@ -75,6 +76,9 @@ export default defineNuxtPlugin((nuxtApp) => { }) } + let isReady = false + let pendingPreference: string | undefined + watch(() => colorMode.preference, (preference) => { if (colorMode.forced) { return @@ -88,7 +92,12 @@ export default defineNuxtPlugin((nuxtApp) => { setColorModeValue(colorMode, preference) } - setPreferenceToStorage(preference) + if (isReady) { + setPreferenceToStorage(preference) + } + else { + pendingPreference = preference + } // Local storage to sync with other tabs // window.localStorage?.setItem(storageKey, preference) }, { immediate: true }) @@ -114,6 +123,14 @@ export default defineNuxtPlugin((nuxtApp) => { watchMedia() } + onIdleAfterHydration(nuxtApp, () => { + isReady = true + const preference = pendingPreference + if (preference) { + nuxtApp.runWithContext(() => setPreferenceToStorage(preference)) + } + }) + nuxtApp.hook('app:mounted', () => { if (colorMode.unknown) { colorMode.preference = helper.preference @@ -125,6 +142,23 @@ export default defineNuxtPlugin((nuxtApp) => { nuxtApp.provide('colorMode', colorMode) }) +function onIdleAfterHydration(nuxtApp: NuxtApp, callback: () => void) { + const schedule = () => { + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(callback, { timeout: 1000 }) + } + else { + setTimeout(callback, 1) + } + } + if (nuxtApp.isHydrating) { + nuxtApp.hooks.hookOnce('app:suspense:resolve', schedule) + } + else { + schedule() + } +} + function setColorModeValue(colorMode: ColorModeInstance, value: string) { // @ts-expect-error readonly property colorMode.value = value diff --git a/test/browser.ts b/test/browser.ts index 0b3a4cf5..54ae7c54 100644 --- a/test/browser.ts +++ b/test/browser.ts @@ -39,6 +39,49 @@ function resumeHydration(page: Page) { return page.evaluate(() => (window as any).__resumeHydration()) } +interface Call { + method: 'matchMedia' | 'addEventListener' | 'setItem' + arg: string + phase: 'script' | 'hydrating' | 'hydrated' +} + +function instrument(page: Page) { + return page.addInitScript(() => { + const calls: Call[] = [] + Object.assign(window, { __calls: calls }) + const phase = (): Call['phase'] => { + if (!('__NUXT_COLOR_MODE__' in window)) { + return 'script' + } + try { + return window.useNuxtApp!().isHydrating ? 'hydrating' : 'hydrated' + } + catch { + return 'hydrating' + } + } + const matchMedia = window.matchMedia + window.matchMedia = (query) => { + calls.push({ method: 'matchMedia', arg: query, phase: phase() }) + return matchMedia.call(window, query) + } + const addEventListener = MediaQueryList.prototype.addEventListener + MediaQueryList.prototype.addEventListener = function (this: MediaQueryList, ...args: Parameters) { + calls.push({ method: 'addEventListener', arg: this.media, phase: phase() }) + return addEventListener.apply(this, args) + } + const setItem = Storage.prototype.setItem + Storage.prototype.setItem = function (this: Storage, key, value) { + calls.push({ method: 'setItem', arg: `${key}=${value}`, phase: phase() }) + return setItem.call(this, key, value) + } + }) +} + +function getCalls(page: Page, method: Call['method']) { + return page.evaluate(method => (window as any).__calls.filter((call: Call) => call.method === method), method) +} + export function defineBrowserTests() { it('resolves the system color scheme', async () => { const page = await createPage('/', { colorScheme: 'dark' }) @@ -77,6 +120,15 @@ export function defineBrowserTests() { await page.close() }) + it('writes to storage once hydrated', async () => { + const page = await createPage(undefined, { colorScheme: 'light' }) + await page.addInitScript(() => localStorage.setItem('nuxt-color-mode', 'dark')) + await instrument(page) + await page.goto(url('/'), { waitUntil: 'hydration' }) + await expect.poll(() => getCalls(page, 'setItem')).toEqual([{ method: 'setItem', arg: 'nuxt-color-mode=dark', phase: 'hydrated' }]) + await page.close() + }) + it('stores a preference changed during hydration', async () => { const page = await createPage(undefined, { colorScheme: 'light' }) await gotoWithHeldHydration(page, '/') From a36d5adb1da2508d007d4c4e53702660e383acab Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 26 Oct 2026 21:12:37 +0000 Subject: [PATCH 2/3] perf: defer `prefers-color-scheme` listener until app is ready --- src/runtime/plugin.client.ts | 24 ++++++++++++++---------- test/browser.ts | 8 ++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/runtime/plugin.client.ts b/src/runtime/plugin.client.ts index c24e1d36..9762e206 100644 --- a/src/runtime/plugin.client.ts +++ b/src/runtime/plugin.client.ts @@ -63,17 +63,19 @@ export default defineNuxtPlugin((nuxtApp) => { let darkWatcher: MediaQueryList + function syncSystemColorScheme() { + if (!colorMode.forced && colorMode.preference === 'system') { + setColorModeValue(colorMode, helper.getColorScheme()) + } + } + function watchMedia() { if (darkWatcher || !window.matchMedia) { return } darkWatcher = window.matchMedia('(prefers-color-scheme: dark)') - darkWatcher.addEventListener('change', () => { - if (!colorMode.forced && colorMode.preference === 'system') { - setColorModeValue(colorMode, helper.getColorScheme()) - } - }) + darkWatcher.addEventListener('change', syncSystemColorScheme) } let isReady = false @@ -86,7 +88,9 @@ export default defineNuxtPlugin((nuxtApp) => { if (preference === 'system') { setColorModeValue(colorMode, helper.getColorScheme()) - watchMedia() + if (isReady) { + watchMedia() + } } else { setColorModeValue(colorMode, preference) @@ -119,12 +123,12 @@ export default defineNuxtPlugin((nuxtApp) => { } }) - if (colorMode.preference === 'system') { - watchMedia() - } - onIdleAfterHydration(nuxtApp, () => { isReady = true + if (colorMode.preference === 'system') { + watchMedia() + syncSystemColorScheme() + } const preference = pendingPreference if (preference) { nuxtApp.runWithContext(() => setPreferenceToStorage(preference)) diff --git a/test/browser.ts b/test/browser.ts index 54ae7c54..8df93331 100644 --- a/test/browser.ts +++ b/test/browser.ts @@ -96,6 +96,14 @@ export function defineBrowserTests() { await page.close() }) + it('listens for system color scheme changes once hydrated', async () => { + const page = await createPage(undefined, { colorScheme: 'dark' }) + await instrument(page) + await page.goto(url('/'), { waitUntil: 'hydration' }) + await expect.poll(() => getCalls(page, 'addEventListener')).toEqual([{ method: 'addEventListener', arg: '(prefers-color-scheme: dark)', phase: 'hydrated' }]) + await page.close() + }) + it('picks up a system color scheme change during hydration', async () => { const page = await createPage(undefined, { colorScheme: 'dark' }) await gotoWithHeldHydration(page, '/') From 1d29fb690911edabf9f293b8c97c011f43a19ed5 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 26 Oct 2026 21:14:07 +0000 Subject: [PATCH 3/3] perf: reuse color scheme resolved by inline script on startup --- src/runtime/plugin.client.ts | 13 +++++++++++-- test/browser.ts | 26 +++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/runtime/plugin.client.ts b/src/runtime/plugin.client.ts index 9762e206..48f14b6e 100644 --- a/src/runtime/plugin.client.ts +++ b/src/runtime/plugin.client.ts @@ -36,6 +36,14 @@ export default defineNuxtPlugin((nuxtApp) => { forced: false, })).value + let initialColorScheme = helper.preference === 'system' && !document.documentElement.hasAttribute('data-color-mode-forced') + ? helper.value + : undefined + + function getColorScheme() { + return initialColorScheme || helper.getColorScheme() + } + if (dataValue) { useHead({ htmlAttrs: { [`data-${dataValue}`]: computed(() => colorMode.value) }, @@ -55,7 +63,7 @@ export default defineNuxtPlugin((nuxtApp) => { } colorMode.forced = false const newValue = colorMode.preference === 'system' - ? helper.getColorScheme() + ? getColorScheme() : colorMode.preference setColorModeValue(colorMode, newValue) } @@ -87,7 +95,7 @@ export default defineNuxtPlugin((nuxtApp) => { } if (preference === 'system') { - setColorModeValue(colorMode, helper.getColorScheme()) + setColorModeValue(colorMode, getColorScheme()) if (isReady) { watchMedia() } @@ -125,6 +133,7 @@ export default defineNuxtPlugin((nuxtApp) => { onIdleAfterHydration(nuxtApp, () => { isReady = true + initialColorScheme = undefined if (colorMode.preference === 'system') { watchMedia() syncSystemColorScheme() diff --git a/test/browser.ts b/test/browser.ts index 8df93331..958b6059 100644 --- a/test/browser.ts +++ b/test/browser.ts @@ -78,7 +78,7 @@ function instrument(page: Page) { }) } -function getCalls(page: Page, method: Call['method']) { +function getCalls(page: Page, method: Call['method']): Promise { return page.evaluate(method => (window as any).__calls.filter((call: Call) => call.method === method), method) } @@ -104,6 +104,30 @@ export function defineBrowserTests() { await page.close() }) + it('reuses the color scheme resolved by the inline script until hydrated', async () => { + const page = await createPage(undefined, { colorScheme: 'dark' }) + await instrument(page) + await page.goto(url('/'), { waitUntil: 'hydration' }) + await expect.poll(() => getCalls(page, 'addEventListener')).toHaveLength(1) + const calls = await getCalls(page, 'matchMedia') + expect(calls.filter(call => call.phase === 'hydrating')).toEqual([]) + await page.close() + }) + + it('picks up a system color scheme change before the app starts', async () => { + const page = await createPage(undefined, { colorScheme: 'dark' }) + let release!: () => void + const released = new Promise(resolve => release = resolve) + await page.route('**/_nuxt/**/*.js', route => released.then(() => route.continue())) + await page.goto(url('/'), { waitUntil: 'commit' }) + await page.waitForFunction(() => '__NUXT_COLOR_MODE__' in window) + await page.emulateMedia({ colorScheme: 'light' }) + release() + await page.waitForFunction(() => window.useNuxtApp?.().isHydrating === false) + await expect.poll(() => getColorMode(page)).toMatchObject({ value: 'light', className: 'light-mode' }) + await page.close() + }) + it('picks up a system color scheme change during hydration', async () => { const page = await createPage(undefined, { colorScheme: 'dark' }) await gotoWithHeldHydration(page, '/')