From c5a443b5d1230e19b96e1fc5fc176e21039afd90 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 1 Sep 2026 17:11:29 +0500 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20react-hooks/exhaustive-deps=20=D0=B8=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BE=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D1=8B=D0=BA=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правило было не задано вовсе: плагин 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 --- .eslintrc.js | 16 +++++++++ .../features/myRequests/RequestsTableView.tsx | 2 +- .../forms/Clearance/ClearanceForm.tsx | 16 ++++++--- .../forms/Clearance/ClearanceView.tsx | 10 ++++-- .../forms/Dismissal/DismissalForm.tsx | 21 ++++++++---- .../forms/Dismissal/DismissalView.tsx | 10 ++++-- .../RecruitingHire/RecruitingHireForm.tsx | 15 +++++++-- .../RecruitingHire/RecruitingHireView.tsx | 10 ++++-- .../RecruitingSelectionForm.tsx | 21 ++++++++---- .../RecruitingSelectionView.tsx | 10 ++++-- .../forms/Referral/ReferralForm.tsx | 22 +++++++++---- .../forms/Referral/ReferralView.tsx | 10 ++++-- .../forms/Ticket1C/Ticket1CForm.tsx | 21 ++++++++---- .../forms/Ticket1C/Ticket1CView.tsx | 10 ++++-- .../forms/TmcTransfer/TmcTransferForm.tsx | 15 +++++++-- .../forms/TmcTransfer/TmcTransferView.tsx | 10 ++++-- .../forms/UnpaidLeave/UnpaidLeaveForm.tsx | 21 ++++++++---- .../forms/UnpaidLeave/UnpaidLeaveView.tsx | 10 ++++-- .../forms/Vacation/VacationForm.tsx | 21 ++++++++---- .../forms/Vacation/VacationView.tsx | 10 ++++-- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 24 ++++++++++---- .../WorkplaceSetup/WorkplaceSetupView.tsx | 10 ++++-- .../propertyPane/DebugUserField.tsx | 20 ++++++----- .../shared/approval/useMatrixEditor.ts | 7 +++- .../shared/ui/useRequestSubmit.ts | 27 ++++++++++++--- .../requestPortal/shared/usePersistedQuery.ts | 13 +++++--- .../requestPortal/shared/useStableCallback.ts | 33 +++++++++++++++++++ 27 files changed, 322 insertions(+), 93 deletions(-) create mode 100644 src/webparts/requestPortal/shared/useStableCallback.ts diff --git a/.eslintrc.js b/.eslintrc.js index cb42e16..ae383bb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -290,6 +290,22 @@ module.exports = { // Rationale to disable: !!{} 'no-extra-boolean-cast': 0, // ==================================================================== + // eslint-plugin-react-hooks (плагин подключён профилем + // @microsoft/eslint-config-spfx/lib/profiles/react, но правил он не задаёт) + // ==================================================================== + // RATIONALE: Хуки нельзя звать условно/в циклах — иначе React + // перепутает состояние между рендерами. + 'react-hooks/rules-of-hooks': 2, + // RATIONALE: Неполный массив зависимостей useEffect/useCallback/ + // useMemo — это устаревшее замыкание: эффект продолжает + // видеть старые пропсы и state. Держим предупреждением: + // там, где отступление осознанное (например, эффект + // должен сработать только на смену id, а не на каждое + // изменение колбэка), рядом стоит + // // eslint-disable-next-line react-hooks/exhaustive-deps + // с объяснением причины. + 'react-hooks/exhaustive-deps': 1, + // ==================================================================== // @microsoft/eslint-plugin-spfx // ==================================================================== '@microsoft/spfx/no-require-ensure': 2, diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index bab5da9..87396d5 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -477,7 +477,7 @@ export const RequestsTableView: React.FC = ({ unsubscribe(); window.clearInterval(timer); }; - }, [context, types, view, statusFilter, debouncedQuery, page]); + }, [context, types, view, statusFilter, debouncedQuery, page, dataKey]); // Все ветки отдают уже нарезанную страницу — просто показываем как есть. const q = debouncedQuery.trim(); diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index f7a61c0..1f61330 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { saveClearanceRequest, clearanceApprovalService } from './submit'; import { loadClearanceRequest } from './load'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const ClearanceForm: React.FC = ({ requestType, @@ -53,6 +54,14 @@ const ClearanceForm: React.FC = ({ const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); + // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффектов идёт + // стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при + // каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + // submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене + // saving/error), а setError стабилен — его и берём. + const { setError: setSubmitError } = submit; const readOnly = !isEditableStatus(requestStatus); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); @@ -78,19 +87,18 @@ const ClearanceForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай: // обходной лист оформляют в день увольнения). При открытии на редактирование diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx index 22a8470..447e52a 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx @@ -31,6 +31,7 @@ import { } from './submit'; import { canEditHrRecordsFields, canEditParkingField } from './approverRights'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface IClearanceViewProps { context: WebPartContext; @@ -49,6 +50,11 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error'; // заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения, // отдел подбора — парковочная карта (см. approverRights.ts). const ClearanceView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки + // идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы + // при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -115,7 +121,7 @@ const ClearanceView: React.FC = ({ context, itemId, formKey setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setError(null); setLoading(false); setLockedWriteOffs( @@ -132,7 +138,7 @@ const ClearanceView: React.FC = ({ context, itemId, formKey return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); // Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя. // isDelegator намеренно не даёт прав: делегировавший не решает за делегата diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx index 0503374..2be4313 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx @@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS, IOrganizat import { saveDismissalRequest, dismissalApprovalService } from './submit'; import { loadDismissalRequest } from './load'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const DismissalForm: React.FC = ({ requestType, @@ -53,6 +54,14 @@ const DismissalForm: React.FC = ({ const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); + // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффектов идёт + // стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при + // каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + // submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене + // saving/error), а setError стабилен — его и берём. + const { setError: setSubmitError } = submit; const readOnly = !isEditableStatus(requestStatus); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); @@ -82,19 +91,18 @@ const DismissalForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); /** Employee/ByEmail → таблица организаций (см. IOrganizationRecord). */ const toOrganization = (records: IEmployeeOrganization[]): IOrganizationRecord[] => @@ -124,8 +132,9 @@ const DismissalForm: React.FC = ({ return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + // context — единственный экземпляр WebPartContext на всё время жизни + // веб-части, так что перезапуска эффекта эта зависимость не добавляет. + }, [editId, context]); // Выбор другого сотрудника подтягивает его таблицу организаций. const handleEmployeeChange = (login: string): void => { diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx index f8cdaba..9d897c2 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx @@ -29,6 +29,7 @@ import { dismissalApprovalService, updateAssetsAcceptor } from './submit'; import { loadClearanceRequest } from '../Clearance/load'; import { ensureClearanceForDismissal } from './createClearance'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; /** * Статусы обходного листа, при которых его СОГЛАСОВАНИЕ уже пройдено целиком @@ -56,6 +57,11 @@ interface IDismissalViewProps { // Переиспользует ту же секцию, что и форма подачи; редактирование выключено через // — поля сами становятся неизменяемыми. const DismissalView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки + // идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы + // при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -94,7 +100,7 @@ const DismissalView: React.FC = ({ context, itemId, formKey setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setError(null); setLoading(false); } @@ -108,7 +114,7 @@ const DismissalView: React.FC = ({ context, itemId, formKey return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); // Печать заявления запрещена до согласования ПАРНОГО обходного листа: сначала // должны отработать/подтвердиться все связанные с увольнением отметки (карты, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 44129b5..94287db 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -60,6 +60,7 @@ import { CandidateSection } from './sections/CandidateSection'; import { PpeSection } from './sections/PpeSection'; import { ConditionsSection } from './sections/ConditionsSection'; import { AgencySection } from './sections/AgencySection'; +import { useStableCallback } from '../../shared/useStableCallback'; const RecruitingHireForm: React.FC = ({ requestType, @@ -83,6 +84,14 @@ const RecruitingHireForm: React.FC = ({ const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); + // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффектов идёт + // стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при + // каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + // submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене + // saving/error), а setError стабилен — его и берём. + const { setError: setSubmitError } = submit; const readOnly = !isEditableStatus(requestStatus); @@ -101,19 +110,19 @@ const RecruitingHireForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); setExistingAttachments(atts); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Маршрут согласования (планируемый) резолвится по email инициатора — текущего // пользователя (кандидата ещё нет в системе). У заявки, открытой на diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 93f0267..67c8f01 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -33,6 +33,7 @@ import { CandidateSection } from './sections/CandidateSection'; import { PpeSection } from './sections/PpeSection'; import { ConditionsSection } from './sections/ConditionsSection'; import { AgencySection } from './sections/AgencySection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface IRecruitingHireViewProps { context: WebPartContext; @@ -56,6 +57,11 @@ const NOOP_SET: SetField = () => undefined; // Переиспользует те же секции, что и форма подачи; редактирование выключено // через — поля сами становятся неизменяемыми. const RecruitingHireView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки + // идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы + // при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -116,7 +122,7 @@ const RecruitingHireView: React.FC = ({ context, itemI setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -131,7 +137,7 @@ const RecruitingHireView: React.FC = ({ context, itemI return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); if (loading) { return
Загрузка заявки…
; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index ef1a087..c7565ff 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -52,6 +52,7 @@ import { DepartmentSection } from './sections/DepartmentSection'; import { VacancySection } from './sections/VacancySection'; import { HealthTripsSection } from './sections/HealthTripsSection'; import { ConditionsSection } from './sections/ConditionsSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const RecruitingSelectionForm: React.FC = ({ requestType, @@ -69,6 +70,14 @@ const RecruitingSelectionForm: React.FC = ({ const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); + // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффектов идёт + // стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при + // каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + // submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене + // saving/error), а setError стабилен — его и берём. + const { setError: setSubmitError } = submit; const readOnly = !isEditableStatus(requestStatus); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); @@ -86,20 +95,19 @@ const RecruitingSelectionForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); setExistingAttachments(atts); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Компании ответственного (Employee/ByEmail.company/companyId) → предзаполнение // «1.6 Организация» (Companies/CompanyPickerMulti). companyId из Employee/ByEmail @@ -132,8 +140,9 @@ const RecruitingSelectionForm: React.FC = ({ return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + // context — единственный экземпляр WebPartContext на всё время жизни + // веб-части, так что перезапуска эффекта эта зависимость не добавляет. + }, [editId, context]); // Выбор другого ответственного подтягивает его подразделение и организацию. const handleResponsibleChange = (login: string): void => { diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index d0d8e61..8ad9098 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -29,6 +29,7 @@ import { DepartmentSection } from './sections/DepartmentSection'; import { VacancySection } from './sections/VacancySection'; import { HealthTripsSection } from './sections/HealthTripsSection'; import { ConditionsSection } from './sections/ConditionsSection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface IRecruitingSelectionViewProps { context: WebPartContext; @@ -47,6 +48,11 @@ const NOOP_SET: SetField = () => undefined; // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же // секции, что и форма подачи; редактирование выключено через . const RecruitingSelectionView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки + // идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы + // при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -80,7 +86,7 @@ const RecruitingSelectionView: React.FC = ({ cont setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -95,7 +101,7 @@ const RecruitingSelectionView: React.FC = ({ cont return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); if (loading) { return
Загрузка заявки…
; diff --git a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx index 14b6032..c5f31ed 100644 --- a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx +++ b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx @@ -43,6 +43,7 @@ import { CandidateSection } from './sections/CandidateSection'; import { VacancySection } from './sections/VacancySection'; import { CandidateInfoSection } from './sections/CandidateInfoSection'; import { AssessmentSection } from './sections/AssessmentSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const ReferralForm: React.FC = ({ requestType, @@ -58,6 +59,14 @@ const ReferralForm: React.FC = ({ const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); + // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффектов идёт + // стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при + // каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + // submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене + // saving/error), а setError стабилен — его и берём. + const { setError: setSubmitError } = submit; const readOnly = !isEditableStatus(requestStatus); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); @@ -83,19 +92,18 @@ const ReferralForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Новая заявка: рекомендатель — сам подающий (подставляем его; подразделение — // из профиля SharePoint SPS-Department, как в «Обходном листе»; должность и @@ -136,8 +144,10 @@ const ReferralForm: React.FC = ({ return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + // context — единственный экземпляр WebPartContext на всё время жизни + // веб-части, approval мемоизирован по editListId: перезапуска эффекта эти + // зависимости не добавляют. + }, [editId, context, approval]); // Выбор другого рекомендателя подтягивает его подразделение из профиля, // должность и организации — из Employee/ByEmail. Оба поля (Position/ diff --git a/src/webparts/requestPortal/forms/Referral/ReferralView.tsx b/src/webparts/requestPortal/forms/Referral/ReferralView.tsx index 15cda2e..55ce994 100644 --- a/src/webparts/requestPortal/forms/Referral/ReferralView.tsx +++ b/src/webparts/requestPortal/forms/Referral/ReferralView.tsx @@ -29,6 +29,7 @@ import { CandidateSection } from './sections/CandidateSection'; import { VacancySection } from './sections/VacancySection'; import { CandidateInfoSection } from './sections/CandidateInfoSection'; import { AssessmentSection } from './sections/AssessmentSection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface IReferralViewProps { context: WebPartContext; @@ -48,6 +49,11 @@ const NOOP_SET: SetField = () => undefined; // те же секции, что и форма подачи; редактирование выключено через // — поля сами становятся неизменяемыми. const ReferralView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки + // идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы + // при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -101,7 +107,7 @@ const ReferralView: React.FC = ({ context, itemId, formKey, setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setError(null); setLoading(false); } @@ -115,7 +121,7 @@ const ReferralView: React.FC = ({ context, itemId, formKey, return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); if (loading) { return
Загрузка заявки…
; diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index d1e0fc4..ea46e50 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -51,6 +51,7 @@ import { getApplicantProfile } from './services/profile.service'; import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME } from './config'; import { RequestSection } from './sections/RequestSection'; import { ContactSection } from './sections/ContactSection'; +import { useStableCallback } from '../../shared/useStableCallback'; /** Текст карточки Planner из данных заявки (соответствует старому buildPlannerNotes). */ function buildPlannerNotes(data: IFormData, code: string): string { @@ -86,6 +87,14 @@ const Ticket1CForm: React.FC = ({ const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); + // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффектов идёт + // стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при + // каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + // submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене + // saving/error), а setError стабилен — его и берём. + const { setError: setSubmitError } = submit; const readOnly = !isEditableStatus(requestStatus); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); @@ -102,20 +111,19 @@ const Ticket1CForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); setExistingAttachments(atts); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Предзаполнение контактов заявителя текущим пользователем — только для новой заявки. // Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком. @@ -140,8 +148,9 @@ const Ticket1CForm: React.FC = ({ return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + // context — единственный экземпляр WebPartContext на всё время жизни + // веб-части, так что перезапуска эффекта эта зависимость не добавляет. + }, [editId, context]); // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx index 628623e..0b3cb4e 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx @@ -41,6 +41,7 @@ import { PlannerTicketService } from './services/plannerTicket.service'; import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config'; import { RequestSection } from './sections/RequestSection'; import { ContactSection } from './sections/ContactSection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface ITicket1CViewProps { context: WebPartContext; @@ -83,6 +84,11 @@ function buildPlannerNotes( // Заявка в отдел 1С только для просмотра (режим согласующего). const Ticket1CView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки + // идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы + // при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -118,7 +124,7 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -133,7 +139,7 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); // Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита // от повторного создания карточки, если решение применяется из нескольких diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 038df5d..462a9fd 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -50,6 +50,7 @@ import { saveTmcTransferRequest, tmcTransferApprovalService } from './submit'; import { loadTmcTransferRequest } from './load'; import { HeaderSection } from './sections/HeaderSection'; import { ItemsSection } from './sections/ItemsSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const TmcTransferForm: React.FC = ({ requestType, @@ -67,6 +68,14 @@ const TmcTransferForm: React.FC = ({ const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); + // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффектов идёт + // стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при + // каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + // submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене + // saving/error), а setError стабилен — его и берём. + const { setError: setSubmitError } = submit; const readOnly = !isEditableStatus(requestStatus); @@ -135,19 +144,19 @@ const TmcTransferForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); setExistingAttachments(atts); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferView.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferView.tsx index 42e3726..a55db2d 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferView.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferView.tsx @@ -40,6 +40,7 @@ import { loadTmcTransferRequest } from './load'; import { tmcTransferApprovalService } from './submit'; import { HeaderSection } from './sections/HeaderSection'; import { ItemsSection } from './sections/ItemsSection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface ITmcTransferViewProps { context: WebPartContext; @@ -54,6 +55,11 @@ interface ITmcTransferViewProps { // Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего). const TmcTransferView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки + // идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы + // при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -107,7 +113,7 @@ const TmcTransferView: React.FC = ({ context, itemId, for setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -122,7 +128,7 @@ const TmcTransferView: React.FC = ({ context, itemId, for return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); if (loading) { return
Загрузка заявки…
; diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx index b493f1f..3dcd50e 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx @@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './mod import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit'; import { loadUnpaidLeaveRequest } from './load'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const UnpaidLeaveForm: React.FC = ({ requestType, @@ -53,6 +54,14 @@ const UnpaidLeaveForm: React.FC = ({ const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); + // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффектов идёт + // стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при + // каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + // submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене + // saving/error), а setError стабилен — его и берём. + const { setError: setSubmitError } = submit; const readOnly = !isEditableStatus(requestStatus); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); @@ -78,19 +87,18 @@ const UnpaidLeaveForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Новая заявка — по умолчанию на себя: подставляем текущего пользователя, // его должность и организации (Employee/ByEmail — см. shared/dictionary/ @@ -116,8 +124,9 @@ const UnpaidLeaveForm: React.FC = ({ return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + // context — единственный экземпляр WebPartContext на всё время жизни + // веб-части, так что перезапуска эффекта эта зависимость не добавляет. + }, [editId, context]); // Выбор другого сотрудника подтягивает его должность и организации. const handleEmployeeChange = (login: string): void => { diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx index 4539760..ff21e05 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx @@ -23,6 +23,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadUnpaidLeaveRequest } from './load'; import { unpaidLeaveApprovalService } from './submit'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface IUnpaidLeaveViewProps { context: WebPartContext; @@ -42,6 +43,11 @@ const NOOP_SET: SetField = () => undefined; // ту же секцию, что и форма подачи; редактирование выключено через // — поля сами становятся неизменяемыми. const UnpaidLeaveView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки + // идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы + // при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -77,7 +83,7 @@ const UnpaidLeaveView: React.FC = ({ context, itemId, for setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setError(null); setLoading(false); } @@ -91,7 +97,7 @@ const UnpaidLeaveView: React.FC = ({ context, itemId, for return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); if (loading) { return
Загрузка заявки…
; diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index 718d129..909697a 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './mod import { saveVacationRequest, vacationApprovalService } from './submit'; import { loadVacationRequest } from './load'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const VacationForm: React.FC = ({ requestType, @@ -53,6 +54,14 @@ const VacationForm: React.FC = ({ const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); + // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффектов идёт + // стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при + // каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + // submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене + // saving/error), а setError стабилен — его и берём. + const { setError: setSubmitError } = submit; const readOnly = !isEditableStatus(requestStatus); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); @@ -78,19 +87,18 @@ const VacationForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Новая заявка — по умолчанию на себя: подставляем текущего пользователя, // его должность и организации (Employee/ByEmail — см. shared/dictionary/ @@ -116,8 +124,9 @@ const VacationForm: React.FC = ({ return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + // context — единственный экземпляр WebPartContext на всё время жизни + // веб-части, так что перезапуска эффекта эта зависимость не добавляет. + }, [editId, context]); // Выбор другого сотрудника подтягивает его должность и организации (как в // исходной pto-tracker, но источник — Employee/ByEmail вместо SPS-JobTitle). diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index 248e7ed..30b7e8b 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -23,6 +23,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadVacationRequest } from './load'; import { vacationApprovalService } from './submit'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface IVacationViewProps { context: WebPartContext; @@ -42,6 +43,11 @@ const NOOP_SET: SetField = () => undefined; // же секцию, что и форма подачи; редактирование выключено через // — поля сами становятся неизменяемыми. const VacationView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки + // идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы + // при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -77,7 +83,7 @@ const VacationView: React.FC = ({ context, itemId, formKey, setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setError(null); setLoading(false); } @@ -91,7 +97,7 @@ const VacationView: React.FC = ({ context, itemId, formKey, return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); if (loading) { return
Загрузка заявки…
; diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index 82459ad..3b8192f 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -51,6 +51,7 @@ import { loadPrefillFromHire } from './prefill'; import { loadWorkplaceRequest } from './load'; import { EmployeeSection } from './sections/EmployeeSection'; import { EquipmentSection } from './sections/EquipmentSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const WorkplaceSetupForm: React.FC = ({ requestType, @@ -71,6 +72,14 @@ const WorkplaceSetupForm: React.FC = ({ const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); const submit = useRequestSubmit(); + // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффектов идёт + // стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при + // каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + // submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене + // saving/error), а setError стабилен — его и берём. + const { setError: setSubmitError } = submit; // Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение. const readOnly = !isEditableStatus(requestStatus); @@ -91,19 +100,19 @@ const WorkplaceSetupForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); setExistingAttachments(atts); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource). useEffect(() => { @@ -122,13 +131,13 @@ const WorkplaceSetupForm: React.FC = ({ }) .catch((e) => { if (alive) { - submit.setError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`); + setSubmitError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`); } }); return () => { alive = false; }; - }, [sourceId]); + }, [sourceId, setSubmitError]); // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь @@ -156,8 +165,9 @@ const WorkplaceSetupForm: React.FC = ({ return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, userEmail]); + // context — единственный экземпляр WebPartContext на всё время жизни + // веб-части, так что перезапуска эффекта эта зависимость не добавляет. + }, [editId, userEmail, context]); // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx index cef8bde..7e583e4 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx @@ -40,6 +40,7 @@ import { loadWorkplaceRequest } from './load'; import { workplaceApprovalService, updateReviewFields } from './submit'; import { EmployeeSection } from './sections/EmployeeSection'; import { EquipmentSection } from './sections/EquipmentSection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface IWorkplaceSetupViewProps { context: WebPartContext; @@ -58,6 +59,11 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error'; // Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель) // на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает). const WorkplaceSetupView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки + // идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы + // при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -122,7 +128,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -137,7 +143,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); // Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования // (по маршруту — непосредственного руководителя) и это текущий пользователь. diff --git a/src/webparts/requestPortal/propertyPane/DebugUserField.tsx b/src/webparts/requestPortal/propertyPane/DebugUserField.tsx index c83b3c9..99a75db 100644 --- a/src/webparts/requestPortal/propertyPane/DebugUserField.tsx +++ b/src/webparts/requestPortal/propertyPane/DebugUserField.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { resolveDebugUser } from '../shared/impersonation'; +import { useStableCallback } from '../shared/useStableCallback'; export interface IDebugUserFieldProps { label: string; @@ -22,6 +23,10 @@ const DEBOUNCE_MS = 400; * чтобы не гадать, кого именно подставили. */ export const DebugUserField: React.FC = ({ label, description, context, initialQuery, onResolved }) => { + // onResolved приходит из панели свойств и пересоздаётся на каждом её рендере: + // в зависимостях эффекта он перезапускал бы поиск, без него — эффект звал бы + // навсегда запомненную первую версию колбэка. Стабильная обёртка снимает оба. + const notifyResolved = useStableCallback(onResolved); const [query, setQuery] = React.useState(initialQuery || ''); const [status, setStatus] = React.useState('idle'); const [found, setFound] = React.useState<{ email: string; displayName: string } | undefined>(undefined); @@ -32,7 +37,7 @@ export const DebugUserField: React.FC = ({ label, descript if (!text) { setStatus('idle'); setFound(undefined); - onResolved('', '', ''); + notifyResolved('', '', ''); return; } @@ -49,25 +54,25 @@ export const DebugUserField: React.FC = ({ label, descript if (cancelled) return; if (isEmail) { setFound(hit); - onResolved(text, text, hit ? hit.displayName : ''); + notifyResolved(text, text, hit ? hit.displayName : ''); } else if (hit) { setStatus('found'); setFound(hit); - onResolved(text, hit.email, hit.displayName); + notifyResolved(text, hit.email, hit.displayName); } else { setStatus('notfound'); setFound(undefined); - onResolved(text, '', ''); + notifyResolved(text, '', ''); } }) .catch(() => { if (cancelled) return; if (isEmail) { - onResolved(text, text, ''); + notifyResolved(text, text, ''); } else { setStatus('notfound'); setFound(undefined); - onResolved(text, '', ''); + notifyResolved(text, '', ''); } }); }, DEBOUNCE_MS); @@ -76,8 +81,7 @@ export const DebugUserField: React.FC = ({ label, descript cancelled = true; window.clearTimeout(timer); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]); + }, [query, context, notifyResolved]); return (
diff --git a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts index 46c5b87..d2d874a 100644 --- a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts +++ b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts @@ -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]); diff --git a/src/webparts/requestPortal/shared/ui/useRequestSubmit.ts b/src/webparts/requestPortal/shared/ui/useRequestSubmit.ts index 7ab07ba..36bbf94 100644 --- a/src/webparts/requestPortal/shared/ui/useRequestSubmit.ts +++ b/src/webparts/requestPortal/shared/ui/useRequestSubmit.ts @@ -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(undefined); const [savedKind, setSavedKind] = useState(undefined); const [error, setError] = useState(undefined); + // Тот же признак «идёт сохранение», но читаемый синхронно. Нужен, чтобы run + // не зависела от состояния и сохраняла неизменную ссылку: формы кладут её + // (и весь submit) в зависимости эффектов. + const savingRef = useRef(false); - const run = async ( + const run = useCallback(async ( k: SubmitKind, save: () => Promise, validate?: () => string | undefined ): Promise => { - 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] + ); } diff --git a/src/webparts/requestPortal/shared/usePersistedQuery.ts b/src/webparts/requestPortal/shared/usePersistedQuery.ts index 9bdc937..f9f3c79 100644 --- a/src/webparts/requestPortal/shared/usePersistedQuery.ts +++ b/src/webparts/requestPortal/shared/usePersistedQuery.ts @@ -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(routeQuery); const debounced = useDebouncedQuery(input); const pushed = React.useRef(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; diff --git a/src/webparts/requestPortal/shared/useStableCallback.ts b/src/webparts/requestPortal/shared/useStableCallback.ts new file mode 100644 index 0000000..d52f104 --- /dev/null +++ b/src/webparts/requestPortal/shared/useStableCallback.ts @@ -0,0 +1,33 @@ +import { useCallback, useLayoutEffect, useRef } from 'react'; + +/** + * Обёртка вокруг колбэка-пропа с НЕИЗМЕННОЙ ссылкой. + * + * Зачем: колбэк, приходящий сверху пропом (`onCodeLoaded`, `onSaved` и т.п.), + * родитель почти всегда пересоздаёт на каждом рендере. Положить такой колбэк + * в зависимости useEffect нельзя — эффект начнёт перезапускаться на ровном + * месте (для форм портала это означает повторную загрузку заявки с сервера). + * Выкинуть его из зависимостей тоже нельзя — эффект зафиксирует ПЕРВУЮ версию + * колбэка и после смены пропа будет звать устаревшее замыкание. + * + * Хук снимает обе проблемы: наружу отдаётся одна и та же функция на всю жизнь + * компонента, а внутри она всегда вызывает АКТУАЛЬНУЮ версию колбэка. Такую + * обёртку можно честно указывать в зависимостях (см. react-hooks/exhaustive-deps). + * + * Аналог `useEffectEvent` из будущих версий React; ограничен колбэками без + * возвращаемого значения — именно такие у нас в пропах. + * + * ВАЖНО: вызывать её во время РЕНДЕРА нельзя — только из эффектов и + * обработчиков событий, иначе можно прочитать колбэк от прошлого рендера. + */ +export function useStableCallback( + 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), []); +}