В крупномасштабных enterprise-проектах поддержание архитектурных инвариантов исключительно через код-ревью не масштабируется. С выходом React 19 появились новые критические соглашения: Server Actions с директивой 'use server', отказ от ручной мемоизации в пользу React Compiler, переход на нативный ref вместо forwardRef и строгие требования к асинхронным переходам startTransition.
Человеческий фактор неизбежно приводит к проникновению антипаттернов в кодовую базу:
setState напрямую в теле useEffect приводит к двойному циклу рендеринга и деградации производительности кадра.try / catch или отсутствие await перед мутацией базы данных на сервере ведет к падению Node.js процесса или утечке стека вызовов в клиентский бандл.Единственный детерминированный способ гарантировать соблюдение архитектурных контрактов — статический анализ абстрактного синтаксического дерева (AST) с помощью кастомных плагинов ESLint v9 на базе @typescript-eslint/utils. Кастомные правила линтера позволяют не только блокировать коммит на этапе CI/CD, но и применять автоматические исправления (Auto-fixes) без участия инженера.
ESLint v9 оперирует абстрактным синтаксическим деревом (AST) по спецификации ESTree, расширенной типами TypeScript через @typescript-eslint/parser. Вместо линейного анализа регулярными выражениями парсер строит иерархический граф узлов (Program, FunctionDeclaration, CallExpression, JSXElement).
Обход графа AST строится на базе архитектурного паттерна Selector-Visitor (селектор-посетитель). Разработчик правила описывает CSS-подобные селекторы (через движок esquery), а рантайм ESLint вызывает зарегистрированные колбэки при входе (enter) или выходе (exit) из соответствующего узла дерева.
[ Исходный файл .tsx ]
│
▼
[ @typescript-eslint/parser ]
│
▼
[ Типизированное дерево TSESTree ]
│
┌───────────┴───────────┐
▼ ▼
[AST Селекторы] [Scope & TypeChecker]
(esquery: CallExpression) (ts.TypeChecker)
│ │
└───────────┬───────────┘
│
▼
[ Rule Listener ]
│
[ context.report() ]
│
▼
[ RuleFixer: replaceText ]
│
▼
[ Валидированный / Исправленный код ]Каждое правило изолировано и получает объект RuleContext, через который запрашивает:
sourceCode: доступ к тексту файла, токенам и комментариям;parserServices: доступ к API компилятора TypeScript (ts.TypeChecker) для проверки типов символов;report(): метод эмиссии диагностического сообщения с привязкой к диапазону символов (loc или node) и объектом исправления fix().Код с антипаттерном вставляется в AST Explorer с парсером @typescript-eslint/parser для идентификации типов узлов и свойств селектора.
Определение типа правила (problem/suggestion), сообщений об ошибках (messages) и схемы конфигурационных параметров.
Составление esquery-селектора для быстрого таргетинга подозрительных узлов AST (например, вызова useEffect).
Использование ts.TypeChecker для проверки того, что вызываемая функция действительно является хуком React, а не утилитой с похожим именем.
Создание идемпотентной трансформации кода через fixer.replaceTextRange или fixer.insertTextBefore.
Запуск набора тестов с валидными (valid) и невалидными (invalid) фрагментами кода для проверки регрессий и корректности автофиксов.
Почему для выявления антипаттернов в коде React 19 используется селекторный обход AST вместо поиска по регулярным выражениям (RegExp)?
setState внутри useEffect#Вызов setLoading(false) или setData(res) напрямую в верхнем скоупе эффекта без условий завершения приводит к мгновенному синхронному ре-рендеру сразу после отрисовки кадра. Кастомное правило выявляет вызовы диспетчеров стейта внутри функции-эффекта и требует либо выноса логики в обработчики событий, либо защиты через условия/флаги отмены.
В Next.js и React 19 функции, помеченные 'use server', выполняются в защищенном контуре Node.js. Если разработчик забывает поставить await перед обращением к базе данных или забывает завернуть вызов в try / catch, ошибка сервера может остаться необработанной или утечь в открытом виде на клиент. Кастомное правило находит все функции в файлах с директивой 'use server' и проверяет наличие обработки ошибок и валидации входных аргументов через Zod/Valibot.
forwardRef на нативный ref#В React 19 функциональные компоненты принимают ref как обычный пропс первым аргументом, а обертка forwardRef объявлена устаревшей (deprecated). Кастомное правило ESLint с автофиксом за один запуск eslint --fix трансформирует тысячи компонентов: удаляет импорт forwardRef, извлекает компонент из обертки и добавляет ref в список параметров функции.
React Compiler требует, чтобы функции рендера не мутировали внешние структуры данных. Кастомное правило анализирует вызовы методов массивов (push, splice, sort) на аргументах компонентов и генерирует предупреждение, защищая компилятор от принудительного отката (Bailout).
Правило создается с использованием хелпера createRule из библиотеки @typescript-eslint/utils. Это обеспечивает полную типизацию узлов AST, контекста и сообщений об ошибках.
Рассмотрим реализацию правила, блокирующего синхронный вызов сеттера стейта напрямую внутри колбэка useEffect:
import { ESLintUtils, AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
export const createRule = ESLintUtils.RuleCreator(
(name) => `https://internal-docs.enterprise.com/rules/${name}`
);
export const noSyncSetStateInUseEffect = createRule({
name: 'no-sync-setstate-in-useeffect',
meta: {
type: 'problem',
docs: {
description: 'Запрещает синхронный вызов функций обновления состояния напрямую в теле useEffect',
},
fixable: 'code',
schema: [],
messages: {
syncSetStateError:
'Синхронный вызов "{{setterName}}" внутри useEffect вызывает каскадный ре-рендер. Оберните его в асинхронный контекст или условие.',
},
},
defaultOptions: [],
create(context) {
return {
// Селектор esquery: ищем вызов функции с именем useEffect
'CallExpression[callee.name="useEffect"]'(node: TSESTree.CallExpression) {
const effectCallback = node.arguments[0];
// Проверяем, что первый аргумент — это стрелочная функция или FunctionExpression
if (
!effectCallback ||
(effectCallback.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
effectCallback.type !== AST_NODE_TYPES.FunctionExpression)
) {
return;
}
// Анализируем только тело функции (BlockStatement)
if (effectCallback.body.type !== AST_NODE_TYPES.BlockStatement) {
return;
}
// Проверяем инструкции верхнего уровня внутри тела эффекта
for (const statement of effectCallback.body.body) {
if (statement.type === AST_NODE_TYPES.ExpressionStatement) {
const expr = statement.expression;
// Проверяем, является ли выражение вызовом функции вида set*()
if (
expr.type === AST_NODE_TYPES.CallExpression &&
expr.callee.type === AST_NODE_TYPES.Identifier &&
/^set[A-Z]/.test(expr.callee.name)
) {
const setterName = expr.callee.name;
context.report({
node: expr,
messageId: 'syncSetStateError',
data: { setterName },
fix(fixer) {
// Автофикс: оборачивание в queueMicrotask для выноса из синхронного потока
return fixer.replaceText(
statement,
`queueMicrotask(() => { ${context.sourceCode.getText(statement)} });`
);
},
});
}
}
}
},
};
},
});Исходный текст преобразуется в лексемы и древовидную структуру TSESTree с привязкой байтовых диапазонов (range).
Каждый узел содержит точные координаты строки и столбца (loc).
Движок esquery фильтрует узлы CallExpression, где имя вызываемого идентификатора равно 'useEffect'.
Селектор отсекает 99% нерелевантных узлов до запуска JS-логики правила.
Итерация по statement-инструкциям первого уровня, поиск вызовов с префиксом set*.
Игнорируются вызовы внутри вложенных обработчиков addEventListener или промисов .then().
Создание диагностического отчета и вычисление диапазона замены текста через RuleFixer.
Если запущен флаг --fix, ESLint атомарно перезаписывает байтовый диапазон файла.
Написание кастомных правил без автоматических тестов недопустимо, так как ошибочный автофикс может повредить production-код. Библиотека @typescript-eslint/rule-tester предоставляет изолированный тестовый раннер:
import { RuleTester } from '@typescript-eslint/rule-tester';
import { noSyncSetStateInUseEffect } from '../src/rules/no-sync-setstate-in-useeffect';
const ruleTester = new RuleTester({
languageOptions: {
parserOptions: {
ecmaVersion: 2024,
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
},
});
ruleTester.run('no-sync-setstate-in-useeffect', noSyncSetStateInUseEffect, {
valid: [
// Валидный случай 1: setState внутри таймера/микротаски
`
useEffect(() => {
const timer = setTimeout(() => {
setCount(1);
}, 0);
return () => clearTimeout(timer);
}, []);
`,
// Валидный случай 2: setState внутри асинхронного fetch
`
useEffect(() => {
async function loadData() {
const res = await fetch('/api/user');
setUserData(res);
}
loadData();
}, []);
`,
],
invalid: [
{
// Невалидный случай: синхронный вызов в корне тела эффекта
code: `
useEffect(() => {
setCount(42);
}, []);
`,
errors: [
{
messageId: 'syncSetStateError',
data: { setterName: 'setCount' },
},
],
output: `
useEffect(() => {
queueMicrotask(() => { setCount(42); });
}, []);
`,
},
],
});Какое фундаментальное требование предъявляется к методу fix(fixer) при разработке автофиксов в ESLint?
Полагаться только на имя идентификатора (node.callee.name === 'useActionState') опасно. Если разработчик объявил локальную функцию с таким же именем или импортировал стороннюю утилиту с совпадающим названием, правило выдаст ложноположительное срабатывание (False Positive).
// ❌ ПЛОХО: проверка только имени идентификатора в AST
if (node.callee.name === 'useQuery') {
context.report({ node, message: 'Запрещено' });
}
// ✅ ПРАВИЛЬНО: проверка через Scope Analysis или TypeScript TypeChecker
const services = ESLintUtils.getParserServices(context);
const checker = services.program.getTypeChecker();
const tsNode = services.esTreeNodeToTSNodeMap.get(node.callee);
const type = checker.getTypeAtLocation(tsNode);
// Проверяем происхождение символа из официального пакета
const symbol = type.getSymbol();Замена фрагмента кода без учета окружающих точек с запятой, круглых скобок или автоматической вставки точек с запятой (ASI) может сломать файл. Если вставить стрелочную функцию без оборачивания тела, парсер следующего шага выбросит фатальную ошибку компиляции. Всегда проверяйте сгенерированный код через RuleTester в секции invalid[].output.
При удалении устаревших узлов через fixer.remove(node) можно случайно стереть комментарии // eslint-disable-next-line или // TODO:, привязанные к удаляемому диапазону байтов. Рекомендуется использовать методы context.sourceCode.getCommentsInside(node) и переносить важные аннотации.