perf(frontend): memoize ColorField/GradientField with stable callbacks

React.memo alone does nothing here: PaletteTabs built a fresh
`(hex) => setColor(slot, hex)` closure per field on every render, so
memo's prop comparison always saw a "changed" onChange and re-rendered
anyway. Stabilized it properly:

- setColor/setGradient read draft/onChange from a ref (updated in a
  useLayoutEffect, not during render -- oxlint's react(refs) rule
  correctly flags a ref write in the render body) so they keep a
  stable identity (useCallback, empty deps) across edits
- per-slot wrapper closures are built once in a useMemo keyed off
  those now-stable functions, not off `draft`

This doesn't change the preset-switch case measured earlier (every
field's value genuinely changes then, so memo correctly re-renders
all of them -- that cost is what startTransition already covers). It
does stop sibling fields re-rendering on interactions that don't
touch them: undo/redo, tab switching, hovering unrelated panel
elements.

Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
loki5512344 2026-09-30 10:58:34 +02:00
parent 786fe1cc1d
commit 6fa36f8b32
Signed by: boba
GPG key ID: 253067914055423B
3 changed files with 36 additions and 10 deletions

View file

@ -1,4 +1,4 @@
import { type CSSProperties, useId, useState } from 'react'
import { type CSSProperties, memo, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { argbToCss, parseArgb } from '../../types'
import './fields.css'
@ -16,7 +16,7 @@ const withAlpha = (hex: string, rgb6: string) => `#${pad2(parseArgb(hex).a)}${rg
const withRgb = (hex: string, a: number) => `#${pad2(a)}${toRgb6(hex).slice(1)}`.toUpperCase()
/** One editable palette slot: native color picker + alpha slider + hex field. */
export function ColorField({ label, value, onChange }: Props) {
export const ColorField = memo(function ColorField({ label, value, onChange }: Props) {
const { t: tr } = useTranslation('themes')
const id = useId()
// Local mirror so a partially-typed hex isn't clobbered mid-edit; reset when the value changes
@ -70,4 +70,4 @@ export function ColorField({ label, value, onChange }: Props) {
</span>
</label>
)
}
})

View file

@ -1,3 +1,4 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { cssAngle } from '@features/clickgui/themeVars'
import { argbToCss, type GradientSpec } from '../../types'
@ -9,7 +10,7 @@ type Props = { title: string; value: GradientSpec; onChange: (patch: Partial<Gra
* A gradient slot. Its title bar is the gradient itself (at the mod's angle), so every tweak
* below is visible right where it is made; the switch turns the slot flat or back.
*/
export function GradientField({ title, value, onChange }: Props) {
export const GradientField = memo(function GradientField({ title, value, onChange }: Props) {
const { t } = useTranslation('themes')
const strip = value.enabled
? `linear-gradient(${cssAngle(value.angle)}deg, ${argbToCss(value.start)}, ${argbToCss(value.end)})`
@ -52,4 +53,4 @@ export function GradientField({ title, value, onChange }: Props) {
</div>
</div>
)
}
})

View file

@ -1,4 +1,4 @@
import { type KeyboardEvent, useId, useState } from 'react'
import { type KeyboardEvent, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types'
import { ColorField } from '../fields/ColorField'
@ -24,9 +24,34 @@ export function PaletteTabs({ draft, onChange }: Props) {
const [tab, setTab] = useState<Tab>('colors')
const base = useId()
const setColor = (slot: keyof ThemeColors, hex: string) => onChange({ ...draft, theme: { ...draft.theme, [slot]: hex } })
const setGradient = (key: keyof ThemeEntry, patch: Partial<GradientSpec>) =>
// ColorField/GradientField are memoized so an edit to one slot doesn't re-render the other
// eleven. That only holds if their `onChange` prop is referentially stable too -- a fresh
// `(hex) => setColor(slot, hex)` per render would defeat memo for every field, every time.
// `latest` lets setColor/setGradient stay stable (empty deps) while still reading the
// current draft/onChange; the per-slot wrapper closures are then built once in a `useMemo`
// keyed off those stable functions, not off `draft`.
const latest = useRef({ draft, onChange })
useLayoutEffect(() => {
latest.current = { draft, onChange }
})
const setColor = useCallback((slot: keyof ThemeColors, hex: string) => {
const { draft, onChange } = latest.current
onChange({ ...draft, theme: { ...draft.theme, [slot]: hex } })
}, [])
const setGradient = useCallback((key: keyof ThemeEntry, patch: Partial<GradientSpec>) => {
const { draft, onChange } = latest.current
onChange({ ...draft, [key]: { ...(draft[key] as GradientSpec), ...patch } })
}, [])
const colorHandlers = useMemo(() => {
const entries = GROUPS.flatMap((g) => g.slots).map((slot) => [slot, (hex: string) => setColor(slot, hex)] as const)
return Object.fromEntries(entries) as Record<keyof ThemeColors, (hex: string) => void>
}, [setColor])
const gradientHandlers = useMemo(() => {
const entries = GRADIENT_SLOTS.map((g) => [g.key, (patch: Partial<GradientSpec>) => setGradient(g.key, patch)] as const)
return Object.fromEntries(entries) as Record<(typeof GRADIENT_SLOTS)[number]['key'], (patch: Partial<GradientSpec>) => void>
}, [setGradient])
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
@ -60,7 +85,7 @@ export function PaletteTabs({ draft, onChange }: Props) {
<legend className="lv-editor-group">{t(`editor.group.${group.key}`)}</legend>
<div className="grid gap-x-4 gap-y-2.5 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
{group.slots.map((slot) => (
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={(hex) => setColor(slot, hex)} />
<ColorField key={slot} label={t(`colors.${slot}`)} value={draft.theme[slot]} onChange={colorHandlers[slot]} />
))}
</div>
</fieldset>
@ -73,7 +98,7 @@ export function PaletteTabs({ draft, onChange }: Props) {
key={g.prefix}
title={t(`editor.gradient.${g.prefix}`)}
value={draft[g.key] as GradientSpec}
onChange={(patch) => setGradient(g.key, patch)}
onChange={gradientHandlers[g.key]}
/>
))}
</div>