diff --git a/resources/js/hooks/use-appearance.tsx b/resources/js/hooks/use-appearance.tsx index 5d75035af..f3c3f59b6 100644 --- a/resources/js/hooks/use-appearance.tsx +++ b/resources/js/hooks/use-appearance.tsx @@ -78,7 +78,24 @@ const mediaQuery = (): MediaQueryList | null => { return window.matchMedia('(prefers-color-scheme: dark)'); }; -const handleSystemThemeChange = (): void => applyTheme(currentAppearance); +// What the system theme resolves to changed, so whatever shows the resolved theme, like the logo copied as SVG, +// renders again. +const handleSystemThemeChange = (): void => { + applyTheme(currentAppearance); + notify(); +}; + +// Another tab saved a theme, or cleared everything it saved, so this one follows without a reload, as the SSR +// cookie already does on the next one. +const handleStorage = (event: StorageEvent): void => { + if (event.key !== 'appearance' && event.key !== null) { + return; + } + + currentAppearance = getStoredAppearance(); + applyTheme(currentAppearance); + notify(); +}; export function initializeTheme(): void { if (typeof window === 'undefined') { @@ -95,6 +112,7 @@ export function initializeTheme(): void { // Set up system theme change listener mediaQuery()?.addEventListener('change', handleSystemThemeChange); + window.addEventListener('storage', handleStorage); } export function useAppearance(): UseAppearanceReturn { diff --git a/tests/Unit/Appearance.test.ts b/tests/Unit/Appearance.test.ts new file mode 100644 index 000000000..82f933cf5 --- /dev/null +++ b/tests/Unit/Appearance.test.ts @@ -0,0 +1,113 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import { beforeEach, test, vi } from 'vite-plus/test'; + +const rendered = vi.fn(); +vi.doMock('react', () => ({ + ...React, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + snapshot: () => unknown, + ) => { + subscribe(rendered); + return snapshot(); + }, +})); + +const saved = new Map(); +const storageListeners: ((event: { key: string | null }) => void)[] = []; +const systemThemeListeners: (() => void)[] = []; +let systemIsDark = false; +const classes = new Set(); + +const localStorage = { + getItem: (key: string) => saved.get(key) ?? null, + setItem: (key: string, value: string) => saved.set(key, value), + removeItem: (key: string) => saved.delete(key), +}; + +Object.assign(globalThis, { + localStorage, + window: { + localStorage, + matchMedia: () => ({ + get matches() { + return systemIsDark; + }, + addEventListener: (_type: string, listener: () => void) => + systemThemeListeners.push(listener), + }), + addEventListener: ( + type: string, + listener: (event: { key: string | null }) => void, + ) => { + if (type === 'storage') { + storageListeners.push(listener); + } + }, + }, + document: { + cookie: '', + documentElement: { + classList: { + toggle: (name: string, on: boolean) => + on ? classes.add(name) : classes.delete(name), + }, + style: {}, + }, + querySelectorAll: () => [], + }, + getComputedStyle: () => ({ getPropertyValue: () => '' }), +}); + +const { initializeTheme, useAppearance } = + await import('../../resources/js/hooks/use-appearance'); + +saved.set('appearance', 'light'); +initializeTheme(); + +beforeEach(() => { + rendered.mockClear(); +}); + +test('follows a theme another tab saves', () => { + useAppearance(); + saved.set('appearance', 'dark'); + storageListeners.forEach((listener) => listener({ key: 'appearance' })); + + assert.ok(classes.has('dark')); + assert.equal(useAppearance().appearance, 'dark'); + assert.ok(rendered.mock.calls.length > 0); +}); + +test('falls back to the system theme when another tab wipes local storage', () => { + systemIsDark = false; + saved.clear(); + storageListeners.forEach((listener) => listener({ key: null })); + + assert.ok(!classes.has('dark')); + assert.equal(useAppearance().appearance, 'system'); +}); + +test('ignores what other tabs save under other keys', () => { + saved.set('appearance', 'dark'); + storageListeners.forEach((listener) => listener({ key: 'chat-dock' })); + + assert.equal(useAppearance().appearance, 'system'); + assert.equal(rendered.mock.calls.length, 0); +}); + +test('re-renders the resolved theme when the system one changes', () => { + saved.set('appearance', 'system'); + storageListeners.forEach((listener) => listener({ key: 'appearance' })); + useAppearance(); + rendered.mockClear(); + + systemIsDark = true; + systemThemeListeners.forEach((listener) => listener()); + + assert.ok(classes.has('dark')); + assert.ok(rendered.mock.calls.length > 0); + assert.equal(useAppearance().resolvedAppearance, 'dark'); +});