From 786fe1cc1dfbd4885e1be2ec92b4a8682cfc37b1 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Wed, 30 Sep 2026 10:51:13 +0200 Subject: [PATCH] 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 --- .../src/features/themes/editor/ThemeEditor.tsx | 14 ++++++++++---- .../features/themes/editor/tests/panel.test.tsx | 9 ++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/themes/editor/ThemeEditor.tsx b/frontend/src/features/themes/editor/ThemeEditor.tsx index 297e0d2..0a9fc9f 100644 --- a/frontend/src/features/themes/editor/ThemeEditor.tsx +++ b/frontend/src/features/themes/editor/ThemeEditor.tsx @@ -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 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 }} />
- + ) diff --git a/frontend/src/features/themes/editor/tests/panel.test.tsx b/frontend/src/features/themes/editor/tests/panel.test.tsx index 309fe58..a162e9d 100644 --- a/frontend/src/features/themes/editor/tests/panel.test.tsx +++ b/frontend/src/features/themes/editor/tests/panel.test.tsx @@ -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() const input = container.querySelector('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)) })