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 { useTranslation } from 'react-i18next'
|
||||||
import { argbToCss, parseArgb } from '../../types'
|
import { argbToCss, parseArgb } from '../../types'
|
||||||
import './fields.css'
|
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()
|
const withRgb = (hex: string, a: number) => `#${pad2(a)}${toRgb6(hex).slice(1)}`.toUpperCase()
|
||||||
|
|
||||||
/** One editable palette slot: native color picker + alpha slider + hex field. */
|
/** 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 { t: tr } = useTranslation('themes')
|
||||||
const id = useId()
|
const id = useId()
|
||||||
// Local mirror so a partially-typed hex isn't clobbered mid-edit; reset when the value changes
|
// 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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { memo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cssAngle } from '@features/clickgui/themeVars'
|
import { cssAngle } from '@features/clickgui/themeVars'
|
||||||
import { argbToCss, type GradientSpec } from '../../types'
|
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
|
* 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.
|
* 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 { t } = useTranslation('themes')
|
||||||
const strip = value.enabled
|
const strip = value.enabled
|
||||||
? `linear-gradient(${cssAngle(value.angle)}deg, ${argbToCss(value.start)}, ${argbToCss(value.end)})`
|
? `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>
|
||||||
</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 { useTranslation } from 'react-i18next'
|
||||||
import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types'
|
import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types'
|
||||||
import { ColorField } from '../fields/ColorField'
|
import { ColorField } from '../fields/ColorField'
|
||||||
|
|
@ -24,9 +24,34 @@ export function PaletteTabs({ draft, onChange }: Props) {
|
||||||
const [tab, setTab] = useState<Tab>('colors')
|
const [tab, setTab] = useState<Tab>('colors')
|
||||||
const base = useId()
|
const base = useId()
|
||||||
|
|
||||||
const setColor = (slot: keyof ThemeColors, hex: string) => onChange({ ...draft, theme: { ...draft.theme, [slot]: hex } })
|
// ColorField/GradientField are memoized so an edit to one slot doesn't re-render the other
|
||||||
const setGradient = (key: keyof ThemeEntry, patch: Partial<GradientSpec>) =>
|
// 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 } })
|
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) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
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>
|
<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">
|
<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) => (
|
{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>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
@ -73,7 +98,7 @@ export function PaletteTabs({ draft, onChange }: Props) {
|
||||||
key={g.prefix}
|
key={g.prefix}
|
||||||
title={t(`editor.gradient.${g.prefix}`)}
|
title={t(`editor.gradient.${g.prefix}`)}
|
||||||
value={draft[g.key] as GradientSpec}
|
value={draft[g.key] as GradientSpec}
|
||||||
onChange={(patch) => setGradient(g.key, patch)}
|
onChange={gradientHandlers[g.key]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue