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');
+});