Что происходит после мутации: revalidatePath и revalidateTag
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Действие отработало, база записала новую строку, ошибок нет — а список на экране прежний. Это не сбой и не задержка: пока действие не сказало, что именно устарело, Next даже не собирает страницу заново. Сказать это можно двумя способами — по адресу через revalidatePath или по метке через revalidateTag.
В части II, в статье про рендер из состояния, у вас были массив и функция render(). Добавили задачу — позвали render. Забыли позвать — экран остался прежним, и это никого не удивляло: перерисовка сама не случается.
Серверное действие забрало preventDefault, сборку FormData и fetch. А эту строчку — не забрало. Только теперь «позвать render» означает «попросить сервер собрать страницу заново», и решение, делать это или нет, Next принимает сам, по одному признаку: пометило ли действие хоть что-нибудь устаревшим.
Модель огрубляет детали, но не суть. Ответ на отправку формы с серверным действием — это не JSON: вместе с результатом функции туда кладётся заново собранная разметка текущего маршрута. Кладётся, только если в ходе действия что-то пометили устаревшим. Иначе Next пропускает сборку страницы целиком: у него нет причин считать, что показанное устарело.
Запись куки внутри действия тоже считается пометкой: после cookies().set() страница пересобирается без единого revalidate — поэтому вход в аккаунт обновляет экран сам.
Отсюда правило, к которому сводится вся статья: после мутации инвалидацию зовёте вы. Двух функций хватает на все случаи, и различаются они тем, что именно помечают — адрес или данные.
Первый способ — назвать маршрут: revalidatePath("/tasks"). Функция приезжает из next/cache, работает только на сервере и возвращает undefined — она ничего не загружает и ничего не ждёт. Всё, что она делает, — кладёт адрес в список устаревшего; список применяется потом, после того как действие вернётся.
Страница задачи осталась старой, хотя показывает те же данные. Это не изъян модели, а поведение настоящей функции: без второго аргумента помечается ровно один адрес. Чтобы накрыть вложенные, нужен revalidatePath("/tasks", "layout") — тогда устареют все адреса, которые делят с /tasks раскладку.
| Вызов | Что помечается устаревшим |
|---|---|
revalidatePath("/tasks") | ровно один адрес — /tasks |
revalidatePath("/tasks", "layout") | /tasks и всё под ним: /tasks/42, /tasks/42/edit |
revalidatePath("/tasks/[id]", "page") | все страницы динамического сегмента разом |
revalidatePath("/", "layout") | весь сайт — самый тяжёлый молоток |
Аргумент — маршрут, а не URL: без домена, без строки запроса. Динамический адрес можно передать в двух видах — конкретным /tasks/42 или шаблоном /tasks/[id], но во втором случае второй аргумент обязателен. Забыли — Next отделается строкой в логе и не сделает ничего: Warning: a dynamic page path "/tasks/[id]" was passed to "revalidatePath", but the "type" parameter is missing.
А вот вариантов у самого type ровно два, и третий компилятор не пропустит.
Диагностика выписывает допустимые значения целиком — читать документацию, чтобы вспомнить два слова, не приходится. А ниже видно, что осуждённый вызов всё равно выполнился: типы живут только на этапе сборки.
Пример молчит, и так задумано: серверной стороны у песочницы нет, а «use server» для компилятора — просто строка. Проверяется здесь другое: что код с такими типами вообще складывается.
Вызов не ходит в базу, не собирает разметку и не возвращает промис — await revalidatePath(...) написать можно, но ждать там нечего. Функция только копит пометки в служебном хранилище текущего запроса. Применяются они разом, когда действие уже вернулось: Next выбрасывает помеченное из кеша и только после этого собирает страницу для ответа.
Отсюда два следствия. Первое: звать revalidatePath несколько раз подряд дёшево, повторы схлопнутся. Второе: внутри самого действия обновлённого кеша ещё нет — оно уже вернулось, когда пометки применяются. Ради случая «прочитать то, что сам только что записал» в Next 16 и появился updateTag, который выбрасывает запись немедленно.
Список задач редко живёт на одном адресе. Он же в шапке счётчиком, он же на главной, он же на странице задачи как «соседние». Перечислять все адреса в каждом действии — работа, которая ломается ровно в тот день, когда появится четвёртый адрес, а про действие никто не вспомнит.
Метка переворачивает связь. Помечается не страница, а сами данные: cacheTag("tasks") ставится там, где их читают, а revalidateTag("tasks", "max") — там, где меняют. Кто из страниц эти данные показывает, знает кеш, и знает точнее вас.
Обратите внимание на последнюю строку: записей стало две, а не три. Страницу /tasks из кеша выбросили, но никто её ещё не запрашивал — и не собирал. Пометка не перерисовывает; она выкидывает устаревшее, а пересчёт случится при следующем визите. Страница /about с другой меткой не шелохнулась.
В настоящем проекте это выглядит так. Метку вешают внутри кешируемой функции, инвалидируют — внутри действия, и ни одного адреса в коде нет.
Второй аргумент — новинка Next 16, и она стоит отдельного абзаца. Раньше revalidateTag принимал одно имя; теперь просят добавить профиль жизни записи ("max", "hours", объект { expire }) — то есть сказать, насколько просроченной запись считать. Старый вызов с одним аргументом работает, но встречает предупреждение об устаревании, а компилятор встречает его раньше.
Диагностика на восьмой строке — про недостающий аргумент, и это тот случай, когда компилятор говорит ровно то же, что скажет Next при выполнении. А ниже видно, чем кончается вызов, который компилятор осудил: код всё равно выполнился, и profile внутри оказался undefined.
| Функция | Что делает | Откуда можно звать |
|---|---|---|
revalidateTag(tag, profile) | помечает записи с меткой просроченными по правилам профиля | действие, обработчик маршрута |
updateTag(tag) | выбрасывает записи немедленно — тот же ответ уже увидит новые данные | только серверное действие |
refresh() | обновляет на клиенте динамические данные, кеша не касается | только серверное действие |
Метку понимает и fetch: fetch(url, { next: { tags: ["tasks"] } }) кладёт ответ в кеш с той же меткой, и revalidateTag выбросит его наравне с «use cache»-функциями.
Обе функции выглядят безобидно — одна строка в конце действия. Ломаются они не в самой строке, а в том, что вокруг неё.
Первое место — забытый await перед записью. Действие вернётся раньше, чем база успеет записать; Next честно выбросит страницу из кеша и честно соберёт её заново — из тех данных, которые есть сейчас, то есть из старых. Хуже того, свежесобранная старая страница ляжет в кеш, и следующего сброса не будет.
Через двести миллисекунд база знает про две задачи, а кеш — про одну, и сам он больше не обновится: пометку уже потратили. Компилятор такое не ловит — тип у insertTask(title) честный Promise<void>, просто его никто не подождал. Ловит линтер: правило no-floating-promises из статьи «typescript-eslint: правила, которых нет у компилятора» существует ровно для таких строк.
Второе место — отрисовка. Позвать инвалидацию из тела серверного компонента заманчиво («данные протухли, обновлю прямо тут»), но Next обрывает такой запрос с ошибкой:
Причина простая: отрисовка обязана быть предсказуемой. Если сборка страницы имеет право менять кеш, два одинаковых запроса перестают давать одинаковый результат, а кеш начинает зависеть от порядка визитов. Инвалидация живёт там, где происходит изменение, — в действии или в обработчике маршрута.
Третье место — код после redirect(). Редирект в Next устроен на брошенном исключении, и всё, что стоит ниже, не выполнится. Пометка должна стоять до него.
Тип redirect — never: компилятор знает, что до следующей строки дело не дойдёт, и подсветит недостижимый код сам. Разбор never был в части III.
Бывает и наоборот: инвалидацию написали правильно, сервер отдаёт свежие данные, а открытая вкладка упорно показывает прежнее. Значит, дело в другом кеше — том, что живёт в браузере. Роутер хранит присланную разметку посещённых и предзагруженных маршрутов, чтобы переход назад был мгновенным (об этом была статья про Link и префетч).
Ответ серверного действия обновляет этот кеш сам — там же приезжает пересобранная разметка. Но если данные изменились мимо действия, обновлять его нечем.
Между второй и третьей строкой вывода экран врал. Чинит это router.refresh() из useRouter(): он просит у сервера разметку текущего маршрута заново, не трогая ни состояние компонентов, ни позицию прокрутки. Кнопка в примере делает ровно это — можете нажать её сами и посмотреть, как кадр догоняет базу.
Сложить всё вместе проще таблицей. Кешей три, и у каждого свой рычаг.
| Что лежит | Где живёт | Чем сбрасывается |
|---|---|---|
| результат кешируемой функции | сервер | revalidateTag, updateTag |
| собранная страница маршрута | сервер | revalidatePath |
| присланная разметка маршрута | вкладка браузера | ответ действия, router.refresh(), refresh() |
Отсюда правило выбора: меняли данные — метка или адрес; данные поменялись без вашего действия (вебхук, вторая вкладка, чужая правка) — router.refresh() на клиенте.
Сроки жизни этих кешей, профили cacheLife и то, какие страницы вообще попадают в кеш, — тема следующей главы, про кеш и прод; сроки и профили разбирает её статья «Cache Components и директива 'use cache'». Здесь достаточно одного: после мутации сброс кеша — ваша строчка кода, и написать её надо в правильном месте.
- После действия Next пересобирает маршрут только тогда, когда в ходе действия что-то пометили устаревшим; иначе сборка страницы пропускается и обновлять экран нечем.
revalidatePath(path, type?)помечает адрес: без второго аргумента — ровно один маршрут, с"layout"— все вложенные; для шаблона/tasks/[id]второй аргумент обязателен.revalidateTagпомечает данные, а не страницы: метку ставят при чтении черезcacheTag, и одно имя накрывает все адреса, которые эти данные показывают.- В Next 16 у
revalidateTagпоявился второй аргумент — профиль; для немедленного сброса внутри действия естьupdateTag, для обновления клиентских данных —refresh(). - Пометка ставится после
awaitзаписи и доredirect; из отрисовки серверного компонента звать её нельзя — Next обрывает запрос ошибкой. - Кешей три: данные и собранные страницы на сервере плюс разметка маршрутов во вкладке; последнюю обновляет ответ действия или
router.refresh().
1/4Серверное действие записало задачу в базу и вернулось. Ни revalidatePath, ни revalidateTag в нём нет. Что приедет в ответе на отправку формы?
2/4Действие изменило задачу и вызвало revalidatePath("/tasks"). У посетителя открыт адрес /tasks/42 — страница этой самой задачи. Обновится ли она?
3/4В проекте на Next 16 внутри действия написали revalidateTag("tasks") — с одним аргументом, как привыкли. Что будет?
4/4Данные в базе поменял вебхук от платёжной системы — никакого серверного действия из этой вкладки не было. Что обновит открытую страницу?
Действие без обновления
Форма добавляет задачу, действие честно пишет в базу, ошибок нет — а список на экране не меняется до перезагрузки. Найдите недостающую строку и допишите её.
Не хватает пометки: без неё Next не пересобирает маршрут после действия и в ответе нет новой разметки.
Строка стоит после await, а не до: пометка, поставленная раньше записи, приведёт к тому, что страница пересоберётся из ещё не изменившихся данных — и старый результат снова ляжет в кеш.
Ранний выход при пустом заголовке ничего не помечает, и это правильно: ничего не изменилось — нечего и сбрасывать.
Три адреса, одна метка
Список задач показан на трёх адресах, и действие честно перечисляет все три. Завтра появится четвёртый — и про него забудут. Перепишите инвалидацию на метку: где её поставить при чтении и как позвать при записи.
Метка ставится там, где данные читают, — внутри кешируемой функции. Тогда действию хватает одного имени, а список адресов исчезает вместе с обязанностью его поддерживать.
Теперь новая страница со списком получает свежие данные бесплатно: она зовёт ту же getTasks, а значит попадает под ту же метку. Обратная сторона — метку надо придумывать осмысленно: "tasks" сбрасывает весь список сразу, и если страниц с задачами станет много, метку дробят по смыслу, например cacheTag("task-" + id) для одной задачи.
Пометка не помогла
Действие содержит и запись, и revalidatePath, но список обновляется через раз: иногда сразу, иногда после второй отправки. Объясните, почему, и почините.
Ошибок здесь две, и они усиливают друг друга. Первая — порядок: пометка стоит до записи. Вторая — забытый await: insertTask возвращает промис, действие его не ждёт и завершается раньше, чем база что-то записала.
В итоге Next сбрасывает кеш и собирает страницу из ещё не изменившихся данных, а запись доезжает после — и старая страница остаётся в кеше до следующей пометки. «Через раз» получается потому, что вторая отправка сбрасывает кеш уже с учётом первой записи.
Компилятор такое не ловит: тип выражения insertTask(title) честный Promise<void>, просто результат никому не нужен. Ловит линтер — правилом no-floating-promises.