Включить 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>
This commit is contained in:
gorbenko.pavel
2026-09-01 17:11:29 +05:00
parent ddce6b4f25
commit c5a443b5d1
27 changed files with 322 additions and 93 deletions
@@ -181,7 +181,12 @@ export function useMatrixEditor(
return () => {
active = false;
};
// load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready/retrySeq.
// load в зависимостях СОЗНАТЕЛЬНО нет: когда матрицу перечитывать —
// решает вызывающий через reloadKey (у него это, например, «выбранный
// сотрудник + id заявки»), а сам load пересоздаётся и от тех данных,
// которые перезагрузки не требуют. С load в массиве маршрут дёргался бы
// на каждое такое изменение. Устаревшего замыкания при этом нет: любой
// повторный запуск (reloadKey/ready/retrySeq) берёт свежий load.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reloadKey, ready, retrySeq]);
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
// Общая машина сохранения заявки для ВСЕХ форм портала.
// Отвечает ТОЛЬКО за транзиентное состояние операции (идёт сохранение / ошибка /
@@ -15,6 +15,7 @@ export interface IRequestSubmit {
/**
* Запустить сохранение. validate (опц.) возвращает текст ошибки или undefined.
* Повторный вызов во время сохранения игнорируется (защита от дабл-клика).
* Ссылка на функцию неизменна — её можно класть в зависимости хуков.
*/
run: (
kind: SubmitKind,
@@ -29,13 +30,17 @@ export function useRequestSubmit(): IRequestSubmit {
const [kind, setKind] = useState<SubmitKind | undefined>(undefined);
const [savedKind, setSavedKind] = useState<SubmitKind | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
// Тот же признак «идёт сохранение», но читаемый синхронно. Нужен, чтобы run
// не зависела от состояния и сохраняла неизменную ссылку: формы кладут её
// (и весь submit) в зависимости эффектов.
const savingRef = useRef<boolean>(false);
const run = async (
const run = useCallback(async (
k: SubmitKind,
save: () => Promise<void>,
validate?: () => string | undefined
): Promise<void> => {
if (saving) {
if (savingRef.current) {
return;
}
setKind(k);
@@ -45,6 +50,7 @@ export function useRequestSubmit(): IRequestSubmit {
return;
}
setError(undefined);
savingRef.current = true;
setSaving(true);
try {
await save();
@@ -52,9 +58,20 @@ export function useRequestSubmit(): IRequestSubmit {
} catch (e) {
setError(`Ошибка сохранения: ${(e as Error).message}`);
} finally {
// require-atomic-updates ловит присваивание после await, но здесь это не
// гонка: savingRef — не производное состояние, а флаг «занято» с одним
// владельцем, и второй вызов run до этой строки просто не проходит.
// eslint-disable-next-line require-atomic-updates
savingRef.current = false;
setSaving(false);
}
};
}, []);
return { saving, kind, savedKind, error, run, setError };
// Объект пересоздаётся только при РЕАЛЬНОЙ смене состояния операции, а не на
// каждом рендере формы: run и setError стабильны, поэтому эффекты, которым
// нужен только setError, не перезапускаются вовсе.
return useMemo(
() => ({ saving, kind, savedKind, error, run, setError }),
[saving, kind, savedKind, error, run]
);
}
@@ -1,4 +1,5 @@
import * as React from 'react';
import { useStableCallback } from './useStableCallback';
// Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск
// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов
@@ -57,15 +58,17 @@ export function useRoutedQuery(
const [input, setInput] = React.useState<string>(routeQuery);
const debounced = useDebouncedQuery(input);
const pushed = React.useRef<string>(routeQuery);
// onQueryChange родитель пересоздаёт на каждом рендере: в зависимостях он
// гонял бы эффект вхолостую, а без него эффект навсегда запомнил бы ПЕРВУЮ
// версию колбэка (а она замкнута на раздел, открытый при монтировании, — и
// запрос уезжал бы не в тот маршрут). Стабильная обёртка снимает и то и другое.
const pushQuery = useStableCallback(onQueryChange);
React.useEffect(() => {
if (debounced === pushed.current) return;
pushed.current = debounced;
onQueryChange(debounced);
// onQueryChange пересоздаётся на каждый рендер родителя — в зависимостях
// он гонял бы эффект вхолостую; важен только сам факт смены debounced.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debounced]);
pushQuery(debounced);
}, [debounced, pushQuery]);
React.useEffect(() => {
if (routeQuery === pushed.current) return;
@@ -0,0 +1,33 @@
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), []);
}