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