mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
perf(themes): gate inactive injected theme styles out of style matching (#1315)
* perf(themes): gate inactive injected theme styles out of style matching Inactive installed themes' <style> elements get media="not all", so the browser skips their rules during style recalculation entirely; only the active theme and the scheduler's day/night slots stay live. With many installed themes every recalc (hover, playing-state flips) walked every theme's rules. Switching only flips the media attribute in the same effects flush that sets data-theme - no re-inject, no flash. * docs(changelog): add 1315 theme style-matching entry
This commit is contained in:
@@ -333,6 +333,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.
|
||||
|
||||
### Themes — smooth UI with many themes installed
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by Asra on the Psysonic Discord, PR [#1315](https://github.com/Psychotoxical/psysonic/pull/1315)**
|
||||
|
||||
* With a large number of community themes installed, every hover or playback-state change made the browser re-evaluate the CSS of every installed theme, which could slow the UI to a crawl. Only the active theme (plus the scheduler's day and night picks) participates now; the others stay dormant until applied — switching themes is unaffected.
|
||||
|
||||
|
||||
## [1.49.0] - 2026-06-29
|
||||
|
||||
|
||||
+10
-5
@@ -4,7 +4,7 @@ import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useLyricsStore } from './store/lyricsStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useInstalledThemesStore } from './store/installedThemesStore';
|
||||
import { syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { gateInjectedThemes, syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { useThemeScheduler } from '@/app/hooks/useThemeScheduler';
|
||||
import { useFontStore } from './store/fontStore';
|
||||
import { getWindowKind } from './app/windowKind';
|
||||
@@ -12,9 +12,9 @@ import MiniPlayerApp from './app/MiniPlayerApp';
|
||||
import MainApp from './app/MainApp';
|
||||
|
||||
export default function App() {
|
||||
// Re-subscribe so themeStore changes trigger a re-render (the value itself
|
||||
// is consumed via useThemeScheduler / data-theme attribute below).
|
||||
useThemeStore(s => s.theme);
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
const themeDay = useThemeStore(s => s.themeDay);
|
||||
const themeNight = useThemeStore(s => s.themeNight);
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const buttonSize = useThemeStore(s => s.buttonSize);
|
||||
@@ -31,7 +31,12 @@ export default function App() {
|
||||
// active community theme is painted without a network round-trip.
|
||||
useEffect(() => {
|
||||
syncInjectedThemes(installedThemes);
|
||||
}, [installedThemes]);
|
||||
// Only the active slots participate in style matching (inactive styles
|
||||
// get media="not all" — see gateInjectedThemes). Runs in the same effects
|
||||
// flush as the data-theme attribute below, so a switch paints with both
|
||||
// applied.
|
||||
gateInjectedThemes([theme, themeDay, themeNight, effectiveTheme]);
|
||||
}, [installedThemes, theme, themeDay, themeNight, effectiveTheme]);
|
||||
|
||||
// Dev only: `--theme-watch <theme.css | dir>` (debug builds) pushes local
|
||||
// theme CSS (+ sibling manifest.json metadata) in on every save. Each
|
||||
|
||||
@@ -5,7 +5,7 @@ import { setLoggingMode, setSubsonicWireUserAgent } from '@/lib/api/platformShel
|
||||
import { getWindowKind } from './windowKind';
|
||||
import { migrateThemeSelection } from '@/lib/themes/themeMigration';
|
||||
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
|
||||
import { syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { gateInjectedThemes, syncInjectedThemes } from '@/lib/themes/themeInjection';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
|
||||
|
||||
/** Sync backend HTTP User-Agent from the main webview once at startup. */
|
||||
@@ -69,6 +69,13 @@ export function applyThemeAtStartup(): void {
|
||||
const s = parsed.state;
|
||||
if (!s) return;
|
||||
syncInjectedThemes(readInstalledThemes());
|
||||
// Gate to the active slots right away so the first style recalcs don't
|
||||
// walk every installed theme's rules (App's effect re-gates after mount).
|
||||
gateInjectedThemes([
|
||||
String(s.theme ?? 'mocha'),
|
||||
String(s.themeDay ?? 'latte'),
|
||||
String(s.themeNight ?? 'mocha'),
|
||||
]);
|
||||
// First-frame best effort for the "follow system" mode: the Web media query
|
||||
// is sync here (the native Tauri theme resolves only after mount, when the
|
||||
// App effect re-applies the effective theme). Unreliable on Linux WebKitGTK,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
validateThemeCss,
|
||||
injectTheme,
|
||||
syncInjectedThemes,
|
||||
gateInjectedThemes,
|
||||
} from '@/lib/themes/themeInjection';
|
||||
import type { InstalledTheme } from '@/store/installedThemesStore';
|
||||
|
||||
@@ -120,3 +121,32 @@ describe('syncInjectedThemes', () => {
|
||||
expect(injected()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gateInjectedThemes', () => {
|
||||
const media = (id: string) =>
|
||||
document.head.querySelector<HTMLStyleElement>(`style[${ATTR}="${id}"]`)?.media;
|
||||
|
||||
it('disables inactive styles via media="not all" and keeps active ones on', () => {
|
||||
syncInjectedThemes([mk('a', block('a')), mk('b', block('b')), mk('c', block('c'))]);
|
||||
gateInjectedThemes(['b']);
|
||||
expect(media('a')).toBe('not all');
|
||||
expect(media('b')).toBe('all');
|
||||
expect(media('c')).toBe('not all');
|
||||
});
|
||||
|
||||
it('re-enables a style when its theme becomes active', () => {
|
||||
syncInjectedThemes([mk('a', block('a')), mk('b', block('b'))]);
|
||||
gateInjectedThemes(['a']);
|
||||
gateInjectedThemes(['b']);
|
||||
expect(media('a')).toBe('not all');
|
||||
expect(media('b')).toBe('all');
|
||||
});
|
||||
|
||||
it('keeps every listed slot active (theme + scheduler day/night)', () => {
|
||||
syncInjectedThemes([mk('a', block('a')), mk('b', block('b')), mk('c', block('c'))]);
|
||||
gateInjectedThemes(['a', 'c']);
|
||||
expect(media('a')).toBe('all');
|
||||
expect(media('b')).toBe('not all');
|
||||
expect(media('c')).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,3 +97,25 @@ export function syncInjectedThemes(themes: InstalledTheme[]): void {
|
||||
});
|
||||
for (const theme of themes) injectTheme(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate injected theme styles by activity: only the styles for `activeIds`
|
||||
* (the applied theme plus the scheduler's day/night slots) participate in
|
||||
* style matching — every other installed theme's <style> gets
|
||||
* `media="not all"`, so the browser skips its rules entirely. With many
|
||||
* installed themes (a big collection, or a dev `--theme-watch` checkout
|
||||
* sweep), an ungated head makes every style recalculation — e.g. `:hover`
|
||||
* while moving the mouse across the Themes grid — walk all of their rules;
|
||||
* gating keeps recalc cost independent of the installed count. Switching
|
||||
* themes only flips the attribute: synchronous, no re-inject, no flash.
|
||||
*/
|
||||
export function gateInjectedThemes(activeIds: Iterable<string>): void {
|
||||
const active = new Set(activeIds);
|
||||
document.head
|
||||
.querySelectorAll<HTMLStyleElement>(`style[${ATTR}]`)
|
||||
.forEach((el) => {
|
||||
const id = el.getAttribute(ATTR);
|
||||
const media = id && active.has(id) ? 'all' : 'not all';
|
||||
if (el.media !== media) el.media = media;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user