diff --git a/CHANGELOG.md b/CHANGELOG.md
index f57e779..9c3711a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,7 +25,7 @@ All notable changes to this project will be documented in this file. This projec
- Updated Vite to 8.2.0, `@vitejs/plugin-react` to 6.0.5, and `lucide-react` to 1.28.0.
### Fixed
-- Custom logos no longer flash the default Stalwart logo while loading.
+- Custom logos no longer flash the default Stalwart logo while loading. Loading is encapsulated in `logoCache` (shared fetch + AbortController + blob URL revoke), keeping `uiStore` free of logo state while still caching across TopBar/Login remounts.
- Icon/label alignment in backend select triggers.
- Web Applications list shows an Enabled column again.
- Appearance Corners preview: only the Rounded choice forces rounded radius on its card and sample; Square stays sharp even when the global theme is square.
diff --git a/src/components/common/Logo.tsx b/src/components/common/Logo.tsx
index 92f0d48..e180817 100644
--- a/src/components/common/Logo.tsx
+++ b/src/components/common/Logo.tsx
@@ -4,9 +4,9 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
-import { useEffect } from 'react';
+import { useEffect, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
-import { useUIStore } from '@/stores/uiStore';
+import { ensureLogoLoaded, getLogoSnapshot, subscribeLogo } from '@/lib/logoCache';
export function DefaultLogo() {
const { t } = useTranslation();
@@ -31,19 +31,17 @@ export function DefaultLogo() {
export default function Logo() {
const { t } = useTranslation();
- const logoUrl = useUIStore((s) => s.logoUrl);
- const logoLoading = useUIStore((s) => s.logoLoading);
- const fetchLogo = useUIStore((s) => s.fetchLogo);
+ const logo = useSyncExternalStore(subscribeLogo, getLogoSnapshot, getLogoSnapshot);
useEffect(() => {
- fetchLogo();
- }, [fetchLogo]);
+ ensureLogoLoaded();
+ }, []);
- if (logoUrl) {
- return
;
+ if (logo.status === 'custom') {
+ return
;
}
- if (logoLoading) {
+ if (logo.status === 'loading') {
return ;
}
diff --git a/src/lib/logoCache.ts b/src/lib/logoCache.ts
new file mode 100644
index 0000000..75c5450
--- /dev/null
+++ b/src/lib/logoCache.ts
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
+ */
+
+import { getApiBaseUrl } from '@/services/api';
+
+export type LogoSnapshot = { status: 'loading' } | { status: 'custom'; url: string } | { status: 'default' };
+
+let snapshot: LogoSnapshot = { status: 'loading' };
+let objectUrl: string | null = null;
+let abortController: AbortController | null = null;
+let started = false;
+const listeners = new Set<() => void>();
+
+function emit(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function revokeObjectUrl(): void {
+ if (objectUrl) {
+ URL.revokeObjectURL(objectUrl);
+ objectUrl = null;
+ }
+}
+
+export function getLogoSnapshot(): LogoSnapshot {
+ return snapshot;
+}
+
+export function subscribeLogo(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+/** Starts a single shared /logo fetch. Safe to call from every Logo mount. */
+export function ensureLogoLoaded(): void {
+ if (started) return;
+ started = true;
+ snapshot = { status: 'loading' };
+ emit();
+
+ abortController?.abort();
+ abortController = new AbortController();
+ const { signal } = abortController;
+
+ fetch(`${getApiBaseUrl()}/logo`, { signal })
+ .then(async (response) => {
+ const contentType = response.headers.get('content-type') ?? '';
+ if (response.ok && contentType.startsWith('image/')) {
+ const blob = await response.blob();
+ if (signal.aborted) return;
+ revokeObjectUrl();
+ objectUrl = URL.createObjectURL(blob);
+ snapshot = { status: 'custom', url: objectUrl };
+ emit();
+ return;
+ }
+ if (signal.aborted) return;
+ snapshot = { status: 'default' };
+ emit();
+ })
+ .catch(() => {
+ if (signal.aborted) return;
+ snapshot = { status: 'default' };
+ emit();
+ });
+}
diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts
index a69c0ee..fa621bd 100644
--- a/src/stores/uiStore.ts
+++ b/src/stores/uiStore.ts
@@ -6,30 +6,17 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
-import { getApiBaseUrl } from '@/services/api';
export type Theme = 'light' | 'dark';
export type ColorTheme = 'stalwart' | 'ocean' | 'forest' | 'violet' | 'rose' | 'amber' | 'teal';
export type Radius = 'rounded' | 'square';
-let logoAbortController: AbortController | null = null;
-let logoObjectUrl: string | null = null;
-
-function revokeLogoObjectUrl() {
- if (logoObjectUrl) {
- URL.revokeObjectURL(logoObjectUrl);
- logoObjectUrl = null;
- }
-}
-
interface UIState {
theme: Theme;
colorTheme: ColorTheme;
radius: Radius;
sidebarOpen: boolean;
activeSection: string;
- logoUrl: string | null;
- logoLoading: boolean;
setTheme: (theme: Theme) => void;
setColorTheme: (colorTheme: ColorTheme) => void;
@@ -37,9 +24,6 @@ interface UIState {
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setActiveSection: (section: string) => void;
- setLogoUrl: (url: string | null) => void;
- setLogoLoading: (loading: boolean) => void;
- fetchLogo: () => void;
}
function applyThemeClass(theme: Theme) {
@@ -87,49 +71,6 @@ export const useUIStore = create()(
radius: 'square',
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
activeSection: '',
- logoUrl: null,
- logoLoading: false,
-
- setLogoUrl: (url) => {
- revokeLogoObjectUrl();
- if (url) {
- logoObjectUrl = url;
- }
- set({ logoUrl: url, logoLoading: false });
- },
-
- setLogoLoading: (loading) => {
- set({ logoLoading: loading });
- },
-
- fetchLogo: () => {
- const { logoUrl, logoLoading } = get();
- if (logoUrl !== null || logoLoading) return;
-
- set({ logoLoading: true });
- if (logoAbortController) {
- logoAbortController.abort();
- }
- logoAbortController = new AbortController();
-
- fetch(`${getApiBaseUrl()}/logo`, {
- signal: logoAbortController.signal,
- })
- .then((response) => {
- const contentType = response.headers.get('content-type') ?? '';
- if (response.ok && contentType.startsWith('image/')) {
- return response.blob().then((blob) => {
- revokeLogoObjectUrl();
- logoObjectUrl = URL.createObjectURL(blob);
- set({ logoUrl: logoObjectUrl, logoLoading: false });
- });
- }
- set({ logoUrl: null, logoLoading: false });
- })
- .catch(() => {
- set({ logoUrl: null, logoLoading: false });
- });
- },
setTheme: (theme) => {
applyThemeClass(theme);