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:
parent
ab86078ca3
commit
786fe1cc1d
2 changed files with 16 additions and 7 deletions
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue