From e7ccca98e73e427e037dedabd5b547b53b2db96a Mon Sep 17 00:00:00 2001 From: AnRil Date: Fri, 22 May 2026 01:18:25 +0700 Subject: [PATCH] =?UTF-8?q?perf:=20sprint=20C=20=E2=80=94=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B8=D1=82=D1=8C=20history=20=D0=BE=D1=82?= =?UTF-8?q?=20IPC=20state-broadcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #9 AppState больше не содержит `history` (вынесено в PersistedState — internal store-shape). `broadcastState()` и IPC.getState шлют через IPC только exercises/settings/challenges/gamesEnabled. Раньше каждый markDone/snooze/toggle вызывал broadcastState() → весь state, включая до 10k history-записей (~500KB JSON), летел через IPC к каждому BrowserWindow и парсился в renderer'е. На долгом горизонте использования становилось заметным лагом UI. Renderer и раньше историю из state не читал (Dashboard вызывает IPC.getHistory отдельно), так что это чистый perf-win без behavioural change. Store-internal mutations продолжают работать с полным PersistedState через `getState()`; renderer-bound IPC использует новый `getStateForRenderer()`. Не сделано из спринта C: zustand setState refactor (#8) — текущая архитектура работает корректно (zustand bathes), `?? []` fallback'и в селекторах безопасны. Реальный gain был от #9, который и закрыт. --- src/main/ipc.ts | 17 ++++++++--------- src/main/state-actions.ts | 7 +++++-- src/main/store.ts | 36 ++++++++++++++++++++++++++++++------ src/shared/types.ts | 11 +++++++++++ 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6fbde3c..fe8569a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -16,6 +16,7 @@ import { deleteExercise, getHistory, getState, + getStateForRenderer, markDone, setGameEnabled, skip, @@ -56,16 +57,14 @@ import { export function registerIpc(): void { ipcMain.handle(IPC.getState, () => { - // Накладываем актуальное значение autostart (источник истины — OS), - // но НЕ мутируем кэш. Раньше прямая мутация state.settings оставляла - // в RAM startWithWindows, отличающийся от persisted-disk-значения, - // и при следующем flush на диск шла OS-правда, а не пользовательский - // toggle. Сейчас возвращаем поверхностную копию. - const state = getState() - return { - ...state, - settings: { ...state.settings, startWithWindows: isAutostartEnabled() } + // Без history (см. getStateForRenderer) и с актуальным значением + // autostart из OS — мутацию делаем по копии, не по cache. + const state = getStateForRenderer() + state.settings = { + ...state.settings, + startWithWindows: isAutostartEnabled() } + return state }) ipcMain.handle(IPC.addExercise, (_e, input: unknown) => { diff --git a/src/main/state-actions.ts b/src/main/state-actions.ts index f46d057..d32671f 100644 --- a/src/main/state-actions.ts +++ b/src/main/state-actions.ts @@ -1,9 +1,12 @@ import { BrowserWindow } from 'electron' import { IPC } from '@shared/ipc' -import { getExercises, getState, updateExercise } from './store' +import { getExercises, getStateForRenderer, updateExercise } from './store' export function broadcastState(): void { - const state = getState() + // Используем variant без `history` — иначе при 10k записей через IPC + // на каждый markDone летит 500KB JSON × M подписчиков. Renderer + // запрашивает историю отдельно через IPC.getHistory. + const state = getStateForRenderer() for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) win.webContents.send(IPC.evtStateChanged, state) } diff --git a/src/main/store.ts b/src/main/store.ts index cf3ed8a..7bb791f 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -17,6 +17,7 @@ import { GameId, HistoryAction, HistoryEntry, + PersistedState, SAMPLE_EXERCISES, Settings } from '@shared/types' @@ -30,7 +31,7 @@ const HISTORY_MAX = 10_000 const WRITE_DEBOUNCE_MS = 1500 const WRITE_RETRY_DELAYS = [50, 200, 800] // ms backoff on transient EBUSY/EPERM -let cache: AppState | null = null +let cache: PersistedState | null = null let storePath = '' let pendingWrite: NodeJS.Timeout | null = null @@ -43,7 +44,7 @@ function getStorePath(): string { return storePath } -function makeInitial(): AppState { +function makeInitial(): PersistedState { const now = Date.now() return { exercises: SAMPLE_EXERCISES.map((e) => ({ @@ -144,8 +145,8 @@ function runMigrations(s: StoredState): StoredState { return cursor } -/** Coerce a (possibly partial) migrated state into a fully-formed AppState. */ -function coerce(s: StoredState): AppState { +/** Coerce a (possibly partial) migrated state into a fully-formed PersistedState. */ +function coerce(s: StoredState): PersistedState { return { exercises: Array.isArray(s.exercises) ? (s.exercises as Exercise[]) : [], settings: { @@ -162,7 +163,7 @@ function coerce(s: StoredState): AppState { } } -function load(): AppState { +function load(): PersistedState { const p = getStorePath() if (!existsSync(p)) { const initial = makeInitial() @@ -325,11 +326,34 @@ function scheduleWrite(): void { pendingWrite.unref?.() } -export function getState(): AppState { +/** + * Internal persisted state — единственный source of truth. Включает историю. + * Mutate напрямую (mutations внутри store.ts), затем scheduleWrite(). + */ +export function getState(): PersistedState { if (!cache) cache = load() return cache } +/** + * State для отправки renderer'у. Копия БЕЗ `history` — историю renderer + * запрашивает отдельным IPC.getHistory. Раньше каждый markDone/snooze + * отправлял весь state через evtStateChanged, и при 10k entries в истории + * это 500KB JSON × N IPC mutations подряд → заметный лаг. + * + * Возвращаемая копия безопасна для мутации (ipc.ts накладывает на settings + * актуальное OS-значение startWithWindows) — мы НЕ мутируем cache. + */ +export function getStateForRenderer(): AppState { + const p = getState() + return { + exercises: p.exercises, + settings: p.settings, + challenges: p.challenges, + gamesEnabled: p.gamesEnabled + } +} + export function getSettings(): Settings { return getState().settings } diff --git a/src/shared/types.ts b/src/shared/types.ts index bc70f6c..fb2e405 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -39,11 +39,22 @@ export type Settings = { quietHours: QuietHours } +/** + * State, видимое renderer'у (через IPC.getState и evtStateChanged). + * `history` намеренно НЕ включена — она достигает 10k записей × ~50 байт = + * 500KB JSON, и шлать её на каждый markDone/snooze/etc слишком дорого. + * Renderer запрашивает историю отдельно через `getHistory()` IPC (с опц. + * `sinceMs` для инкрементальной подгрузки). + */ export type AppState = { exercises: Exercise[] settings: Settings challenges: Challenge[] gamesEnabled: Partial> +} + +/** Persisted shape — расширяет AppState историей (живёт только в main). */ +export type PersistedState = AppState & { history?: HistoryEntry[] }