fix(admin-ui): UserPicker — порядок хуков (rules-of-hooks)

Early-return на TextInput-fallback стоял ДО useMemo(triggerLabel) и
useMemo(selected). React падает «rendered fewer hooks than expected»
когда количество вызовов хуков меняется между рендерами.

Reproduce: loading=true первый рендер → ВСЕ хуки выполнились (включая
triggerLabel). loading=false с data=[] второй рендер → fallback
возвращается до triggerLabel useMemo → меньше хуков → error boundary
ловит → юзер видит «Что-то пошло не так».

Перенёс все useMemo выше early-return. Логика рендера без изменений.
This commit is contained in:
Zimin A.N.
2026-05-25 18:13:56 +03:00
parent 5268df0653
commit cc85b3b337
@@ -46,11 +46,23 @@ export function UserPicker({ value, onChange, label, placeholder, disabled }: Us
const fallbackToTextInput = const fallbackToTextInput =
!usersQ.isLoading && !usersQ.isError && usersList.length === 0 !usersQ.isLoading && !usersQ.isError && usersList.length === 0
// Все хуки ДО любого early-return — иначе rules-of-hooks нарушится при
// переходе loading→empty (loading rendered ВСЕ хуки → empty возвращает
// TextInput минуя последующие useMemo). React в production падает на
// "rendered fewer hooks than expected" → error boundary → "Что-то пошло
// не так". Reproduce: loading=true первый рендер, loading=false data=[]
// второй.
const usersBySub = useMemo(() => { const usersBySub = useMemo(() => {
const m = new Map<string, UserListItem>() const m = new Map<string, UserListItem>()
for (const u of usersList) m.set(u.sub, u) for (const u of usersList) m.set(u.sub, u)
return m return m
}, [usersList]) }, [usersList])
const selected = value ? usersBySub.get(value) : undefined
const triggerLabel = useMemo(() => {
if (!value) return placeholder ?? 'Любой'
if (selected) return labelFor(selected)
return `${value.slice(0, 8)}`
}, [value, selected, placeholder])
if (fallbackToTextInput) { if (fallbackToTextInput) {
return ( return (
@@ -65,14 +77,6 @@ export function UserPicker({ value, onChange, label, placeholder, disabled }: Us
) )
} }
const selected = value ? usersBySub.get(value) : undefined
const triggerLabel = useMemo(() => {
if (!value) return placeholder ?? 'Любой'
if (selected) return labelFor(selected)
// Value есть, но юзера нет в кеше — показываем UUID prefix.
return `${value.slice(0, 8)}`
}, [value, selected, placeholder])
// Поиск: substring по preferredUsername / name / email / sub. cmdk // Поиск: substring по preferredUsername / name / email / sub. cmdk
// делает client-side фильтрацию по value атрибуту CommandItem'а; // делает client-side фильтрацию по value атрибуту CommandItem'а;
// концатенируем все поля в один searchable string. // концатенируем все поля в один searchable string.