Детектив одного бага с модалкой Next.js

Как я искал причину зависшей модалки в Next.js, а нашел архитектурную ошибку в Symfony.

Бывают баги, которые выглядят как мелкая недоработка на фронтенде, а на деле подсвечивают архитектурный долг на бэкенде. Рассказываю, как расследование одного «залипшего» окна привело меня к рефакторингу доменной модели.

Симптом: два близнеца с разным поведением

В админке на Next.js (App Router) есть два абсолютно идентичных компонента удаления: для курсов и для профилей. Оба вызывают Server Action, показывают уведомление и дергают router.refresh().

С курсами всё работало идеально: нажал «Удалить» — окно исчезло, строка пропала. С профилями начался полтергейст: запрос уходит, тост «Профиль удален» появляется, а модальное окно висит. Если нажать F5 — профиль исчезает. Значит, бэкенд его всё-таки удаляет.

Этап 1. Подозреваем фронтенд

Первая мысль: я забыл закрыть модалку. Смотрю в код — вызова setOpen(false) действительно нет ни в одном из компонентов! Почему тогда закрывалось окно с курсами?

Оказалось, оно и не закрывалось само. Просто при удалении курса родительская таблица мгновенно перерисовывалась, строка исчезала из DOM, и диалог размонтировался вместе с ней. А в таблице профилей строка после router.refresh() оставалась на месте, поэтому окно продолжало висеть.

Этап 2. Ищем виновника в кеше Next.js

Добавил setOpen(false). Окно закрываться начало, но строка в таблице всё равно пропадала только после F5. В App Router такое поведение обычно кричит об агрессивном кешировании.

Пошел проверять: 1. Сравнил экшены получения списков курсов и профилей — идентичны. 2. Повесил на страницу export const dynamic = “force-dynamic” — ноль эффекта. 3. Попробовал revalidatePath — таблица упорно отдавала старые данные.

При этом при добавлении нового профиля таблица обновлялась мгновенно. Значит, кеш Next.js тут ни при чем.

Этап 3. Эксперимент с таймером и осознание гонки

Чтобы проверить гипотезу о задержке на сервере, я добавил перед router.refresh() искусственную паузу в 400 миллисекунд. И таблица обновилась.

Стало ясно: я поймал классический Race Condition (состояние гонки). Фронтенд отправлял DELETE-запрос, получал 200 OK и через миллисекунду запрашивал обновленный список. Но бэкенд к этому моменту еще не успевал завершить все операции по удалению.

Этап 4. Развязка на бэкенде

Оставлять setTimeout в продакшене — плохой тон. Полез в код бэкенда на PHP / Symfony. В хендлере удаления профиля я увидел такую картину:

$profile->remove(); $this->profiles->remove($profile); $this->flusher->flush();

$this->messageBus->dispatch( new ProfileRemoved($profile->getId()->getValue()) );

Ошибка крылась в ручной отправке события в MessageBus прямо из хендлера. Диспетчеризация улетала в шину в отрыве от жизненного цикла транзакции Doctrine (или конфликтовала со стандартным механизмом сбора событий во Flusher), из-за чего обновление read-модели происходило с задержкой или в неверном порядке.

Решение

Я перенес регистрацию доменного события внутрь самой сущности Profile, в метод remove():

public function remove(): void { if ($this->role->isAdmin()) { throw new DomainException(‘Cannot remove admin profile.’); } $this->recordEvent(new ProfileRemoved($this->id->getValue())); }

Теперь Flusher сам извлекает событие из сущности и отправляет его в шину строго после успешного коммита транзакции в базу. Ручной вызов MessageBus из хендлера я удалил, а с фронтенда убрал костыльный таймер.

Главный вывод

Если на фронтенде приходится ставить setTimeout для обновления данных после мутации — почти наверняка на бэкенде нарушена согласованность транзакций и событий. Инкапсуляция бизнес-логики и доменных событий внутри сущностей (DDD) не только делает код чище, но и спасает от неочевидных гонок запросов на клиенте.

#разработка #бэкенд #фронтенд #nextjs #symfony #php #архитектура #ddd