perf: sprint C — отделить history от IPC state-broadcast

#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, который и закрыт.
This commit is contained in:
AnRil
2026-05-22 01:18:25 +07:00
parent 4745f5e091
commit e7ccca98e7
4 changed files with 54 additions and 17 deletions

View File

@@ -16,6 +16,7 @@ import {
deleteExercise, deleteExercise,
getHistory, getHistory,
getState, getState,
getStateForRenderer,
markDone, markDone,
setGameEnabled, setGameEnabled,
skip, skip,
@@ -56,16 +57,14 @@ import {
export function registerIpc(): void { export function registerIpc(): void {
ipcMain.handle(IPC.getState, () => { ipcMain.handle(IPC.getState, () => {
// Накладываем актуальное значение autostart (источник истины — OS), // Без history (см. getStateForRenderer) и с актуальным значением
// но НЕ мутируем кэш. Раньше прямая мутация state.settings оставляла // autostart из OS — мутацию делаем по копии, не по cache.
// в RAM startWithWindows, отличающийся от persisted-disk-значения, const state = getStateForRenderer()
// и при следующем flush на диск шла OS-правда, а не пользовательский state.settings = {
// toggle. Сейчас возвращаем поверхностную копию. ...state.settings,
const state = getState() startWithWindows: isAutostartEnabled()
return {
...state,
settings: { ...state.settings, startWithWindows: isAutostartEnabled() }
} }
return state
}) })
ipcMain.handle(IPC.addExercise, (_e, input: unknown) => { ipcMain.handle(IPC.addExercise, (_e, input: unknown) => {

View File

@@ -1,9 +1,12 @@
import { BrowserWindow } from 'electron' import { BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import { getExercises, getState, updateExercise } from './store' import { getExercises, getStateForRenderer, updateExercise } from './store'
export function broadcastState(): void { 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()) { for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtStateChanged, state) if (!win.isDestroyed()) win.webContents.send(IPC.evtStateChanged, state)
} }

View File

@@ -17,6 +17,7 @@ import {
GameId, GameId,
HistoryAction, HistoryAction,
HistoryEntry, HistoryEntry,
PersistedState,
SAMPLE_EXERCISES, SAMPLE_EXERCISES,
Settings Settings
} from '@shared/types' } from '@shared/types'
@@ -30,7 +31,7 @@ const HISTORY_MAX = 10_000
const WRITE_DEBOUNCE_MS = 1500 const WRITE_DEBOUNCE_MS = 1500
const WRITE_RETRY_DELAYS = [50, 200, 800] // ms backoff on transient EBUSY/EPERM 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 storePath = ''
let pendingWrite: NodeJS.Timeout | null = null let pendingWrite: NodeJS.Timeout | null = null
@@ -43,7 +44,7 @@ function getStorePath(): string {
return storePath return storePath
} }
function makeInitial(): AppState { function makeInitial(): PersistedState {
const now = Date.now() const now = Date.now()
return { return {
exercises: SAMPLE_EXERCISES.map((e) => ({ exercises: SAMPLE_EXERCISES.map((e) => ({
@@ -144,8 +145,8 @@ function runMigrations(s: StoredState): StoredState {
return cursor return cursor
} }
/** Coerce a (possibly partial) migrated state into a fully-formed AppState. */ /** Coerce a (possibly partial) migrated state into a fully-formed PersistedState. */
function coerce(s: StoredState): AppState { function coerce(s: StoredState): PersistedState {
return { return {
exercises: Array.isArray(s.exercises) ? (s.exercises as Exercise[]) : [], exercises: Array.isArray(s.exercises) ? (s.exercises as Exercise[]) : [],
settings: { settings: {
@@ -162,7 +163,7 @@ function coerce(s: StoredState): AppState {
} }
} }
function load(): AppState { function load(): PersistedState {
const p = getStorePath() const p = getStorePath()
if (!existsSync(p)) { if (!existsSync(p)) {
const initial = makeInitial() const initial = makeInitial()
@@ -325,11 +326,34 @@ function scheduleWrite(): void {
pendingWrite.unref?.() 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() if (!cache) cache = load()
return cache 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 { export function getSettings(): Settings {
return getState().settings return getState().settings
} }

View File

@@ -39,11 +39,22 @@ export type Settings = {
quietHours: QuietHours quietHours: QuietHours
} }
/**
* State, видимое renderer'у (через IPC.getState и evtStateChanged).
* `history` намеренно НЕ включена — она достигает 10k записей × ~50 байт =
* 500KB JSON, и шлать её на каждый markDone/snooze/etc слишком дорого.
* Renderer запрашивает историю отдельно через `getHistory()` IPC (с опц.
* `sinceMs` для инкрементальной подгрузки).
*/
export type AppState = { export type AppState = {
exercises: Exercise[] exercises: Exercise[]
settings: Settings settings: Settings
challenges: Challenge[] challenges: Challenge[]
gamesEnabled: Partial<Record<GameId, boolean>> gamesEnabled: Partial<Record<GameId, boolean>>
}
/** Persisted shape — расширяет AppState историей (живёт только в main). */
export type PersistedState = AppState & {
history?: HistoryEntry[] history?: HistoryEntry[]
} }