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:
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user