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)) })