From 6b41692d8155752df925337c9323e6684da7549e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:55:41 +0100 Subject: [PATCH] fix: show themes the admin installed in the theme picker without opening mail first --- components/__tests__/plugin-app-events.test.tsx | 14 ++++++++++++++ components/mail/mail-app.tsx | 5 ++--- components/providers/plugin-app-events.tsx | 7 ++++++- .../__tests__/themes-settings-collapse.test.tsx | 13 +++++++++++-- components/settings/themes-settings.tsx | 6 ++++++ 5 files changed, 39 insertions(+), 6 deletions(-) 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);