diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d5f92c..c1d11e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -369,6 +369,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Modal dialogs carried no accessible name, so a screen reader announced them without saying which dialog had opened. The dialog is now linked to its title, and each instance gets its own id so several open dialogs cannot be confused for one another. +### Contributors — theme credits could show an outdated name + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1302](https://github.com/Psychotoxical/psysonic/pull/1302)** + +* **Settings → System → Contributors** read the theme list from a cache that is only refreshed every 12 hours, so a theme author whose name had since been corrected in the store kept showing under the old one, with no way to refresh from that screen. The list now shows the cached names instantly and quietly corrects itself from the store in the background. + ## [1.49.0] - 2026-06-29 diff --git a/src/features/settings/components/SystemTab.tsx b/src/features/settings/components/SystemTab.tsx index 0497a58a..2668fee5 100644 --- a/src/features/settings/components/SystemTab.tsx +++ b/src/features/settings/components/SystemTab.tsx @@ -21,7 +21,7 @@ import { SettingsToggle } from '@/features/settings/components/SettingsToggle'; import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard'; import { BackupSection } from '@/features/settings/components/BackupSection'; import { CONTRIBUTORS, MAINTAINERS, themeContributorsFromRegistry, type ThemeContributor } from '@/config/settingsCredits'; -import { fetchRegistry } from '@/lib/themes/themeRegistry'; +import { revalidateRegistry } from '@/lib/themes/themeRegistry'; export function SystemTab() { const { t } = useTranslation(); @@ -37,15 +37,16 @@ export function SystemTab() { .catch(() => {}); }, []); - // Community theme authors come from the store registry (cached, offline-safe). - // On a first run with no cached registry this simply stays empty. + // Community theme authors come from the store registry. Stale-while-revalidate: + // the cached copy paints immediately (offline-safe), then a background refresh + // corrects it. Reading the plain TTL cache would leave a corrected author + // mis-credited for up to 12 hours, and Credits has no refresh control of its + // own. On a first run with no cached registry this simply stays empty. useEffect(() => { let cancelled = false; - fetchRegistry() - .then(({ registry }) => { - if (!cancelled) setThemeContributors(themeContributorsFromRegistry(registry.themes)); - }) - .catch(() => {}); + void revalidateRegistry(registry => { + if (!cancelled) setThemeContributors(themeContributorsFromRegistry(registry.themes)); + }); return () => { cancelled = true; }; }, []); diff --git a/src/lib/themes/themeRegistry.test.ts b/src/lib/themes/themeRegistry.test.ts index 0235e146..768826e3 100644 --- a/src/lib/themes/themeRegistry.test.ts +++ b/src/lib/themes/themeRegistry.test.ts @@ -3,7 +3,7 @@ * stale-on-error fallback, and malformed-cache tolerance. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { fetchRegistry, getCachedRegistry } from '@/lib/themes/themeRegistry'; +import { fetchRegistry, getCachedRegistry, revalidateRegistry } from '@/lib/themes/themeRegistry'; const CACHE_KEY = 'psysonic_theme_registry_cache'; const NOW = 1_000_000_000; @@ -95,3 +95,55 @@ describe('fetchRegistry', () => { expect(calls[0]).not.toContain('jsdelivr'); }); }); + +describe('revalidateRegistry', () => { + it('serves the cache first, then the fresh copy — the TTL must not hide an update', async () => { + // The exact case this exists for: a cache well inside the TTL, holding a + // registry that has since been corrected upstream. `fetchRegistry` alone + // would return the stale copy and never hit the network. + writeCache(NOW, 'cached'); + vi.stubGlobal('fetch', vi.fn(async () => okRes(reg('corrected')))); + + const seen: string[] = []; + await revalidateRegistry(r => seen.push(r.generatedAt)); + + expect(seen).toEqual(['cached', 'corrected']); + }); + + it('emits once when the fresh copy matches the cache — no pointless re-render', async () => { + writeCache(NOW, 'same'); + vi.stubGlobal('fetch', vi.fn(async () => okRes(reg('same')))); + + const seen: string[] = []; + await revalidateRegistry(r => seen.push(r.generatedAt)); + + expect(seen).toEqual(['same']); + }); + + it('emits once with the network copy when there is no cache', async () => { + vi.stubGlobal('fetch', vi.fn(async () => okRes(reg('first')))); + + const seen: string[] = []; + await revalidateRegistry(r => seen.push(r.generatedAt)); + + expect(seen).toEqual(['first']); + }); + + it('keeps the cached copy when the network fails, and does not emit it twice', async () => { + writeCache(NOW, 'cached'); + vi.stubGlobal('fetch', vi.fn(async () => failRes())); + + const seen: string[] = []; + await revalidateRegistry(r => seen.push(r.generatedAt)); + + expect(seen).toEqual(['cached']); + }); + + it('never rejects and emits nothing when there is no cache and no network', async () => { + vi.stubGlobal('fetch', vi.fn(async () => failRes())); + + const seen: string[] = []; + await expect(revalidateRegistry(r => seen.push(r.generatedAt))).resolves.toBeUndefined(); + expect(seen).toEqual([]); + }); +}); diff --git a/src/lib/themes/themeRegistry.ts b/src/lib/themes/themeRegistry.ts index bd9619db..e8553c66 100644 --- a/src/lib/themes/themeRegistry.ts +++ b/src/lib/themes/themeRegistry.ts @@ -110,6 +110,37 @@ export async function fetchRegistry(opts?: { force?: boolean }): Promise void, +): Promise { + const cached = getCachedRegistry(); + if (cached) onRegistry(cached); + try { + const { registry, stale } = await fetchRegistry({ force: true }); + // `stale` means the network failed and we were handed the cache back — the + // caller already has it. + if (!stale && registry.generatedAt !== cached?.generatedAt) onRegistry(registry); + } catch { + // No cache and no network. Nothing to show, nothing to update. + } +} + /** * Fetch a single theme's CSS text from GitHub raw (repo-relative path). Raw is * used rather than a mutable CDN edge so an install or update always gets the