From 0a52e875a28093d7f90c0a390de6fce51c11b70b Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:26:41 +0200 Subject: [PATCH] fix(settings): revalidate the registry behind the theme credits (#1302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): revalidate the registry behind the theme credits Credits read the theme registry through the plain TTL cache, so a copy up to 12 hours old was served without ever touching the network. That is fine for browsing the store, but Credits attributes work to a person: an author whose handle is corrected upstream stayed mis-credited until the cache aged out, and Credits has no refresh control of its own. Add `revalidateRegistry` — stale-while-revalidate. The cached copy paints immediately (still offline-safe), a forced fetch runs in the background, and the list updates only when the registry actually changed. * docs(changelog): note theme credits revalidation fix (#1302) --- CHANGELOG.md | 6 +++ .../settings/components/SystemTab.tsx | 17 +++--- src/lib/themes/themeRegistry.test.ts | 54 ++++++++++++++++++- src/lib/themes/themeRegistry.ts | 31 +++++++++++ 4 files changed, 99 insertions(+), 9 deletions(-) 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