Files
request-portal/src/webparts/requestPortal/shared/useStableCallback.ts
T
gorbenko.pavel c5a443b5d1 Включить react-hooks/exhaustive-deps и разобрать устаревшие замыкания
Правило было не задано вовсе: плагин eslint-plugin-react-hooks подключает
профиль @microsoft/eslint-config-spfx/lib/profiles/react, но правил из него
не включает. Теперь в .eslintrc.js явно заданы rules-of-hooks (error) и
exhaustive-deps (warn); сборка проходит с нулём предупреждений.

Что реально чинилось, а не затыкалось:

- shared/useStableCallback.ts — обёртка с неизменной ссылкой над колбэком-пропом
  (аналог будущего useEffectEvent). Ссылка стабильна, внутри всегда актуальная
  версия колбэка, поэтому её можно честно класть в зависимости.
- onCodeLoaded во всех forms/*/*View.tsx и *Form.tsx идёт в эффект загрузки
  через эту обёртку: просто дописать проп в зависимости нельзя — заявка
  перезапрашивалась бы на каждый рендер родителя.
- useRequestSubmit: run стабилизирован (useCallback + savingRef вместо чтения
  состояния), возвращаемый объект мемоизирован. Формы берут из него стабильный
  setError и указывают в зависимостях его, а не submit целиком.
- shared/usePersistedQuery.ts (useRoutedQuery) — было устаревшее замыкание:
  эффект навсегда запоминал первую версию onQueryChange, а та замкнута на route
  на момент монтирования, то есть набранный запрос мог уехать в маршрут чужого
  раздела. Теперь зовётся актуальная версия.
- propertyPane/DebugUserField.tsx — то же самое с onResolved.
- Формы: в эффект "новая заявка по умолчанию на себя" дописан context
  (единственный экземпляр на жизнь веб-части, лишних перезапусков не даёт),
  в ReferralForm ещё и мемоизированный approval.
- RequestsTableView: dataKey в зависимости (он и так выводится из view/
  statusFilter/debouncedQuery, которые там уже есть).

Осознанные отступления оставлены с объяснением ПОЧЕМУ: useMatrixEditor (момент
перечитывания матрицы задаёт вызывающий через reloadKey), companiesFieldValue
в RecruitingHire/RecruitingSelection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:11:54 +05:00

34 lines
2.3 KiB
TypeScript

import { useCallback, useLayoutEffect, useRef } from 'react';
/**
* Обёртка вокруг колбэка-пропа с НЕИЗМЕННОЙ ссылкой.
*
* Зачем: колбэк, приходящий сверху пропом (`onCodeLoaded`, `onSaved` и т.п.),
* родитель почти всегда пересоздаёт на каждом рендере. Положить такой колбэк
* в зависимости useEffect нельзя — эффект начнёт перезапускаться на ровном
* месте (для форм портала это означает повторную загрузку заявки с сервера).
* Выкинуть его из зависимостей тоже нельзя — эффект зафиксирует ПЕРВУЮ версию
* колбэка и после смены пропа будет звать устаревшее замыкание.
*
* Хук снимает обе проблемы: наружу отдаётся одна и та же функция на всю жизнь
* компонента, а внутри она всегда вызывает АКТУАЛЬНУЮ версию колбэка. Такую
* обёртку можно честно указывать в зависимостях (см. react-hooks/exhaustive-deps).
*
* Аналог `useEffectEvent` из будущих версий React; ограничен колбэками без
* возвращаемого значения — именно такие у нас в пропах.
*
* ВАЖНО: вызывать её во время РЕНДЕРА нельзя — только из эффектов и
* обработчиков событий, иначе можно прочитать колбэк от прошлого рендера.
*/
export function useStableCallback<TArgs extends unknown[]>(
callback: ((...args: TArgs) => void) | undefined
): (...args: TArgs) => void {
const ref = useRef(callback);
// Обновляем ссылку до того, как отработают обычные (passive) эффекты, —
// так вызов из useEffect гарантированно попадает в свежий колбэк.
useLayoutEffect(() => {
ref.current = callback;
});
return useCallback((...args: TArgs) => ref.current?.(...args), []);
}