mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
fix(settings): revalidate the registry behind the theme credits (#1302)
* 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)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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; };
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,37 @@ export async function fetchRegistry(opts?: { force?: boolean }): Promise<FetchRe
|
||||
throw new Error('registry fetch failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale-while-revalidate read, for surfaces that must not show outdated data but
|
||||
* must not block on the network either. Hands the cached copy over immediately
|
||||
* (if there is one), then force-fetches and hands over the fresh copy when it
|
||||
* actually differs.
|
||||
*
|
||||
* The TTL path is deliberately bypassed: a copy up to {@link TTL_MS} old is fine
|
||||
* for *browsing* the store, but it is not fine for the Credits list, which
|
||||
* attributes work to a person — a theme author whose handle was corrected
|
||||
* upstream would otherwise stay mis-credited for up to 12 hours, with no way to
|
||||
* refresh from that screen.
|
||||
*
|
||||
* `onRegistry` runs at most twice (cached, then fresh) and never with the same
|
||||
* registry twice. Never rejects: with no cache and no network the caller simply
|
||||
* keeps whatever it had.
|
||||
*/
|
||||
export async function revalidateRegistry(
|
||||
onRegistry: (registry: Registry) => void,
|
||||
): Promise<void> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user