{ "index": 215, "slug": "editorial-2022-01-mechanism-ssr-csr", "title": "SSR и CSR без двойного состояния: как сохранить границу данных", "excerpt": "Первый экран уже содержит данные, но после гидрации браузер запрашивает их снова и может заменить интерфейс. Разбираем владельцев source, HTML, snapshot и client state, а затем проверяем mismatch до любого автоматического восстановления.", "contentHtml": "
Пользователь открывает страницу. Сервер отдаёт HTML с карточкой товара и ценой. Через мгновение JavaScript запускает ещё один запрос. Цена меняется, кнопка на секунду исчезает, а в Network появляются два ответа. Иногда это выглядит как нормальная гидрация. Иногда второй ответ приходит из другого кеша или с другим правом доступа и подменяет первый экран.
\nОшибка стоит дороже одного запроса. Команда теряет доверие к SSR, увеличивает время до стабильного интерфейса и начинает чинить симптом: отключает серверный рендер, ставит таймер или всегда делает refetch. После этого причина рассинхронизации остаётся. Следующий баг возникает на другом locale, cookie или feature flag.
\nТезис: HTML, сериализованный snapshot и состояние клиента — разные артефакты. У каждого должен быть владелец, версия и момент жизни. Hydrate принимает snapshot как initial state только после проверки контракта. Новый client fetch начинается только из отдельного условия. Если версии не совпали, сначала фиксируем mismatch. Не маскируем его свежим ответом.
\nServer request читает source. Это может быть база, API или серверный кеш. Renderer использует результат и строит HTML. Сам HTML показывает элементы, но не обязан содержать всю информацию, которая нужна клиентскому коду для продолжения работы. Поэтому приложение передаёт рядом сериализованный snapshot.
\nSnapshot — не общий кеш приложения. Это переносимое начальное состояние для конкретного document response. Он должен быть связан с тем же контекстом, который сформировал HTML: route, locale, пользователь, права, feature flags и версия данных. Если эти входы влияют на разметку, их нельзя считать случайными деталями.
\nHydrate присоединяет логику к уже существующей разметке. В React это не второй независимый SSR. Клиентское дерево должно давать тот же первоначальный вывод, что и серверное. В противном случае браузер начинает исправлять несовпадение или сообщает о нём, а команда получает неясный переход между состояниями.
\nПосле принятия snapshot владельцем initial state становится клиентское приложение. Это не означает, что snapshot владеет всей будущей историей данных. Он объясняет только первый экран. Следующий запрос должен иметь новый input и отдельную причину: действие пользователя, истёкший freshness window, подписка или согласованный recovery path.
\nНазовём версию entry-r7. Сервер читает запись, использует её для HTML и кладёт ту же версию в snapshot. Bootstrap сравнивает marker разметки и версию snapshot до создания нового client state. В учебном примере ниже нет React, DOM и сети. Код показывает порядок переходов, а не готовую библиотеку.
const serverEnvelope = {\n source: { version: 'entry-r7', data: { price: 100 } },\n markupVersion: 'entry-r7',\n serializedSnapshot: JSON.stringify({\n schema: 1,\n version: 'entry-r7',\n data: { price: 100 }\n })\n};\n\nconst snapshot = JSON.parse(serverEnvelope.serializedSnapshot);\nconst sameVersion = serverEnvelope.markupVersion === snapshot.version;\n\nif (sameVersion) {\n clientState = snapshot.data;\n // Не запускаем fetch только из-за mount.\n} else {\n diagnostic = {\n kind: 'hydration-mismatch',\n expected: serverEnvelope.markupVersion,\n received: snapshot.version\n };\n // Recovery выбирается отдельно после записи причины.\n}\nВажна не строка сравнения сама по себе. Важен момент, когда она выполняется. Если hook сначала создаёт пустой store, а затем effect читает snapshot, fetch уже получил право изменить экран. Guard оказался слишком поздним. Правильная граница находится до начальной мутации состояния.
\nВерсия также не должна быть декоративным timestamp. Если сервер формирует HTML из записи пользователя A, а snapshot берётся из общего кеша пользователя B, одинаковая строка времени не исправит контракт. Версия должна отвечать на вопрос: из какого чтения и какого контекста появились оба представления?
\n| Артефакт | Кто создаёт | Кто читает | Срок жизни | Опасная подмена |
|---|---|---|---|---|
| source | server request | renderer | до формирования ответа | считать source доступным браузеру |
| HTML | server renderer | пользователь и hydrate | до изменения DOM | считать HTML полным client state |
| serialized snapshot | server response | bootstrap | до принятия initial state | считать его общим кешем всех страниц |
| client state | hydrate | интерактивный UI | по правилам приложения | создать пустым без проверки snapshot |
| client fetch | явный client trigger | браузер | новая операция | запускать на каждый mount |
Таблица нужна для расследования. Если невозможно назвать владельца значения, нельзя надёжно объяснить, почему оно изменилось. Если неизвестен срок жизни, нельзя отличить устаревший snapshot от нового состояния. Эти два вопроса полезнее общего флага loading.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После hydrate виден повторный запрос | Bootstrap не получил initial snapshot или hook его игнорирует | Найти serialized version и условие запуска fetch | Передать named snapshot и пропустить fetch при принятой версии |
| Первый экран меняется без действия пользователя | HTML и snapshot созданы из разных чтений | Сравнить source version, markup marker и snapshot version | Остановить скрытую мутацию, записать mismatch и выбрать recovery path |
| Есть warning о hydration mismatch | Разные данные, locale, timezone, browser API или markup | Сравнить server input и первый client render | Сделать вывод детерминированным или перенести различие после согласованного первого прохода |
| Fetch следует после фильтра | Пользователь создал новый input | Связать запрос с событием и параметрами фильтра | Оставить fetch, но не называть его исправлением гидрации |
Один и тот же URL в Network не объясняет три первые строки. Для расследования сохраняйте порядок: какой ответ пришёл, какая версия была в markup, какую версию прочитал bootstrap и что запустило fetch. Без trigger второй запрос нельзя классифицировать.
\nПредставим, что HTML помечен как entry-r8, а snapshot содержит entry-r7. Без guard приложение может принять snapshot, затем получить свежий ответ и показать его как исправление. Такой путь убирает свидетельство. Мы уже не знаем, почему HTML и данные разошлись: сработал кеш документа, сменился пользователь, неправильно собрался ключ или разные сервисы прочитали source в разные моменты.
Отрицательный путь должен быть заметен. Сначала создаём diagnostic с двумя версиями. Затем не запускаем markup mutation, client state mutation и автоматический fetch. После записи выбираем recovery. Это может быть повторный запрос с тем же контекстом, показ ошибки или контролируемый client-only переход. Выбор зависит от продукта. Нельзя выдавать его за универсальное правило React.
\nДля отсутствующего snapshot причина другая. Тут нечего сравнивать. Следует исправить передачу initial state или явно объявить страницу client-rendered. Для совпавшего snapshot повторный запрос тоже не всегда ошибка: freshness policy может требовать обновления. Но тогда policy должна быть названа, измерима и отделена от hydrate.
\nВ примере версия и JSON придуманы для обучения. Они не являются production-данными. Fixture не запускает React, не открывает браузер, не измеряет latency и не подтверждает результат конкретного framework. Её задача — закрепить порядок: принять snapshot при совпадении и оставить mismatch видимым до mutation.
\nОфициальная документация React описывает серверный рендер HTML и гидрацию существующей разметки. Она не задаёт поля source, markupVersion или политику client fetch. Это проектный контракт. Не следует ссылаться на документацию React как на доказательство того, что конкретный кеш, serializer или recovery path безопасен.
Сервер и клиент могут честно получить разные входы. Cookie, locale, timezone, permission set, random value, текущая дата и feature flag часто меняют вывод. Если различие неизбежно, первый render должен оставаться согласованным, а клиентская часть может изменить интерфейс после hydrate отдельным шагом. Такой двухпроходный путь добавляет работу и может быть заметен на медленной сети. Он не отменяет проверку.
\nНе лечите mismatch через случайный setTimeout, отключение SSR или бездумный suppressHydrationWarning. Эти меры могут скрыть сообщение, но не назначают владельца данных и не объясняют цену расхождения. Сначала сохраните факты. Потом выберите минимальное обратимое изменение.
Решение готово, когда для одного документированного сценария можно показать четыре связанных факта: source и HTML созданы из согласованного контекста; snapshot содержит ту же версию; первый client render не меняет содержимое сам по себе; любой последующий fetch имеет названный trigger и новый input. Для mismatch есть отдельный наблюдаемый результат, а не тихий fallback.
\nПроверка должна проходить в двух ветках. В matching case Network не содержит автоматического запроса только из-за mount, а initial state берётся из snapshot. В mismatch case система сохраняет expected и received, не выполняет mutation до recovery и позволяет найти владельца следующего действия. Только после этого можно оценивать UX, кеширование и производительность конкретного приложения.
\n