diff --git a/components/__tests__/plugin-app-events.test.tsx b/components/__tests__/plugin-app-events.test.tsx
index 75790e086..0c290c4fe 100644
--- a/components/__tests__/plugin-app-events.test.tsx
+++ b/components/__tests__/plugin-app-events.test.tsx
@@ -3,6 +3,7 @@ import { act, render } from '@testing-library/react';
import { appLifecycleHooks, clearAllHooks, routerHooks } from '@/lib/plugin-hooks';
import { usePluginStore } from '@/stores/plugin-store';
import { useAuthStore } from '@/stores/auth-store';
+import { useThemeStore } from '@/stores/theme-store';
let pathname = '/';
vi.mock('next/navigation', () => ({ usePathname: () => pathname }));
@@ -19,6 +20,7 @@ beforeEach(() => {
clearAllHooks();
pathname = '/';
usePluginStore.setState({ initialized: false, initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
useAuthStore.setState({ isAuthenticated: false });
});
afterEach(() => {
@@ -83,6 +85,18 @@ describe('app events for plugins', () => {
expect(usePluginStore.getState().initializePlugins).toHaveBeenCalledTimes(1);
});
+ // A theme the admin installed used to arrive only once the mail view had
+ // been opened, so Settings opened directly never listed it.
+ it('syncs the server themes once the user is signed in, on any page', () => {
+ pathname = '/settings';
+ render();
+ expect(useThemeStore.getState().syncServerThemes).not.toHaveBeenCalled();
+
+ act(() => { useAuthStore.setState({ isAuthenticated: true }); });
+
+ expect(useThemeStore.getState().syncServerThemes).toHaveBeenCalledTimes(1);
+ });
+
it('reports navigation between pages', () => {
const navigate = listen(routerHooks.onNavigate);
const { rerender } = render();
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index aa89bb046..8dd4aa2dc 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -130,7 +130,6 @@ import { useDocumentTitle } from "@/hooks/use-document-title";
import { mailTitleContext } from "@/lib/tab-title";
import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
-import { useThemeStore } from "@/stores/theme-store";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto";
import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose";
@@ -1339,10 +1338,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [checkAuth]);
// Initialize plugins on mount (re-activates enabled plugins after refresh)
- // Also syncs server-managed plugins and themes to the client
+ // Also syncs server-managed plugins to the client; themes sync from
+ // PluginAppEvents on every page.
useEffect(() => {
usePluginStore.getState().initializePlugins();
- useThemeStore.getState().syncServerThemes();
}, []);
// Hydrate persisted column widths from localStorage
diff --git a/components/providers/plugin-app-events.tsx b/components/providers/plugin-app-events.tsx
index f16b5146d..38ec9872e 100644
--- a/components/providers/plugin-app-events.tsx
+++ b/components/providers/plugin-app-events.tsx
@@ -9,6 +9,7 @@ import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
import { appLifecycleHooks, routerHooks, toastHooks, uiHooks } from '@/lib/plugin-hooks';
import { usePluginStore } from '@/stores/plugin-store';
+import { useThemeStore } from '@/stores/theme-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
@@ -126,8 +127,12 @@ export function PluginAppEvents() {
// Start the plugins on whichever page the user lands on. They used to start
// only from the mail view, so a plugin's sidebar entry, page or reminders
// were missing on Calendar, Contacts, Files or Settings opened directly.
+ // Server themes had the same problem: one installed by the admin never
+ // reached the theme picker until the mail view had been opened.
useEffect(() => {
- if (signedIn) void usePluginStore.getState().initializePlugins();
+ if (!signedIn) return;
+ void usePluginStore.getState().initializePlugins();
+ void useThemeStore.getState().syncServerThemes();
}, [signedIn]);
const readyFired = useRef(false);
diff --git a/components/settings/__tests__/themes-settings-collapse.test.tsx b/components/settings/__tests__/themes-settings-collapse.test.tsx
index ceac48da8..f36c868f7 100644
--- a/components/settings/__tests__/themes-settings-collapse.test.tsx
+++ b/components/settings/__tests__/themes-settings-collapse.test.tsx
@@ -1,5 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
-import { beforeEach, describe, expect, it } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ThemesSettings } from '../themes-settings';
import { useThemeStore } from '@/stores/theme-store';
@@ -16,7 +16,16 @@ function cardNames(): string[] {
describe('ThemesSettings collapse', () => {
beforeEach(() => {
- act(() => { useThemeStore.setState({ activeThemeId: null }); });
+ act(() => {
+ useThemeStore.setState({ activeThemeId: null, syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ });
+ });
+
+ // Without this, a theme the admin installed in the same tab stayed out of
+ // the gallery until a reload.
+ it('fetches the server themes when it opens', () => {
+ render();
+ expect(useThemeStore.getState().syncServerThemes).toHaveBeenCalledTimes(1);
});
it('shows three cards and a Show all button', () => {
diff --git a/components/settings/themes-settings.tsx b/components/settings/themes-settings.tsx
index 3978072db..9c80da72e 100644
--- a/components/settings/themes-settings.tsx
+++ b/components/settings/themes-settings.tsx
@@ -25,6 +25,12 @@ export function ThemesSettings({ embedded }: { embedded?: boolean } = {}) {
const themePolicy = getThemePolicy();
const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id));
+ // Fetch again on open, so a theme the admin just installed in this tab
+ // shows up without a reload.
+ useEffect(() => {
+ void useThemeStore.getState().syncServerThemes();
+ }, []);
+
// Render each preview in the variant matching the app's current mode, and
// keep it in sync when the user toggles light/dark elsewhere.
const [isDark, setIsDark] = useState(false);