Проблема обычно видна уже в браузере: сервер отдал карточку, после загрузки JavaScript клиент снова начинает получать те же данные. Пока ответ совпадает, ошибка кажется безобидной. Цена проявляется при изменении источника между двумя фазами: пользователь сначала видит один текст, затем другой, а команда не может сказать, какой запрос владел состоянием первого экрана.
В этой заметке не будем обещать «правильный SSR для всех фреймворков». Нужна более узкая договорённость: серверный запрос, сериализованный snapshot, переход hydrate и возможная клиентская загрузка имеют разные роли. Если snapshot подтверждён той же версией, он владеет начальным состоянием и второй запрос не стартует. Если версия не совпала, расхождение записывается до mutation, а путь восстановления выбирается явно.
Сначала назовите четыре перехода
SSR — это не синоним «данные уже есть». Серверный обработчик читает source и создаёт HTML. Затем рядом с разметкой появляется сериализованное описание начального состояния. Hydrate читает уже отданный snapshot и присоединяет клиентскую логику к тому, что показал сервер. Client fetch — отдельное действие после этой проверки, а не побочный эффект каждого mount.
Версия нужна не как декоративное поле. Она отвечает на конкретный вопрос: HTML, который видит пользователь, и payload, который готов принять клиент, описывают один источник или два разных? Для учебного примера достаточно строки entry-r7. В проекте это может быть revision записи, ETag, версия набора фильтров или иной контракт, который вы умеете получить и сравнить на обеих сторонах границы.
| Фаза | Владелец | Вход | Допустимое действие | Что считать ошибкой |
|---|---|---|---|---|
| SSR/source | server-request | версионированная запись | прочитать source и отдать HTML | выдать HTML без понятной версии source |
| serialized snapshot | serialized-snapshot | payload и версия SSR | сохранить начальное состояние рядом с HTML | потерять версию при сериализации |
| hydrate | browser-transition | версия разметки и snapshot | сравнить до изменения состояния | молча принять разные версии |
| client fetch | browser-transition | явное решение после проверки | пропустить при совпадении или начать выбранный recovery path | запустить повторный запрос «на всякий случай» |
Практическая схема: snapshot принимается один раз
Ниже не React-код. Это локальная fixture из этого пакета: она создаёт объект server envelope, сериализует version вместе с payload и строит план hydrate. У неё нет DOM, сети, файла, таймера или browser API. Поэтому пример нельзя выдавать за трассу браузера и нельзя по нему судить о LCP, latency либо поведении конкретного hook. Его можно использовать для другого: удержать на месте контракт владения данными.
import { runSsrCsrFixture } from "./upgrade-2022-01.mjs";
const fixture = runSsrCsrFixture();
const plan = fixture.matchingHydration;
console.log(plan.stateOwner);
// serialized-snapshot
console.log(plan.clientFetch.action);
// skipped-snapshot-version-matched
if (plan.clientFetch.performed) {
throw new Error("matching snapshot must not start a second fetch");
}
// This is an in-memory teaching model, not a React or Next.js integration.
У совпадающего пути свойство stateOwner равно serialized-snapshot. Поле clientFetch.performed остаётся false. Это не магическая оптимизация и не механизм React. Это проектное правило учебной модели: если HTML и snapshot указывают на один version, начальный экран не запрашивает второй источник. В настоящем приложении такое правило нужно связать с конкретной точкой, где создаётся запрос и где потребляется initial state.
Маршрут: симптом → причина → проверка → действие
- Симптом. После первого HTML появляется второй вызов для тех же данных, либо экран меняет текст сразу после hydrate.
- Причина. Компонент не знает, что initial state уже пришёл с SSR, или HTML и snapshot принадлежат разным версиям source.
- Проверка. Выпишите четыре фазы из таблицы. Для каждой назовите owner, version, место записи и место чтения. Не объединяйте source и client cache в одно неименованное «состояние».
- Проверка границы. До любой mutation сравните version из server markup с version сериализованного payload. Fixture показывает этот порядок в
steps. - Действие при совпадении. Передайте snapshot в initial state и оставьте client fetch пропущенным. Защитите это отдельной проверкой, а не надеждой на порядок эффектов.
- Действие при mismatch. Сохраните обе версии и остановите неявный переход. Затем отдельно выберите refresh, сообщение пользователю или controlled rerender — это уже решение конкретного проекта.
Где закрепить правило в приложении
У правила должны быть два владельца кода. Первый владелец формирует server response: он обязан вернуть HTML и snapshot из одного чтения либо обозначить, что это не гарантируется. Второй владелец — точка client bootstrap: она обязана принять snapshot только после сравнения и не создавать параллельную загрузку по умолчанию. Если эти обязанности спрятаны в нескольких hook, баг становится похож на проблему кеша, хотя на деле пропал контракт между фазами.
Полезно дать snapshot имя, которое нельзя спутать с долгоживущим cache. initialPageSnapshot означает «данные для данного server response», а не «последняя известная запись во всём приложении». Его версия живёт столько же, сколько документ и первый клиентский переход. Дальнейшие обновления могут использовать другой cache и другие правила. Смешение этих уровней рождает второй fetch: один слой считает себя пустым, хотя другой уже показал данные.
Что факт, что fixture и что проектное решение
Факт документации React периода 17: hydrate предназначен для markup, отрендеренного server renderer, и ожидает одинаковое содержимое сервера и клиента; mismatch следует исправлять. Документация не предписывает сравнивать строковую версию и не обещает отсутствие повторных запросов. Fixture — локальная модель этой проверки, а не реализация ReactDOM.hydrate. Project decision — где хранить version, какой recovery path выбрать и когда разрешать новую загрузку.
Ограничение важнее красивой схемы: equal version не доказывает равенство всех полей, прав пользователя, locale или feature configuration. Если эти факторы меняют видимый HTML, они должны стать частью контракта snapshot либо отдельным условием перехода. Сначала добавьте один такой фактор в fixture и тест, потом переносите правило в компонент. Так число «необъяснимых» запросов уменьшается без выдуманного production-результата.
Историческая граница
Материал помещён в январь 2022 года. Для терминов использованы только первичные снимки React documentation от 23 декабря 2021 года и release React 17.0.2 от марта 2021-го. Здесь нет API React 18, Next.js App Router и поздних схем server components. Даже в рамках React 17 version protocol остаётся учебным и проектным слоем поверх требования согласованного server/client markup.
Проверяемые источники
- React documentation: ReactDOM.hydrate, historical source snapshot — первичный снимок официальной документации на commit от 23 декабря 2021 года. Он требует одинаковое содержимое server/client и советует считать mismatch ошибкой; protocol версии из fixture в документе не задан.
- React documentation: ReactDOMServer.renderToString, historical source snapshot — первичный снимок того же официального репозитория от 23 декабря 2021 года: server renderer отдаёт initial HTML, а hydrate присоединяется к существующей разметке. Он не описывает эту учебную JavaScript-схему.
- React 17.0.2: versioned release record — официальная release record от 22 марта 2021 года. Она фиксирует верхнюю границу версии React, о которой можно говорить в январе 2022; материал не использует API React 18 или Next.js App Router.