feat(frontend): dedupe /public/settings via shared usePublicSettings hook (#325)

Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page
load — 4 from raw-fetch consumers + 3 from React Query consumers using
inconsistent queryKeys. Captured live in Chrome DevTools.

Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts.

Adds:
- frontend/src/hooks/usePublicSettings.ts — single React Query hook,
  60s staleTime, queryKey ['public-settings']. Vitest with mocked api
  proves multi-mount dedup.
- Extended PublicSettings interface with seo_meta_* fields used by
  RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader.

Migrates 19 call sites across 4 risk-ordered rounds:
- Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with
  refetchInterval to preserve maintenance polling), MaintenanceWrapper
  (drops the now-redundant per-route ping; axios interceptor already
  handles 503), AdminHeader.
- Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage,
  AdminLoginPage, MaintenanceMode.
- Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock,
  ReCaptcha, useWatermarkSettings (rips out raw fetch + local state),
  LegalPage.
- Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload,
  CreateEventPage. EventDetailsPage Round D ships in the follow-up
  commit that adds presigned-download UI on the same page.

App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to
later commits — both files mix #325 changes with backend feature work.
This commit is contained in:
Paul Nothaft
2026-04-28 10:01:53 +02:00
parent 46bc894d91
commit 3d4ae4d7e9
23 changed files with 258 additions and 271 deletions
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { usePublicSettings } from '../usePublicSettings';
import { publicSettingsService } from '../../services/publicSettings.service';
vi.mock('../../services/publicSettings.service', () => ({
publicSettingsService: {
getPublicSettings: vi.fn(),
},
}));
const getPublicSettingsMock = vi.mocked(publicSettingsService.getPublicSettings);
function makeWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return { client, Wrapper };
}
describe('usePublicSettings', () => {
beforeEach(() => {
getPublicSettingsMock.mockReset();
});
it('returns settings from the public settings service', async () => {
getPublicSettingsMock.mockResolvedValue({
branding_company_name: 'PicPeak Test',
maintenance_mode: false,
} as Awaited<ReturnType<typeof publicSettingsService.getPublicSettings>>);
const { Wrapper } = makeWrapper();
const { result } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.branding_company_name).toBe('PicPeak Test');
expect(getPublicSettingsMock).toHaveBeenCalledTimes(1);
});
it('dedupes parallel callers in the same QueryClient', async () => {
getPublicSettingsMock.mockResolvedValue({
branding_company_name: 'PicPeak Test',
} as Awaited<ReturnType<typeof publicSettingsService.getPublicSettings>>);
const { Wrapper } = makeWrapper();
const { result: first } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
const { result: second } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
const { result: third } = renderHook(() => usePublicSettings(), { wrapper: Wrapper });
await waitFor(() => {
expect(first.current.isSuccess).toBe(true);
expect(second.current.isSuccess).toBe(true);
expect(third.current.isSuccess).toBe(true);
});
// Single network call regardless of how many components mount the hook.
expect(getPublicSettingsMock).toHaveBeenCalledTimes(1);
});
});
+2 -1
View File
@@ -2,4 +2,5 @@ export * from './useSessionTimeout';
export * from './useOnClickOutside';
export * from './useLocalizedDate';
export * from './useLocalizedTimeAgo';
export * from './usePermission';
export * from './usePermission';
export * from './usePublicSettings';
+2 -9
View File
@@ -1,8 +1,7 @@
import { useTranslation } from 'react-i18next';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
import { de, enUS, ptBR } from 'date-fns/locale';
import { useQuery } from '@tanstack/react-query';
import { publicSettingsService } from '../services/publicSettings.service';
import { usePublicSettings } from './usePublicSettings';
// Convert old date format strings to new date-fns format
const convertDateFormat = (format: string): string => {
@@ -15,13 +14,7 @@ const convertDateFormat = (format: string): string => {
export const useLocalizedDate = () => {
const { i18n } = useTranslation();
// Fetch public settings to get the date format
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => publicSettingsService.getPublicSettings(),
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: 1, // Only retry once to avoid blocking the UI
});
const { data: settings } = usePublicSettings();
const getLocale = () => {
if (i18n.language === 'de') return de;
+18
View File
@@ -0,0 +1,18 @@
import { useQuery, type UseQueryOptions } from '@tanstack/react-query';
import { publicSettingsService, type PublicSettings } from '../services/publicSettings.service';
export const PUBLIC_SETTINGS_QUERY_KEY = ['public-settings'] as const;
type PublicSettingsQueryOptions = Omit<
UseQueryOptions<PublicSettings, Error>,
'queryKey' | 'queryFn'
>;
export function usePublicSettings(options?: PublicSettingsQueryOptions) {
return useQuery<PublicSettings, Error>({
queryKey: PUBLIC_SETTINGS_QUERY_KEY,
queryFn: () => publicSettingsService.getPublicSettings(),
staleTime: 60_000,
...options,
});
}
+7 -24
View File
@@ -1,27 +1,10 @@
import { useState, useEffect } from 'react';
import { api } from '../config/api';
import { usePublicSettings } from './usePublicSettings';
export function useWatermarkSettings() {
const [watermarkEnabled, setWatermarkEnabled] = useState(false);
const [loading, setLoading] = useState(true);
const { data: settings, isLoading } = usePublicSettings();
useEffect(() => {
const fetchSettings = async () => {
try {
// Use public settings endpoint that doesn't require authentication
const response = await api.get('/public/settings');
setWatermarkEnabled(response.data.branding_watermark_enabled || false);
} catch (error) {
console.error('Failed to fetch watermark settings:', error);
// Default to false if we can't fetch settings
setWatermarkEnabled(false);
} finally {
setLoading(false);
}
};
fetchSettings();
}, []);
return { watermarkEnabled, loading };
}
return {
watermarkEnabled: Boolean(settings?.branding_watermark_enabled),
loading: isLoading,
};
}