From 0cd8998dc9ecd2e083fa97a78a69d24432a3dc06 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 01:39:39 +0200 Subject: [PATCH] =?UTF-8?q?refactor(app):=20Phase=20B.1=20=E2=80=94=20extr?= =?UTF-8?q?act=20pre-React=20bootstrap=20into=20src/app/=20(#555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.tsx shrinks from 56 -> 17 LOC. New module surface: - src/app/windowKind.ts: cached getWindowKind() detector, replaces the global __PSY_WINDOW_LABEL__ string everywhere - src/app/bootstrap.ts: pushUserAgentToBackend + pushLoggingModeToBackend + runPreReactBootstrap orchestrator App.tsx + playerStore.ts now read getWindowKind() instead of poking window.__PSY_WINDOW_LABEL__ directly. Behaviour-preserving. --- .github/frontend-hot-path-files.txt | 4 + src/App.tsx | 7 +- src/app/bootstrap.test.ts | 145 ++++++++++++++++++++++++++++ src/app/bootstrap.ts | 45 +++++++++ src/app/windowKind.test.ts | 66 +++++++++++++ src/app/windowKind.ts | 30 ++++++ src/main.tsx | 43 +-------- src/store/playerStore.ts | 4 +- 8 files changed, 298 insertions(+), 46 deletions(-) create mode 100644 src/app/bootstrap.test.ts create mode 100644 src/app/bootstrap.ts create mode 100644 src/app/windowKind.test.ts create mode 100644 src/app/windowKind.ts diff --git a/.github/frontend-hot-path-files.txt b/.github/frontend-hot-path-files.txt index 620b436d..0613bd21 100644 --- a/.github/frontend-hot-path-files.txt +++ b/.github/frontend-hot-path-files.txt @@ -38,5 +38,9 @@ src/utils/resolveReplayGainDb.ts src/utils/songToTrack.ts src/utils/buildInfiniteQueueCandidates.ts +# ── Phase B.1: pre-React bootstrap + window-kind detector (2026-05-12) ── +src/app/windowKind.ts +src/app/bootstrap.ts + # ── stores (added as their tests grew past the floor) ──────────────── src/store/previewStore.ts diff --git a/src/App.tsx b/src/App.tsx index f50309ef..5bec5016 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -112,6 +112,7 @@ import ZipDownloadOverlay from './components/ZipDownloadOverlay'; import FpsOverlay from './components/FpsOverlay'; import PasteClipboardHandler from './components/PasteClipboardHandler'; import { usePerfProbeFlags } from './utils/perfFlags'; +import { getWindowKind } from './app/windowKind'; const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed'; @@ -1274,9 +1275,9 @@ export default function App() { // Mini Player window: detected via Tauri window label. Rendered without // router / sidebar / full audio listeners — it just listens for state + sends - // control events. Label is read synchronously from a global set in main.tsx - // so the initial render picks the right tree. - const isMiniWindow = typeof window !== 'undefined' && (window as any).__PSY_WINDOW_LABEL__ === 'mini'; + // control events. Cached synchronously by `getWindowKind()` so the initial + // render picks the right tree without flicker. + const isMiniWindow = getWindowKind() === 'mini'; useEffect(() => { document.documentElement.setAttribute('data-theme', effectiveTheme); diff --git a/src/app/bootstrap.test.ts b/src/app/bootstrap.test.ts new file mode 100644 index 00000000..86ef0140 --- /dev/null +++ b/src/app/bootstrap.test.ts @@ -0,0 +1,145 @@ +/** + * Tests for the pre-React bootstrap orchestrator. + * + * Each step is wrapped in a try/catch so a non-Tauri runtime never breaks + * startup; the tests pin both the happy path (invoke called with the right + * payload) and the failure-tolerant path (no throw when invoke or storage + * misbehaves). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +vi.mock('../store/playerStore', () => ({ + installQueueUndoHotkey: vi.fn(), +})); + +vi.mock('./windowKind', () => ({ + getWindowKind: vi.fn(() => 'main'), +})); + +import { invoke } from '@tauri-apps/api/core'; +import { installQueueUndoHotkey } from '../store/playerStore'; +import { getWindowKind } from './windowKind'; +import { + pushLoggingModeToBackend, + pushUserAgentToBackend, + runPreReactBootstrap, +} from './bootstrap'; + +const ORIGINAL_USER_AGENT = window.navigator.userAgent; + +function setUserAgent(ua: string): void { + Object.defineProperty(window.navigator, 'userAgent', { + value: ua, + configurable: true, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(invoke).mockResolvedValue(undefined); + vi.mocked(getWindowKind).mockReturnValue('main'); + localStorage.clear(); + setUserAgent('psysonic-test-ua/1.0'); +}); + +afterEach(() => { + setUserAgent(ORIGINAL_USER_AGENT); +}); + +describe('pushUserAgentToBackend', () => { + it('forwards the navigator UA to the backend on the main window', () => { + pushUserAgentToBackend(); + expect(invoke).toHaveBeenCalledWith('set_subsonic_wire_user_agent', { + userAgent: 'psysonic-test-ua/1.0', + windowLabel: 'main', + }); + }); + + it('skips the call entirely when the current window is the mini player', () => { + vi.mocked(getWindowKind).mockReturnValue('mini'); + pushUserAgentToBackend(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('skips when the navigator UA is empty', () => { + setUserAgent(''); + pushUserAgentToBackend(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('does not throw when invoke rejects (non-Tauri runtime)', () => { + vi.mocked(invoke).mockRejectedValue(new Error('not tauri')); + expect(() => pushUserAgentToBackend()).not.toThrow(); + }); +}); + +describe('pushLoggingModeToBackend', () => { + it('forwards a valid persisted loggingMode to set_logging_mode', () => { + localStorage.setItem( + 'psysonic-auth', + JSON.stringify({ state: { loggingMode: 'debug' } }), + ); + pushLoggingModeToBackend(); + expect(invoke).toHaveBeenCalledWith('set_logging_mode', { mode: 'debug' }); + }); + + it.each(['off', 'normal', 'debug'])('accepts mode=%s', mode => { + localStorage.setItem('psysonic-auth', JSON.stringify({ state: { loggingMode: mode } })); + pushLoggingModeToBackend(); + expect(invoke).toHaveBeenCalledWith('set_logging_mode', { mode }); + }); + + it('skips when no psysonic-auth entry is present', () => { + pushLoggingModeToBackend(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('skips an unknown mode value', () => { + localStorage.setItem( + 'psysonic-auth', + JSON.stringify({ state: { loggingMode: 'verbose' } }), + ); + pushLoggingModeToBackend(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('skips when state.loggingMode is missing', () => { + localStorage.setItem('psysonic-auth', JSON.stringify({ state: {} })); + pushLoggingModeToBackend(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('does not throw on malformed JSON', () => { + localStorage.setItem('psysonic-auth', '{not json'); + expect(() => pushLoggingModeToBackend()).not.toThrow(); + expect(invoke).not.toHaveBeenCalled(); + }); +}); + +describe('runPreReactBootstrap', () => { + it('runs all steps in order: warm window-kind cache, push UA, push logging mode, install hotkey', () => { + localStorage.setItem( + 'psysonic-auth', + JSON.stringify({ state: { loggingMode: 'normal' } }), + ); + + runPreReactBootstrap(); + + expect(getWindowKind).toHaveBeenCalled(); + expect(invoke).toHaveBeenCalledWith('set_subsonic_wire_user_agent', expect.any(Object)); + expect(invoke).toHaveBeenCalledWith('set_logging_mode', { mode: 'normal' }); + expect(installQueueUndoHotkey).toHaveBeenCalledTimes(1); + }); + + it('still installs the hotkey on the mini window even though UA push is skipped', () => { + vi.mocked(getWindowKind).mockReturnValue('mini'); + runPreReactBootstrap(); + expect(installQueueUndoHotkey).toHaveBeenCalledTimes(1); + // installQueueUndoHotkey itself early-returns inside playerStore on mini — + // the bootstrap doesn't second-guess that decision. + }); +}); diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts new file mode 100644 index 00000000..42a6ea0e --- /dev/null +++ b/src/app/bootstrap.ts @@ -0,0 +1,45 @@ +import { invoke } from '@tauri-apps/api/core'; +import { installQueueUndoHotkey } from '../store/playerStore'; +import { getWindowKind } from './windowKind'; + +/** Sync backend HTTP User-Agent from the main webview once at startup. */ +export function pushUserAgentToBackend(): void { + try { + if (getWindowKind() !== 'main') return; + const ua = window.navigator.userAgent?.trim(); + if (ua) { + void invoke('set_subsonic_wire_user_agent', { userAgent: ua, windowLabel: 'main' }); + } + } catch { + // Ignore in non-Tauri runtimes. + } +} + +/** + * Push the persisted logging mode to Rust before React mounts. Zustand rehydrate + * runs after first paint; AppShell's useEffect can miss the user's persisted + * `loggingMode` until then — but waveform/audio may already run. Matches the + * `psysonic-auth` localStorage key. + */ +export function pushLoggingModeToBackend(): void { + try { + const raw = localStorage.getItem('psysonic-auth'); + if (!raw) return; + const parsed = JSON.parse(raw) as { state?: { loggingMode?: string } }; + const mode = parsed.state?.loggingMode; + if (mode === 'off' || mode === 'normal' || mode === 'debug') { + void invoke('set_logging_mode', { mode }); + } + } catch { + // Ignore parse / non-Tauri. + } +} + +/** Orchestrates everything that must run before React mounts. */ +export function runPreReactBootstrap(): void { + // Pre-warm the window-kind cache so subsequent reads are sync + safe. + getWindowKind(); + pushUserAgentToBackend(); + pushLoggingModeToBackend(); + installQueueUndoHotkey(); +} diff --git a/src/app/windowKind.test.ts b/src/app/windowKind.test.ts new file mode 100644 index 00000000..3e3f473c --- /dev/null +++ b/src/app/windowKind.test.ts @@ -0,0 +1,66 @@ +/** + * Tests for the cached Tauri window-kind detector. + * + * The cache is module-scoped, so each test must reset it via the + * `_resetWindowKindCacheForTest()` escape hatch — otherwise the first call + * locks the value for the rest of the file. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: vi.fn(), +})); + +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { _resetWindowKindCacheForTest, getWindowKind } from './windowKind'; + +beforeEach(() => { + _resetWindowKindCacheForTest(); + vi.clearAllMocks(); +}); + +afterEach(() => { + _resetWindowKindCacheForTest(); +}); + +describe('getWindowKind', () => { + it('returns "main" when the current window label is "main"', () => { + vi.mocked(getCurrentWindow).mockReturnValue({ label: 'main' } as ReturnType); + expect(getWindowKind()).toBe('main'); + }); + + it('returns "mini" when the current window label is "mini"', () => { + vi.mocked(getCurrentWindow).mockReturnValue({ label: 'mini' } as ReturnType); + expect(getWindowKind()).toBe('mini'); + }); + + it('falls back to "main" for any other label', () => { + vi.mocked(getCurrentWindow).mockReturnValue({ label: 'something-else' } as ReturnType); + expect(getWindowKind()).toBe('main'); + }); + + it('falls back to "main" when getCurrentWindow throws (non-Tauri runtime)', () => { + vi.mocked(getCurrentWindow).mockImplementation(() => { + throw new Error('not in tauri'); + }); + expect(getWindowKind()).toBe('main'); + }); + + it('caches the first result and does not call getCurrentWindow again', () => { + vi.mocked(getCurrentWindow).mockReturnValue({ label: 'mini' } as ReturnType); + expect(getWindowKind()).toBe('mini'); + expect(getWindowKind()).toBe('mini'); + expect(getWindowKind()).toBe('mini'); + expect(getCurrentWindow).toHaveBeenCalledTimes(1); + }); + + it('re-reads after the cache is reset', () => { + vi.mocked(getCurrentWindow).mockReturnValue({ label: 'main' } as ReturnType); + expect(getWindowKind()).toBe('main'); + + _resetWindowKindCacheForTest(); + vi.mocked(getCurrentWindow).mockReturnValue({ label: 'mini' } as ReturnType); + expect(getWindowKind()).toBe('mini'); + expect(getCurrentWindow).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/app/windowKind.ts b/src/app/windowKind.ts new file mode 100644 index 00000000..c1828485 --- /dev/null +++ b/src/app/windowKind.ts @@ -0,0 +1,30 @@ +import { getCurrentWindow } from '@tauri-apps/api/window'; + +export type WindowKind = 'main' | 'mini'; + +let cached: WindowKind | null = null; + +/** + * Tauri window-label detection, cached after the first call. The result + * decides whether App() renders the full main UI tree or the standalone + * mini-player tree, and it must be consistent for the lifetime of the + * webview — Tauri never changes a window's label. + * + * Falls back to 'main' in non-Tauri environments (jsdom, plain browser). + */ +export function getWindowKind(): WindowKind { + if (cached !== null) return cached; + let label: string; + try { + label = getCurrentWindow().label; + } catch { + label = 'main'; + } + cached = label === 'mini' ? 'mini' : 'main'; + return cached; +} + +/** Test-only: clears the cached window kind so each test starts clean. */ +export function _resetWindowKindCacheForTest(): void { + cached = null; +} diff --git a/src/main.tsx b/src/main.tsx index e94c6bdc..1454ab96 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,53 +1,14 @@ import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; -import { invoke } from '@tauri-apps/api/core'; -import { getCurrentWindow } from '@tauri-apps/api/window'; import App from './App'; -import { installQueueUndoHotkey } from './store/playerStore'; +import { runPreReactBootstrap } from './app/bootstrap'; import './i18n'; import './styles/theme.css'; import './styles/layout.css'; import './styles/components.css'; import './styles/tracks.css'; -// Expose the Tauri window label synchronously so App() can pick its root -// component (main app vs mini player) on first render without flicker. -try { - (window as any).__PSY_WINDOW_LABEL__ = getCurrentWindow().label; -} catch { - (window as any).__PSY_WINDOW_LABEL__ = 'main'; -} - -// Sync backend HTTP User-Agent from the main webview once at startup. -try { - const windowLabel = (window as any).__PSY_WINDOW_LABEL__ ?? 'main'; - if (windowLabel === 'main') { - const ua = window.navigator.userAgent?.trim(); - if (ua) { - void invoke('set_subsonic_wire_user_agent', { userAgent: ua, windowLabel }); - } - } -} catch { - // Ignore in non-Tauri runtimes. -} - -// Zustand rehydrate runs after first paint; AppShell's useEffect can miss the -// user's persisted `loggingMode` until then — but waveform/audio may already -// run. Push persisted mode to Rust before React mounts (matches `psysonic-auth`). -try { - const raw = localStorage.getItem('psysonic-auth'); - if (raw) { - const parsed = JSON.parse(raw) as { state?: { loggingMode?: string } }; - const mode = parsed.state?.loggingMode; - if (mode === 'off' || mode === 'normal' || mode === 'debug') { - void invoke('set_logging_mode', { mode }); - } - } -} catch { - // Ignore parse / non-Tauri. -} - -installQueueUndoHotkey(); +runPreReactBootstrap(); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 00b1d594..122fb3ec 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -24,6 +24,7 @@ import { resolveReplayGainDb } from '../utils/resolveReplayGainDb'; import { shuffleArray } from '../utils/shuffleArray'; import { songToTrack } from '../utils/songToTrack'; import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates'; +import { getWindowKind } from '../app/windowKind'; // Re-export for backward compatibility with the ~30 call sites that still // import these helpers from playerStore. Phase E (store splits) will migrate @@ -3590,8 +3591,7 @@ export function installQueueUndoHotkey(): void { if (typeof window === 'undefined') return; const w = window as unknown as Record; if (w[QUEUE_UNDO_HOTKEY_FLAG]) return; - const label = w.__PSY_WINDOW_LABEL__; - if (label === 'mini') return; + if (getWindowKind() === 'mini') return; w[QUEUE_UNDO_HOTKEY_FLAG] = true; document.addEventListener( 'keydown',