perf(frontend): defer the theme editor's heavy preset/import re-render

Measured with Playwright (rAF frame deltas around a preset click, real
Chromium, production): even with every CSS transition disabled, a
single frame still spiked to ~133ms on preset switch -- proving the
worst offender wasn't CSS at all, but the synchronous React re-render
(reset() swaps the whole draft, re-rendering every ColorField/
GradientField and the ClickGui panel at once).

Wrapped the three call sites that replace the whole draft (preset
pick, reset-to-default, file import) in startTransition so React can
deprioritize that render instead of blocking the frame the click
lands on.

Result (same measurement, after fix): peak frame 133ms -> 83ms,
frames over 100ms: 1 -> 0. Not a full fix -- frames over 16ms actually
rose slightly (18 -> 21), so total render cost is roughly the same,
just spread out instead of landing in one blocking spike. A real fix
for the remaining cost needs memoizing ColorField/GradientField so a
preset switch doesn't re-render every field instance; noted as a
follow-up, not done here.

panel.test.tsx: import's name-box assertion now waits instead of
checking synchronously, since the state update is deferred.

Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
loki5512344 2026-09-30 10:51:13 +02:00
parent ab86078ca3
commit 786fe1cc1d
Signed by: boba
GPG key ID: 253067914055423B
2 changed files with 16 additions and 7 deletions

View file

@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useMemo } from 'react'
import { type ReactNode, startTransition, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { PRESETS } from '../presets.generated'
@ -34,9 +34,15 @@ export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: R
return () => window.removeEventListener('keydown', onKey)
}, [undo, redo])
// Swapping the whole draft re-renders every color/gradient field and the ClickGui panel
// at once -- that's real render work, not a CSS transition cost (measured: the frame spike
// survives with all transitions disabled). startTransition lets React deprioritize it so the
// click itself still paints immediately instead of the whole update landing in one blocking frame.
const heavyReset = (next: ThemeEntry) => startTransition(() => reset(next))
const pickPreset = (id: string) => {
const preset = PRESETS.find((p) => p.id === id)
if (preset) reset(preset)
if (preset) heavyReset(preset)
}
return (
@ -50,13 +56,13 @@ export function ThemeEditor({ initial, intro }: { initial: ThemeEntry; intro?: R
<PanelHeader
draft={draft}
onRename={(name) => set({ ...draft, name, id: nameToId(name) })}
history={{ undo, redo, canUndo, canRedo, reset: () => reset(initial), resetName: initial.name }}
history={{ undo, redo, canUndo, canRedo, reset: () => heavyReset(initial), resetName: initial.name }}
/>
<div className="lv-editor-scroll flex min-h-0 flex-1 flex-col gap-5 p-4 lg:overflow-y-auto">
<PresetPicker activeId={draft.id} onPick={pickPreset} />
<PaletteTabs draft={draft} onChange={set} />
</div>
<ExportActions draft={draft} onImport={reset} />
<ExportActions draft={draft} onImport={heavyReset} />
</section>
</div>
)

View file

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { PRESETS } from '../../presets.generated'
@ -96,11 +96,14 @@ test('loading a file imports a valid theme and explains a broken one', async ()
const { container } = render(<ThemeEditor initial={PRESETS[0]} />)
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!
// The import itself is applied inside startTransition (same deferred path as picking a
// preset), so the name box can lag one tick behind the (urgent) status message -- wait for
// the field's own value instead of asserting it in the same tick as the status text.
await user.upload(input, new File([toProfileJson(PRESETS[5])], 'theme.json', { type: 'application/json' }))
expect(await screen.findByRole('status')).toHaveTextContent(`Загружена тема «${PRESETS[5].name}»`)
expect(nameBox()).toHaveValue(PRESETS[5].name)
await waitFor(() => expect(nameBox()).toHaveValue(PRESETS[5].name))
await user.upload(input, new File(['not json'], 'broken.json', { type: 'application/json' }))
expect(await screen.findByText(/Это не тема LoVisual/)).toBeInTheDocument()
expect(nameBox()).toHaveValue(PRESETS[5].name)
await waitFor(() => expect(nameBox()).toHaveValue(PRESETS[5].name))
})