Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 59 additions & 12 deletions src/runtime/plugin.client.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -35,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) },
Expand All @@ -54,41 +63,53 @@ export default defineNuxtPlugin((nuxtApp) => {
}
colorMode.forced = false
const newValue = colorMode.preference === 'system'
? helper.getColorScheme()
? getColorScheme()
: colorMode.preference
setColorModeValue(colorMode, newValue)
}
})

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
let pendingPreference: string | undefined

watch(() => colorMode.preference, (preference) => {
if (colorMode.forced) {
return
}

if (preference === 'system') {
setColorModeValue(colorMode, helper.getColorScheme())
watchMedia()
setColorModeValue(colorMode, getColorScheme())
if (isReady) {
watchMedia()
}
}
else {
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 })
Expand All @@ -110,9 +131,18 @@ export default defineNuxtPlugin((nuxtApp) => {
}
})

if (colorMode.preference === 'system') {
watchMedia()
}
onIdleAfterHydration(nuxtApp, () => {
Comment thread
atinux marked this conversation as resolved.
Resolved
isReady = true
initialColorScheme = undefined
if (colorMode.preference === 'system') {
watchMedia()
syncSystemColorScheme()
}
const preference = pendingPreference
if (preference) {
nuxtApp.runWithContext(() => setPreferenceToStorage(preference))
}
})

nuxtApp.hook('app:mounted', () => {
if (colorMode.unknown) {
Expand All @@ -125,6 +155,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
Expand Down
84 changes: 84 additions & 0 deletions test/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof addEventListener>) {
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']): Promise<Call[]> {
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' })
Expand All @@ -53,6 +96,38 @@ 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('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<void>(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, '/')
Expand All @@ -77,6 +152,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, '/')
Expand Down
Loading